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
@@ -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()