feat(alarms): scripted condition Quality from worst-of-input tag quality (#478)
v2-ci / build (pull_request) Successful in 3m48s
v2-ci / unit-tests (pull_request) Failing after 11m0s

Layer 3 of #477: a scripted alarm's condition Quality now reflects the WORST
quality across its input tags, mirroring the native OT semantic (#477 L2).

Plumbing (quality was silently discarded twice on the live path):
- VirtualTagActor.DependencyValueChanged gains Quality (defaulted Good); the
  DependencyMuxActor forwards the published AttributeValuePublished.Quality it
  already carried; ScriptedAlarmHostActor.OnDependencyChanged pushes the real
  quality into the engine (was hardcoded 0u/Good).

Engine (Core.ScriptedAlarms):
- ScriptedAlarmEngine computes worst-of-input quality each eval (skipping
  not-yet-published inputs, which are a readiness concern, not a quality signal)
  and carries it on ScriptedAlarmEvent.WorstInputStatusCode.
- A real transition carries the current worst quality so ToSnapshot's full
  snapshot doesn't clobber quality back to Good (e.g. transition while Uncertain).
- A Bad input freezes the condition (no transition), like a comms-lost native
  driver; a quality-bucket change with no transition emits the new
  EmissionKind.QualityChanged, routed to the existing #477-L2
  AlarmQualityUpdate -> WriteAlarmQuality node path (quality only, no /alerts
  row, no historian write). ScriptedAlarmSource skips QualityChanged so it never
  fabricates a phantom IAlarmSource event.

Host: ToSnapshot maps WorstInputStatusCode -> OpcUaQuality; OnEngineEmission
routes QualityChanged out of band.

Tests (TDD, RED-first): engine worst-carry + Bad/restore QualityChanged +
unchanged-bucket-no-emit; source swallows QualityChanged; mux forwards quality;
host Bad-dep -> AlarmQualityUpdate(no alerts) + transition snapshot carries worst.
Docs: AlarmTracking.md Layer-3 section + design doc.

Closes #478
This commit is contained in:
Joseph Doherty
2026-07-17 15:56:06 -04:00
parent 6dda0549e2
commit 8c5e2be92e
12 changed files with 449 additions and 21 deletions
@@ -1005,6 +1005,104 @@ public sealed class ScriptedAlarmEngineTests
// If Dispose threw or hung, the WaitAsync would surface it.
}
// -------------------------------------------------------------------------
// #478 — scripted condition Quality from worst-of-input tag quality (Layer 3)
// -------------------------------------------------------------------------
private const uint StatusUncertain = 0x40000000u;
private const uint StatusBad = 0x80000000u;
/// <summary>A transition that fires while an input is Uncertain carries that worst input quality on the
/// emitted event (so the projected snapshot doesn't clobber quality back to Good).</summary>
[Fact]
public async Task Transition_carries_worst_input_quality()
{
var up = new FakeUpstream();
up.Set("Temp", 50);
using var eng = Build(up, out _);
await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
TestContext.Current.CancellationToken);
var events = new List<ScriptedAlarmEvent>();
eng.OnEvent += (_, e) => events.Add(e);
// Uncertain is "ready" (only outright Bad short-circuits), so the predicate runs and fires Activated.
up.Push("Temp", 150, StatusUncertain);
await WaitForAsync(() => events.Any(e => e.Emission == EmissionKind.Activated));
var activated = events.First(e => e.Emission == EmissionKind.Activated);
(activated.WorstInputStatusCode >> 30).ShouldBe(1u); // Uncertain severity bucket
}
/// <summary>A Bad input freezes the condition (no transition) but the worst-quality bucket changed
/// Good→Bad, so the engine emits a standalone QualityChanged carrying Bad.</summary>
[Fact]
public async Task Bad_input_without_transition_emits_QualityChanged_Bad()
{
var up = new FakeUpstream();
up.Set("Temp", 50); // predicate false → inactive
using var eng = Build(up, out _);
await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
TestContext.Current.CancellationToken);
var events = new List<ScriptedAlarmEvent>();
eng.OnEvent += (_, e) => events.Add(e);
up.Push("Temp", 50, StatusBad);
await WaitForAsync(() => events.Any(e => e.Emission == EmissionKind.QualityChanged));
var q = events.First(e => e.Emission == EmissionKind.QualityChanged);
(q.WorstInputStatusCode >> 30).ShouldBeGreaterThanOrEqualTo(2u); // Bad severity bucket
// No phantom state transition accompanied the quality change.
events.Any(e => e.Emission == EmissionKind.Activated || e.Emission == EmissionKind.Cleared)
.ShouldBeFalse();
eng.GetState("HighTemp")!.Active.ShouldBe(AlarmActiveState.Inactive);
}
/// <summary>Restoring a Good input after a Bad one flips the bucket back and emits QualityChanged(Good).</summary>
[Fact]
public async Task Restoring_good_input_emits_QualityChanged_Good()
{
var up = new FakeUpstream();
up.Set("Temp", 50);
using var eng = Build(up, out _);
await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
TestContext.Current.CancellationToken);
var events = new List<ScriptedAlarmEvent>();
eng.OnEvent += (_, e) => events.Add(e);
up.Push("Temp", 50, StatusBad);
await WaitForAsync(() => events.Any(e => e.Emission == EmissionKind.QualityChanged
&& (e.WorstInputStatusCode >> 30) >= 2u));
events.Clear();
up.Push("Temp", 50, 0u); // Good again, still below threshold
await WaitForAsync(() => events.Any(e => e.Emission == EmissionKind.QualityChanged));
var q = events.First(e => e.Emission == EmissionKind.QualityChanged);
(q.WorstInputStatusCode >> 30).ShouldBe(0u); // Good bucket
}
/// <summary>A value change that keeps the same quality bucket (Good→Good) emits no QualityChanged.</summary>
[Fact]
public async Task No_QualityChanged_when_bucket_unchanged()
{
var up = new FakeUpstream();
up.Set("Temp", 50);
using var eng = Build(up, out _);
await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
TestContext.Current.CancellationToken);
var events = new List<ScriptedAlarmEvent>();
eng.OnEvent += (_, e) => events.Add(e);
up.Push("Temp", 60, 0u); // Good→Good, still below threshold → no transition, no quality change
await Task.Delay(150, TestContext.Current.CancellationToken);
events.ShouldNotContain(e => e.Emission == EmissionKind.QualityChanged);
}
private static async Task WaitForAsync(Func<bool> cond, int timeoutMs = 2000)
{
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);