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()
|
||||
|
||||
+51
@@ -664,6 +664,57 @@ public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase
|
||||
evtFalse.HistorizeToAveva.ShouldBe(false);
|
||||
}
|
||||
|
||||
/// <summary>#478 — a Bad-quality dependency (no threshold crossing → no state transition) drives the
|
||||
/// scripted condition's Quality out of band: the host publishes an <see cref="OpcUaPublishActor.AlarmQualityUpdate"/>
|
||||
/// (Bad, Uns realm) and NO <c>/alerts</c> transition — quality is an annotation, not a state change.</summary>
|
||||
[Fact]
|
||||
public void Bad_quality_dependency_publishes_AlarmQualityUpdate_and_no_alerts()
|
||||
{
|
||||
var publish = CreateTestProbe();
|
||||
var mux = CreateTestProbe();
|
||||
var alerts = CreateTestProbe();
|
||||
SubscribeToAlerts(alerts);
|
||||
|
||||
var (host, _) = Spawn(publish, mux);
|
||||
host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan(id: "alm-1", depRef: "M.T", threshold: 90) }));
|
||||
mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(Timeout); // load completed
|
||||
|
||||
// Baseline is Good (unread inputs are not a quality signal — no load-time annotation). Drive the
|
||||
// input Bad with a value below threshold: the predicate freezes (no transition), but the worst-input
|
||||
// quality bucket moves Good→Bad → a QualityChanged annotation flows out of band.
|
||||
host.Tell(new VirtualTagActor.DependencyValueChanged("M.T", 10, DateTime.UtcNow, OpcUaQuality.Bad));
|
||||
|
||||
var q = publish.FishForMessage<OpcUaPublishActor.AlarmQualityUpdate>(m => m.Quality == OpcUaQuality.Bad, Timeout);
|
||||
q.AlarmNodeId.ShouldBe("alm-1");
|
||||
q.Realm.ShouldBe(AddressSpaceRealm.Uns);
|
||||
|
||||
// A pure quality change is NOT a state transition: no /alerts row.
|
||||
alerts.ExpectNoMsg(TimeSpan.FromMilliseconds(400));
|
||||
}
|
||||
|
||||
/// <summary>#478 — when a transition fires while an input is Uncertain, the projected full snapshot
|
||||
/// carries that worst-of-input quality (not a clobbered Good), so the condition node reflects that its
|
||||
/// state is derived from untrustworthy inputs.</summary>
|
||||
[Fact]
|
||||
public void Transition_snapshot_carries_worst_input_quality()
|
||||
{
|
||||
var publish = CreateTestProbe();
|
||||
var mux = CreateTestProbe();
|
||||
var alerts = CreateTestProbe();
|
||||
SubscribeToAlerts(alerts);
|
||||
|
||||
var (host, _) = Spawn(publish, mux);
|
||||
host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan(id: "alm-1", depRef: "M.T", threshold: 90) }));
|
||||
mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(Timeout); // load completed
|
||||
|
||||
// Above threshold (activates) but Uncertain quality — Uncertain is "ready", so the predicate runs.
|
||||
host.Tell(new VirtualTagActor.DependencyValueChanged("M.T", 99, DateTime.UtcNow, OpcUaQuality.Uncertain));
|
||||
|
||||
var state = publish.FishForMessage<OpcUaPublishActor.AlarmStateUpdate>(m => m.State.Active, Timeout);
|
||||
state.AlarmNodeId.ShouldBe("alm-1");
|
||||
state.State.Quality.ShouldBe(OpcUaQuality.Uncertain);
|
||||
}
|
||||
|
||||
/// <summary>OneShotShelve transition carries the operator's identity: an operator-driven OneShotShelve
|
||||
/// drives <c>OneShotShelveAsync</c> — the resulting <see cref="AlarmTransitionEvent"/>(<c>"Shelved"</c>)
|
||||
/// on the alerts topic must carry <c>User == cmd.User</c> (the acting operator), NOT the generic
|
||||
|
||||
@@ -149,6 +149,21 @@ public sealed class DependencyMuxActorTests : RuntimeActorTestBase
|
||||
subscriber.ExpectMsg<VirtualTagActor.DependencyValueChanged>().TagId.ShouldBe("ref-1");
|
||||
}
|
||||
|
||||
/// <summary>#478 — the mux forwards the published source quality onto DependencyValueChanged so the
|
||||
/// scripted-alarm host can derive a condition's worst-of-input quality.</summary>
|
||||
[Fact]
|
||||
public void Publish_quality_is_forwarded_on_DependencyValueChanged()
|
||||
{
|
||||
var mux = Sys.ActorOf(DependencyMuxActor.Props());
|
||||
var sub = CreateTestProbe();
|
||||
mux.Tell(new DependencyMuxActor.RegisterInterest(new[] { "tag-1" }, sub.Ref));
|
||||
|
||||
mux.Tell(new DriverInstanceActor.AttributeValuePublished(
|
||||
"driver-1", "tag-1", 10, OpcUaQuality.Bad, DateTime.UtcNow));
|
||||
|
||||
sub.ExpectMsg<VirtualTagActor.DependencyValueChanged>().Quality.ShouldBe(OpcUaQuality.Bad);
|
||||
}
|
||||
|
||||
private sealed class EchoSumEvaluator : ZB.MOM.WW.OtOpcUa.Commons.Engines.IVirtualTagEvaluator
|
||||
{
|
||||
/// <summary>Evaluates the expression by summing all dependency values as integers.</summary>
|
||||
|
||||
Reference in New Issue
Block a user