feat(alarms): scripted condition Quality from worst-of-input tag quality (#478)
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:
@@ -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);
|
||||
|
||||
@@ -107,6 +107,28 @@ public sealed class ScriptedAlarmSourceTests
|
||||
events.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>#478 — a QualityChanged engine emission (source-quality bucket change, no state transition)
|
||||
/// must NOT surface through the IAlarmSource fan-out: quality is delivered out of band via the
|
||||
/// dedicated node path, never as an AlarmEventArgs (which would materialize / historize a condition).</summary>
|
||||
[Fact]
|
||||
public async Task QualityChanged_emission_raises_no_alarm_event()
|
||||
{
|
||||
var (engine, source, up) = await BuildAsync();
|
||||
using var _e = engine;
|
||||
using var _s = source;
|
||||
|
||||
var events = new List<AlarmEventArgs>();
|
||||
source.OnAlarmEvent += (_, e) => events.Add(e);
|
||||
await source.SubscribeAlarmsAsync([], TestContext.Current.CancellationToken);
|
||||
|
||||
// Drive HighTemp's input Bad: the predicate freezes (no transition), but the worst-quality bucket
|
||||
// moves Good→Bad → the engine emits QualityChanged. The source must swallow it.
|
||||
up.Push("Temp", 50, 0x80000000u);
|
||||
await Task.Delay(200);
|
||||
|
||||
events.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that AcknowledgeAsync routes to the engine with a default user.</summary>
|
||||
[Fact]
|
||||
public async Task AcknowledgeAsync_routes_to_engine_with_default_user()
|
||||
|
||||
Reference in New Issue
Block a user