diff --git a/docs/AlarmTracking.md b/docs/AlarmTracking.md index 8bf83275..4f00551d 100644 --- a/docs/AlarmTracking.md +++ b/docs/AlarmTracking.md @@ -68,7 +68,7 @@ are set explicitly. Leaving them unset shipped them as **null** on every event | `ConditionName` | the leaf / display name (e.g. `HR200`) | Where the short human-readable name lives | | `ConditionClassId` | always **`BaseConditionClassType`** | Part 9's "no condition class modelled" value. Unset shipped `NodeId.Null` (#475) | | `ConditionClassName` | always **`"BaseConditionClass"`** | Matches `ConditionClassId`. Unset shipped empty text (#475) | -| `Quality` | the condition's **source-data quality** — tracks the native source's connectivity (`Good` / `Bad`) | A pure annotation; never alters Active/Acked/Retain. Unset shipped the accidentally-Good default (#477) — see below | +| `Quality` | the condition's **source-data quality** — native tracks the source's connectivity (`Good` / `Bad`); scripted takes the worst of its input tags' qualities (#478) | A pure annotation; never alters Active/Acked/Retain. Unset shipped the accidentally-Good default (#477) — see below | **Why `BaseConditionClassType` and not `ProcessConditionClassType`.** We hold no per-alarm classification at the materialize seam, and `ConditionClassId` is a wire contract clients bucket on. @@ -139,9 +139,41 @@ driver emits **no alarm transitions** (the feed goes silent). The quality theref warm for failover) and publishes **no `/alerts` row** — driver comms health already has its own status surface (`IDriverHealthPublisher`); a row per condition would be alarm-fatigue. -**Scripted alarms** are script-computed and always live in this scope, so they materialize `Good` and are -not connectivity-driven. Deriving a scripted condition's quality from the worst of its input tags' -qualities is a deferred follow-up (Layer 3). +**Scripted alarms (Layer 3, #478).** A scripted condition's state is computed from one or more input tags, +so its `Quality` is the **worst** quality across those inputs at evaluation time ("can I trust this +condition's state?") — mirroring the native OT semantic: + +- The mux now forwards each input's source quality (`DependencyValueChanged.Quality`), and the scripted host + pushes it into the engine's read cache (previously every mux value was treated as `Good`). +- The `ScriptedAlarmEngine` computes the worst input quality each evaluation. A **real transition** carries + it on the emitted event → `ScriptedAlarmHostActor.ToSnapshot` projects it (so a transition fired while an + input is `Uncertain` does not clobber quality back to `Good`). +- A **Bad input freezes the condition** (`AreInputsReady` holds its state — no transition), exactly like a + comms-lost native driver. So a quality-bucket change with no transition is emitted as + `EmissionKind.QualityChanged` and routed to the **same** dedicated `AlarmQualityUpdate → WriteAlarmQuality` + node path native uses (quality only, one Part 9 event on a bucket change, **no `/alerts` row**, no + historian write). `ScriptedAlarmSource` skips `QualityChanged` so it never fabricates a phantom + `IAlarmSource` event. +- An input that has **not been published yet** (cold start) is *not* a quality signal (that is the readiness + guard's job) — it contributes `Good`, so scripted conditions don't flash `Bad` at every deploy. The first + actually-`Bad` published value flips the bucket and annotates. + +**Coverage boundary (#478 as shipped).** Scripted quality tracks input tags whose driver **publishes a +data change carrying a Bad/Uncertain `StatusCode`** (e.g. an OpcUaClient input forwarding a server's +per-item Bad). It does **not** yet cover a driver **comms loss**: a poll driver (Modbus/S7) whose device +goes unreachable emits only `ConnectivityChanged` and goes *silent* on the value feed (see +`DriverInstanceActor.Reconnecting`), so the scripted engine keeps the last-known Good value and the +condition stays `Good`. Bridging driver connectivity into scripted inputs — the symmetric of the native +`OnDriverConnectivityChanged` path above, plus resolving the null-value/cold-start asymmetry (a runtime +`Bad` with a null value is currently indistinguishable from cold start and contributes `Good`) — is tracked +as the Layer-4 follow-up (#481). + +Guards: `ScriptedAlarmEngineTests` (transition carries `Uncertain`; `Bad` input with no transition emits +`QualityChanged(Bad)`; restore emits `QualityChanged(Good)`; unchanged bucket emits nothing), +`ScriptedAlarmSourceTests.QualityChanged_emission_raises_no_alarm_event`, +`DependencyMuxActorTests.Publish_quality_is_forwarded_on_DependencyValueChanged`, and +`ScriptedAlarmHostActorTests` (`Bad_quality_dependency_publishes_AlarmQualityUpdate_and_no_alerts`, +`Transition_snapshot_carries_worst_input_quality`). Wire-level guard: `NativeAlarmEventIdentityFieldDeliveryTests.Condition_event_Quality_tracks_source_connectivity_on_the_wire` subscribes with a `[Quality, Message]` clause (Quality is declared on `ConditionType`) and asserts a diff --git a/docs/plans/2026-07-17-alarm-condition-quality-477-design.md b/docs/plans/2026-07-17-alarm-condition-quality-477-design.md index d5700872..7af95234 100644 --- a/docs/plans/2026-07-17-alarm-condition-quality-477-design.md +++ b/docs/plans/2026-07-17-alarm-condition-quality-477-design.md @@ -125,3 +125,63 @@ their snapshot quality stays `Good`. the issue. - `docs/AlarmTracking.md` §"Condition event identity fields" gains a Quality subsection (Good/Bad semantics, annotation-not-state-change, quality-bucket change fires an event). + +## Layer 3 — scripted worst-of-input quality (Gitea #478, implemented 2026-07-17) + +**Problem.** A scripted alarm is computed from one or more input tags. Its condition should report the +**worst** quality of those inputs ("can I trust this condition's state?"), not the hardcoded `Good` Layer 1 +left at `ScriptedAlarmHostActor.ToSnapshot`. + +**Two blockers discovered in the live path (both silently discard quality):** +1. `DependencyMuxActor.OnAttributeValuePublished` builds `VirtualTagActor.DependencyValueChanged` **without** + the `AttributeValuePublished.Quality` it already carries. +2. `ScriptedAlarmHostActor.OnDependencyChanged` pushes each mux value into the engine's upstream with a + **hardcoded `0u` (Good)** StatusCode. +So even a `Bad` driver value reached the scripted engine as Good — Layer 3 has to plumb quality first. + +**Design (mirrors Layer 2's native OT semantic through the scripted channel):** +- **Plumb quality end-to-end.** `DependencyValueChanged` gains `OpcUaQuality Quality` (defaulted `Good`, so the + virtual-tag engine's calls are unchanged); the mux forwards `msg.Quality`; `OnDependencyChanged` maps it to a + StatusCode (`Good→0`, `Uncertain→0x40000000`, `Bad→0x80000000`) on the pushed `DataValueSnapshot`. +- **Engine computes the worst input quality** each evaluation (over the refilled read cache, **before** the + `AreInputsReady` short-circuit so a `Bad` input is still observed) and carries it as + `ScriptedAlarmEvent.WorstInputStatusCode` (a raw `uint` StatusCode — `Core.ScriptedAlarms` doesn't reference + Commons, so it stays in the engine's existing StatusCode vocabulary; the host maps it to `OpcUaQuality`). +- **Transitions carry the current worst quality** → `ToSnapshot` projects it (no clobber-back-to-Good when a + transition fires while an input is `Uncertain`). +- **Quality-only changes emit out of band.** A `Bad` input freezes the condition (`AreInputsReady` returns + false → no transition), exactly like a comms-lost native driver — so quality can't ride a transition. The + engine tracks the last worst-quality **bucket** per alarm and, when the bucket changes with **no** transition + emission, emits a new `EmissionKind.QualityChanged` event. The host routes that to the **existing** Layer 2 + `OpcUaPublishActor.AlarmQualityUpdate → IOpcUaAddressSpaceSink.WriteAlarmQuality` path (sets ONLY Quality, + one Part 9 event on a bucket change, **no `/alerts` row**, no historian write). No new sink surface. +- **`ScriptedAlarmSource` (the `IAlarmSource` fan-out adapter) skips `QualityChanged`** — quality is delivered + through the dedicated node path, never as a phantom `AlarmEventArgs` (which would materialize/historize a + native condition). + +**Files (Layer 3):** +- `src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/Part9StateMachine.cs` — `EmissionKind.QualityChanged`. +- `src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs` — worst-of-input, bucket tracking, + `WorstInputStatusCode` on the event, quality-only emission. +- `src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmSource.cs` — skip `QualityChanged`. +- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs` — `DependencyValueChanged.Quality`. +- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs` — forward `msg.Quality`. +- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs` — push real quality; + `ToSnapshot` maps `WorstInputStatusCode`; `OnEngineEmission` routes `QualityChanged → AlarmQualityUpdate`. + +**Tests (RED-first):** engine — transition carries `Uncertain` worst; `Bad` input with no transition emits +`QualityChanged(Bad)`; restore emits `QualityChanged(Good)`; no spurious emit when the bucket is unchanged. +`ScriptedAlarmSource` — `QualityChanged` raises no `OnAlarmEvent`. Mux — `DependencyValueChanged` carries the +published quality. Host — `Bad` dependency → `AlarmQualityUpdate(Bad)`, no `/alerts` publish; `ToSnapshot` +maps the event's worst quality. + +**Coverage boundary → Layer 4 (#481).** L3 covers inputs whose driver **publishes a Bad/Uncertain-status +data change** (the mux quality path). It does **not** cover a driver **comms loss**: a poll driver +(Modbus/S7) whose device goes unreachable emits only `ConnectivityChanged` and goes silent on the value feed +(`DriverInstanceActor.Reconnecting`), so the scripted engine keeps the last-known Good value and the +condition stays Good — the same silent-feed problem native solved in L2, but native's `OnDriverConnectivityChanged` +bridge fans only to **native** condition nodes (`_alarmNodeIdByDriverRef`), not into the mux the scripted +engine reads. Bridging connectivity into scripted inputs — plus resolving the null-value/cold-start +asymmetry (a runtime `Bad` with a null value is currently indistinguishable from cold start and contributes +`Good`) and its ripple into virtual-tag quality — is **Gitea #481 (Layer 4)**. Found by the post-implementation +code review; the code as shipped faithfully implements #478's written scope (mux-delivered input quality). diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/Part9StateMachine.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/Part9StateMachine.cs index 23643061..1de8baf3 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/Part9StateMachine.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/Part9StateMachine.cs @@ -377,4 +377,9 @@ public enum EmissionKind Enabled, Disabled, CommentAdded, + /// #478 — the worst-of-input source quality changed bucket (Good/Uncertain/Bad) with no + /// accompanying Part 9 state transition. Delivered out of band via the dedicated + /// WriteAlarmQuality node path, never through the IAlarmSource fan-out (it is not a + /// state change and must not materialize or historize a condition). + QualityChanged, } diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs index 1dac6ac2..19828a87 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs @@ -62,6 +62,17 @@ public sealed class ScriptedAlarmEngine : IDisposable private readonly ConcurrentDictionary _scratchByAlarmId = new(StringComparer.Ordinal); + /// + /// #478 — last emitted worst-of-input quality bucket per alarm (0 = Good, 1 = Uncertain, + /// 2 = Bad), computed each evaluation over the refilled read cache. A change in this bucket + /// with no accompanying Part 9 state transition drives a standalone + /// emission (a Bad input freezes the condition — no + /// transition — so quality can't ride one, exactly like a comms-lost native driver). Only ever + /// mutated under _evalGate; cleared alongside on load/dispose. + /// + private readonly ConcurrentDictionary _lastQualityBucketByAlarmId = + new(StringComparer.Ordinal); + /// /// Compile cache for every alarm predicate. Routes 's /// calls through the @@ -203,6 +214,7 @@ public sealed class ScriptedAlarmEngine : IDisposable // have changed (different Inputs, different Logger), so any reuse would be // unsafe. _scratchByAlarmId.Clear(); + _lastQualityBucketByAlarmId.Clear(); // Dispose every compiled-predicate ALC from the prior generation BEFORE we // recompile this one. Skipping this is what made the earlier fix a // no-op in production. @@ -412,7 +424,7 @@ public sealed class ScriptedAlarmEngine : IDisposable // OnEvent dispatch until after Release() so a slow subscriber or a // subscriber that re-enters the engine doesn't block / deadlock. if (result.Emission != EmissionKind.None) - pending = BuildEmission(state, result.State, result.Emission); + pending = BuildEmission(state, result.State, result.Emission, LastWorstStatus(alarmId)); else if (result.NoOpReason is { } reason) { // The Part9StateMachine remarks promise a diagnostic log line for @@ -513,13 +525,27 @@ public sealed class ScriptedAlarmEngine : IDisposable _ => new AlarmScratch(state.Inputs, state.Logger, _clock)); RefillReadCache(scratch.ReadCache, state.Inputs); + // #478 — worst OPC UA quality across the alarm's inputs, computed BEFORE the readiness + // short-circuit so an outright-Bad input is still observed. A bucket change with no state + // transition is delivered out of band as a QualityChanged emission (see below). + var worstStatus = WorstInputStatus(scratch.ReadCache); + var qualityBucketChanged = TrackQualityBucket(state.Definition.AlarmId, worstStatus); + // Cold-start guard — skip the predicate when any referenced upstream tag has no // cached value yet (the upstream subscription hasn't delivered its first push). // Without this, predicates that cast `(double)ctx.GetTag(path).Value` throw NRE on // every tick until the cache fills, spamming the log with identical stack traces. // Bad quality is treated the same: the input isn't available at the predicate's // expected type, so the only defensible move is to hold the prior condition state. - if (!AreInputsReady(scratch.ReadCache)) return seed; + if (!AreInputsReady(scratch.ReadCache)) + { + // The condition is frozen (can't trust its state), but its source quality just changed + // bucket — annotate it out of band so a comms-lost / Bad-input scripted condition reports + // Bad, mirroring the native OT path. + if (qualityBucketChanged) + pendingEmissions.Add(BuildQualityEmission(state, seed, worstStatus)); + return seed; + } var context = scratch.Context; @@ -544,10 +570,19 @@ public sealed class ScriptedAlarmEngine : IDisposable } var result = Part9StateMachine.ApplyPredicate(seed, predicateTrue, nowUtc); - if (result.Emission != EmissionKind.None) + var transition = result.Emission != EmissionKind.None + ? BuildEmission(state, result.State, result.Emission, worstStatus) + : null; + if (transition is not null) { - var evt = BuildEmission(state, result.State, result.Emission); - if (evt is not null) pendingEmissions.Add(evt); + // A real transition carries the current worst quality so the projected full-snapshot + // write doesn't clobber quality back to Good (e.g. a transition while an input is Uncertain). + pendingEmissions.Add(transition); + } + else if (qualityBucketChanged) + { + // No transition (or a Suppressed one) but the quality bucket moved — annotate out of band. + pendingEmissions.Add(BuildQualityEmission(state, result.State, worstStatus)); } return result.State; } @@ -599,7 +634,8 @@ public sealed class ScriptedAlarmEngine : IDisposable /// done by AFTER the gate is /// released. /// - private ScriptedAlarmEvent? BuildEmission(AlarmState state, AlarmConditionState condition, EmissionKind kind) + private ScriptedAlarmEvent? BuildEmission( + AlarmState state, AlarmConditionState condition, EmissionKind kind, uint worstInputStatus) { // Suppressed kind means shelving ate the emission — we don't fire for subscribers // but the state record still advanced so startup recovery reflects reality. @@ -629,9 +665,89 @@ public sealed class ScriptedAlarmEngine : IDisposable // Carry the per-alarm durable-historization opt-out through to subscribers. The historian // adapter honors it to suppress ONLY the durable sink write; the live alerts fan-out is // unaffected (it is not gated on this flag). - HistorizeToAveva: state.Definition.HistorizeToAveva); + HistorizeToAveva: state.Definition.HistorizeToAveva, + // #478 — the worst input quality at evaluation time rides the transition so the projected + // full snapshot keeps quality consistent (no clobber-to-Good). + WorstInputStatusCode: worstInputStatus); } + /// + /// #478 — build a standalone event carrying the new + /// worst-of-input quality. Emitted when the quality bucket moved but no Part 9 transition fired + /// (a Bad input freezes the condition; a Suppressed/None transition also leaves state unchanged). + /// The host routes it to the dedicated WriteAlarmQuality node path (annotate quality only, + /// no /alerts row, no historian write); the fan-out skips it. + /// + private ScriptedAlarmEvent BuildQualityEmission( + AlarmState state, AlarmConditionState condition, uint worstInputStatus) + => new( + AlarmId: state.Definition.AlarmId, + EquipmentPath: state.Definition.EquipmentPath, + AlarmName: state.Definition.AlarmName, + Kind: state.Definition.Kind, + Severity: state.Definition.Severity, + Message: MessageTemplate.Resolve(state.Definition.MessageTemplate, TryLookup), + Condition: condition, + Emission: EmissionKind.QualityChanged, + TimestampUtc: _clock(), + HistorizeToAveva: state.Definition.HistorizeToAveva, + WorstInputStatusCode: worstInputStatus); + + /// Worst OPC UA StatusCode across a refilled read cache — the entry with the highest severity + /// bits (top 2). An input with no value yet (null snapshot/value — the cold-start placeholder, or a + /// not-yet-published upstream) is NOT a quality signal: it means "no data", which the + /// guard already handles by holding the condition. Counting it as Bad here + /// would flash every scripted condition Bad at deploy until the first push and would flood the quality + /// path with load-time annotations, so unread inputs are skipped (contribute Good). Empty / all-unread + /// cache ⇒ Good (0). + private static uint WorstInputStatus(IReadOnlyDictionary cache) + { + uint worst = 0u; + var worstSeverity = 0u; + foreach (var kv in cache) + { + if (kv.Value is null || kv.Value.Value is null) continue; // no data yet — not a quality signal + var status = kv.Value.StatusCode; + var severity = status >> 30; + if (severity > worstSeverity) + { + worstSeverity = severity; + worst = status; + } + } + return worst; + } + + /// Update the tracked worst-quality bucket for an alarm; return true iff the 3-state bucket + /// (0 = Good, 1 = Uncertain, 2 = Bad) changed from the last observed value. Only called under + /// _evalGate. + private bool TrackQualityBucket(string alarmId, uint worstStatus) + { + var bucket = QualityBucket(worstStatus); + var prior = _lastQualityBucketByAlarmId.TryGetValue(alarmId, out var b) ? b : 0; // default Good + _lastQualityBucketByAlarmId[alarmId] = bucket; + return bucket != prior; + } + + /// Collapse an OPC UA StatusCode's 2 severity bits (00/01/10/11) to a 3-state quality bucket + /// (0 = Good, 1 = Uncertain, 2 = Bad). + private static int QualityBucket(uint statusCode) + { + var severity = statusCode >> 30; + return severity >= 2 ? 2 : (int)severity; + } + + /// #478 — a canonical worst StatusCode for an alarm's last-observed quality bucket, used by + /// the operator-command + shelving-timer emission paths (which don't re-read inputs) so an ack / + /// shelve while an input is Bad still carries Bad rather than resetting the condition to Good. + private uint LastWorstStatus(string alarmId) + => (_lastQualityBucketByAlarmId.TryGetValue(alarmId, out var bucket) ? bucket : 0) switch + { + 2 => 0x80000000u, // Bad + 1 => 0x40000000u, // Uncertain + _ => 0u, // Good + }; + /// /// Invoke the handler for a built emission. Must be /// called OUTSIDE _evalGate: a slow subscriber would otherwise @@ -708,7 +824,7 @@ public sealed class ScriptedAlarmEngine : IDisposable _alarms[id] = state with { Condition = result.State }; if (result.Emission != EmissionKind.None) { - var evt = BuildEmission(state, result.State, result.Emission); + var evt = BuildEmission(state, result.State, result.Emission, LastWorstStatus(id)); if (evt is not null) pending.Add(evt); } } @@ -780,6 +896,7 @@ public sealed class ScriptedAlarmEngine : IDisposable _alarms.Clear(); _alarmsReferencing.Clear(); _scratchByAlarmId.Clear(); + _lastQualityBucketByAlarmId.Clear(); // Dispose every compiled-predicate ALC so the engine's shutdown actually // releases the emitted assemblies. The drain above ensures no evaluator is // mid-call; CompiledScriptCache.Dispose internally guards against use-after- @@ -851,7 +968,11 @@ public sealed record ScriptedAlarmEvent( EmissionKind Emission, DateTime TimestampUtc, string? Comment = null, - bool HistorizeToAveva = true); + bool HistorizeToAveva = true, + // #478 — the worst OPC UA StatusCode across the alarm's input tags at evaluation time. A raw uint + // (Core.ScriptedAlarms does not reference Commons/OpcUaQuality); the host maps it to OpcUaQuality by + // the top-2 severity bits. Default 0u == Good keeps every existing constructor call unchanged. + uint WorstInputStatusCode = 0u); /// /// Upstream source abstraction — intentionally identical shape to the virtual-tag diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmSource.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmSource.cs index 3e59119b..045701c6 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmSource.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmSource.cs @@ -81,6 +81,11 @@ public sealed class ScriptedAlarmSource : IAlarmSource, IDisposable { if (_disposed) return; + // #478 — QualityChanged is a source-quality annotation, not a Part 9 state change. It is delivered + // out of band via the dedicated WriteAlarmQuality node path; surfacing it here would fabricate a + // phantom AlarmEventArgs that materializes / historizes a condition. Swallow it. + if (evt.Emission == EmissionKind.QualityChanged) return; + foreach (var sub in _subscriptions.Values) { if (!Matches(sub, evt)) continue; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs index 960fb3e9..5c465063 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs @@ -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)); } + /// #478 — map the 3-state to an OPC UA StatusCode (severity bits) + /// for the engine's read cache. The inverse of . + private static uint StatusFromQuality(OpcUaQuality quality) => quality switch + { + OpcUaQuality.Bad => 0x80000000u, + OpcUaQuality.Uncertain => 0x40000000u, + _ => 0u, // Good + }; + + /// #478 — collapse an engine (top-2 + /// severity bits) back to the 3-state the Commons snapshot / node path use. + 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)); /// Maps the Core onto the Commons /// mirror (the Commons assembly can't see the Core enum). diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs index cd2eb52d..5badbfdf 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs @@ -100,7 +100,7 @@ public sealed class DependencyMuxActor : ReceiveActor // space carries thousands of tags and only a fraction feed virtual-tag expressions. return; } - var dep = new VirtualTagActor.DependencyValueChanged(msg.FullReference, msg.Value, msg.TimestampUtc); + var dep = new VirtualTagActor.DependencyValueChanged(msg.FullReference, msg.Value, msg.TimestampUtc, msg.Quality); foreach (var sub in subscribers) { sub.Tell(dep); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs index b9d6e513..d733a264 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs @@ -29,7 +29,11 @@ public sealed class VirtualTagActor : ReceiveActor { public const string ScriptLogsTopic = "script-logs"; - public sealed record DependencyValueChanged(string TagId, object? Value, DateTime TimestampUtc); + /// A dependency's value changed. (#478) carries the source + /// driver's OPC UA quality so the scripted-alarm host can derive a condition's worst-of-input quality; + /// it defaults to , and the virtual-tag engine ignores it. + public sealed record DependencyValueChanged( + string TagId, object? Value, DateTime TimestampUtc, OpcUaQuality Quality = OpcUaQuality.Good); /// Re-assert the last emitted value/quality to the parent bridge, bypassing the value dedup. /// Sent by on every apply (deploy) to a surviving child: a deploy diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ScriptedAlarmEngineTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ScriptedAlarmEngineTests.cs index 396f8ac5..1748070a 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ScriptedAlarmEngineTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ScriptedAlarmEngineTests.cs @@ -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; + + /// 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). + [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(); + 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 + } + + /// 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. + [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(); + 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); + } + + /// Restoring a Good input after a Bad one flips the bucket back and emits QualityChanged(Good). + [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(); + 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 + } + + /// A value change that keeps the same quality bucket (Good→Good) emits no QualityChanged. + [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(); + 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 cond, int timeoutMs = 2000) { var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs); diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ScriptedAlarmSourceTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ScriptedAlarmSourceTests.cs index dda21ce5..f3851326 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ScriptedAlarmSourceTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ScriptedAlarmSourceTests.cs @@ -107,6 +107,28 @@ public sealed class ScriptedAlarmSourceTests events.Count.ShouldBe(0); } + /// #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). + [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(); + 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(); + } + /// Verifies that AcknowledgeAsync routes to the engine with a default user. [Fact] public async Task AcknowledgeAsync_routes_to_engine_with_default_user() diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmHostActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmHostActorTests.cs index 7fb9d2f4..5ced6fa9 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmHostActorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmHostActorTests.cs @@ -664,6 +664,57 @@ public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase evtFalse.HistorizeToAveva.ShouldBe(false); } + /// #478 — a Bad-quality dependency (no threshold crossing → no state transition) drives the + /// scripted condition's Quality out of band: the host publishes an + /// (Bad, Uns realm) and NO /alerts transition — quality is an annotation, not a state change. + [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(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(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)); + } + + /// #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. + [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(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(m => m.State.Active, Timeout); + state.AlarmNodeId.ShouldBe("alm-1"); + state.State.Quality.ShouldBe(OpcUaQuality.Uncertain); + } + /// OneShotShelve transition carries the operator's identity: an operator-driven OneShotShelve /// drives OneShotShelveAsync — the resulting ("Shelved") /// on the alerts topic must carry User == cmd.User (the acting operator), NOT the generic diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/DependencyMuxActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/DependencyMuxActorTests.cs index c026cee1..48495808 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/DependencyMuxActorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/DependencyMuxActorTests.cs @@ -149,6 +149,21 @@ public sealed class DependencyMuxActorTests : RuntimeActorTestBase subscriber.ExpectMsg().TagId.ShouldBe("ref-1"); } + /// #478 — the mux forwards the published source quality onto DependencyValueChanged so the + /// scripted-alarm host can derive a condition's worst-of-input quality. + [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().Quality.ShouldBe(OpcUaQuality.Bad); + } + private sealed class EchoSumEvaluator : ZB.MOM.WW.OtOpcUa.Commons.Engines.IVirtualTagEvaluator { /// Evaluates the expression by summing all dependency values as integers.