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:
@@ -278,11 +278,31 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
|
||||
|
||||
private void OnDependencyChanged(VirtualTagActor.DependencyValueChanged msg)
|
||||
{
|
||||
// Feed the live value into the upstream the engine subscribes from. StatusCode 0 = Good; the
|
||||
// mux only forwards values it received from a driver publish, so we treat them as Good-quality.
|
||||
_upstream.Push(msg.TagId, new DataValueSnapshot(msg.Value, 0u, msg.TimestampUtc, msg.TimestampUtc));
|
||||
// Feed the live value into the upstream the engine subscribes from. #478 — carry the source
|
||||
// driver's quality (mapped to an OPC UA StatusCode) so the engine can derive a scripted
|
||||
// condition's worst-of-input quality; a Bad/Uncertain input is no longer silently treated as Good.
|
||||
_upstream.Push(msg.TagId,
|
||||
new DataValueSnapshot(msg.Value, StatusFromQuality(msg.Quality), msg.TimestampUtc, msg.TimestampUtc));
|
||||
}
|
||||
|
||||
/// <summary>#478 — map the 3-state <see cref="OpcUaQuality"/> to an OPC UA StatusCode (severity bits)
|
||||
/// for the engine's read cache. The inverse of <see cref="QualityFromStatus"/>.</summary>
|
||||
private static uint StatusFromQuality(OpcUaQuality quality) => quality switch
|
||||
{
|
||||
OpcUaQuality.Bad => 0x80000000u,
|
||||
OpcUaQuality.Uncertain => 0x40000000u,
|
||||
_ => 0u, // Good
|
||||
};
|
||||
|
||||
/// <summary>#478 — collapse an engine <see cref="ScriptedAlarmEvent.WorstInputStatusCode"/> (top-2
|
||||
/// severity bits) back to the 3-state <see cref="OpcUaQuality"/> the Commons snapshot / node path use.</summary>
|
||||
private static OpcUaQuality QualityFromStatus(uint statusCode) => (statusCode >> 30) switch
|
||||
{
|
||||
0 => OpcUaQuality.Good,
|
||||
1 => OpcUaQuality.Uncertain,
|
||||
_ => OpcUaQuality.Bad,
|
||||
};
|
||||
|
||||
private void OnEngineEmission(EngineEmission msg)
|
||||
{
|
||||
var e = msg.Event;
|
||||
@@ -294,6 +314,21 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
|
||||
return;
|
||||
}
|
||||
|
||||
// #478 — QualityChanged is a source-quality annotation (worst-of-input bucket moved with no Part 9
|
||||
// state transition — e.g. an input went Bad, freezing the condition). Route it to the dedicated
|
||||
// WriteAlarmQuality node path (sets ONLY Quality, one Part 9 event on a bucket change): NO full-state
|
||||
// projection (would clobber severity/message) and NO /alerts row (it is not a state transition, so it
|
||||
// must not historize). Same out-of-band quality path native comms-loss uses (#477 Layer 2).
|
||||
if (e.Emission == EmissionKind.QualityChanged)
|
||||
{
|
||||
_publishActor.Tell(new OpcUaPublishActor.AlarmQualityUpdate(
|
||||
AlarmNodeId: e.AlarmId,
|
||||
Quality: QualityFromStatus(e.WorstInputStatusCode),
|
||||
TimestampUtc: e.TimestampUtc,
|
||||
Realm: AddressSpaceRealm.Uns));
|
||||
return;
|
||||
}
|
||||
|
||||
// Bridge to OPC UA: project the FULL Part 9 condition state (enabled/active/acked/confirmed/
|
||||
// shelving/severity/message) onto the materialised condition node via the Commons snapshot.
|
||||
// e.AlarmId is the materialised condition's NodeId (T14 aligned it to the ScriptedAlarmId).
|
||||
@@ -540,9 +575,10 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
|
||||
Shelving: MapShelving(e.Condition.Shelving.Kind),
|
||||
Severity: (ushort)SeverityToInt(e.Severity),
|
||||
Message: e.Message,
|
||||
// #477 — scripted conditions are script-computed and always live in this scope, so they report Good.
|
||||
// Deriving quality from the worst of a script's input-tag qualities is a deferred follow-up (Layer 3).
|
||||
Quality: OpcUaQuality.Good);
|
||||
// #478 — the condition's Quality is the worst quality across the script's input tags at evaluation
|
||||
// time (carried on the event by the engine). A transition fired while an input is Uncertain projects
|
||||
// Uncertain here so the full-snapshot write doesn't clobber quality back to Good.
|
||||
Quality: QualityFromStatus(e.WorstInputStatusCode));
|
||||
|
||||
/// <summary>Maps the Core <see cref="ShelvingKind"/> onto the Commons <see cref="AlarmShelvingKind"/>
|
||||
/// mirror (the Commons assembly can't see the Core enum).</summary>
|
||||
|
||||
Reference in New Issue
Block a user