Files
lmxopcua/docs/plans/2026-07-17-alarm-condition-quality-477-design.md
T
Joseph Doherty 043e237dba
v2-ci / build (pull_request) Successful in 5m42s
v2-ci / unit-tests (pull_request) Failing after 13m11s
docs(alarms): state #478 coverage boundary + file Layer-4 comms-loss follow-up (#481)
Post-implementation review (HIGH finding) noted #478's mux-delivered
input-quality path 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, so a scripted alarm keeps the last Good value.
The code as shipped faithfully implements #478's written scope (worst of input
tags' qualities via the dependency mux). The comms-loss bridge for scripted
alarms (symmetric of native #477-L2, plus the null-value/cold-start asymmetry
and its VT-quality ripple) is tracked as #481. Docs updated in
AlarmTracking.md + the design doc.
2026-07-17 16:07:55 -04:00

14 KiB

Alarm condition Quality (issue #477) — design

Status: implemented (L1+L2) · Date: 2026-07-17 · Issue: #477 (follow-up chain #473 → #475 → #477) Scope decision: Layer 1 + Layer 2, Bad-direct, annotate-only. Layer 3 (scripted worst-of-input) deferred → #478.

Problem

AlarmConditionState.Quality is never assigned anywhere in src/ — neither by OtOpcUaNodeManager.MaterialiseAlarmCondition nor by the WriteAlarmCondition transition path. Because StatusCodes.Good == 0x00000000, default(StatusCode) is Good, so the field is accidentally valid — clients parse it, but it reports Good unconditionally regardless of the backing tag's real quality.

This is a wrong-value bug, not the null-value bug class of #473/#475. Part 9 defines ConditionType.Quality as "the quality of the Condition's source data". OT impact: when a native alarm's device goes offline (comms lost) the condition still reports Quality = Good, so an operator (or an HMI bucketing on IsGood) cannot distinguish "genuinely not active" from "we have lost contact and do not know".

Why it isn't a 2-line default (confirmed by code)

  1. Alarm-bearing raw tags have no value variable. AddressSpaceApplier materialises a raw tag as either a condition node (tag.Alarm is not null) or a value variable (else) — never both, since they'd share the same s=<RawPath> NodeId. So WriteValue (the only path carrying OpcUaQuality) is never invoked for an alarm node. Quality has nowhere to land today.
  2. The alarm channel is quality-blind. AlarmEventArgs (driver → host) and AlarmConditionSnapshot (host → SDK sink) both carry no quality field.
  3. On comms-loss the alarm feed goes silent. DriverInstanceActor on DisconnectObserved detaches the alarm subscription and re-enters Reconnecting — no transition event ever arrives to carry Bad. So the "device offline" signal must come from driver connectivity, independently of alarm transitions.

Decisions (the issue's open questions)

# Question Decision Rationale
1 Does an alarm tag get quality today? No Confirmed above — new plumbing required.
2 Direct status code vs. policy map Direct Bad on comms-loss; Good on reconnect Matches how a value variable would read; unambiguous for IsGood bucketing.
3 Does Bad also suppress transitions / touch Retain? No — annotate only A comms-lost active condition must stay active + retained. Silently clearing an active alarm on comms-loss is the unsafe direction. Quality is a pure annotation; the Active/Ack/Retain state machine is untouched.
4 Scripted alarms: worst-of-inputs quality? Deferred (Layer 3) Scripted conditions stay Good. Filed as a follow-up issue.

Architecture — reuse the existing publish path, add no sink method

The key move: do not add a new IOpcUaAddressSpaceSink method. A new sink-interface surface would have to be forwarded through DeferredAddressSpaceSink or it is inert on driver hosts (the F10b prod-inertness trap). Instead the NativeAlarmProjector becomes the single owner of per-condition state and quality, and a connectivity change re-projects the last snapshot with a swapped quality through the existing AlarmStateUpdate → OpcUaPublishActor → WriteAlarmCondition path.

Layer 1 — make Quality a real, plumbed field

  • AlarmConditionSnapshot (Commons) gains OpcUaQuality Quality (last positional param, default OpcUaQuality.Good so scripted callers and existing tests keep compiling; Commons already knows OpcUaQuality via IOpcUaAddressSpaceSink).
  • MaterialiseAlarmCondition sets alarm.Quality.Value at build time: native → BadWaitingForInitialData (honest until connectivity confirms Good, matching the value-variable "waiting for initial data" convention), scripted → Good (script-computed, always live in this scope).
  • WriteAlarmCondition projects StatusFromQuality(state.Quality) onto condition.Quality.Value (+ SourceTimestamp).
  • The delta-gate (AlarmConditionDelta / ReadConditionDelta / ToConditionDelta) gains a Quality member, so a Good→Bad bucket change is a genuine delta and fires a Part 9 condition event.

Layer 2 — drive native quality from driver connectivity

  • DriverInstanceActor: new public sealed record ConnectivityChanged(string DriverInstanceId, bool Connected). Context.Parent.Tell it on Become(Connected) entry (true) and on the transitions into Reconnecting (DisconnectObserved / ForceReconnect) (false). Fire-and-forget, mirrors DeltaApplied.
  • NativeAlarmProjector: per-node state becomes (bool Active, bool Acked, OpcUaQuality Quality). Project(transition) preserves the current quality; new ProjectQuality(nodeId, quality) preserves Active/Acked and swaps only the quality, returning a full snapshot.
  • DriverHostActor: Receive<ConnectivityChanged> iterates _alarmNodeIdByDriverRef for that driver instance and Tells one AlarmStateUpdate per condition with the re-projected snapshot (connected ? Good : Bad). Ungated — both redundancy nodes track their own driver's comms, matching the existing "condition write stays ungated (Secondary keeps its address space warm)" rule. No /alerts row for a quality-only change — driver health already has its own status/alerts surface via IDriverHealthPublisher; a row here would be alarm-fatigue.

Scripted alarms are unaffected: they are not driver instances, receive no ConnectivityChanged, and their snapshot quality stays Good.

Files

Layer 1

  • src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/AlarmConditionSnapshot.cs
  • src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs (MaterialiseAlarmCondition, WriteAlarmCondition, AlarmConditionDelta/ReadConditionDelta/ToConditionDelta)
  • src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs (ToSnapshot — Quality=Good, or rely on default)

Layer 2

  • src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs
  • src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/NativeAlarmProjector.cs
  • src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs

Tests (TDD, RED-first)

  1. Wire-level (the issue's suggested guard) — extend NativeAlarmEventIdentityFieldDeliveryTests (OpcUaServer.IntegrationTests): active alarm → event Quality.IsGood; driver disconnect → condition event Quality.IsGood == false; reconnect → Good. Verify RED against pre-fix.
  2. Node-levelNodeManagerAlarmSourceFieldsTests: materialise sets Quality (native BadWaitingForInitialData, scripted Good); WriteAlarmCondition projects snapshot quality and fires on a quality-bucket change only.
  3. NativeAlarmProjector unit: ProjectQuality keeps Active/Acked + swaps quality; Project preserves quality.
  4. DriverInstanceActor: Connected entry Tells ConnectivityChanged(true); DisconnectObserved Tells ConnectivityChanged(false).
  5. DriverHostActor: ConnectivityChanged(false) pushes a Bad-quality AlarmStateUpdate to every condition of that driver instance.

Deferred / notes

  • Layer 3 (scripted worst-of-input quality) → Gitea #478.
  • Implementation note: L2 uses a dedicated IOpcUaAddressSpaceSink.WriteAlarmQuality path (not a full-snapshot re-projection). Rationale: a connectivity change must set only Quality; re-projecting a full snapshot would clobber a cold condition's severity/message and can't annotate a condition that never fired a transition. The new sink method is forwarded through DeferredAddressSpaceSink (the F10b inertness trap) — auto-verified by DeferredSinkForwardingReflectionTests (reflection guard) + its realm-discriminator guard.
  • Test-harness note: the new DriverInstanceActor → parent ConnectivityChanged Tell polluted existing parent-TestProbe assertions in 3 DriverInstanceActor*Tests files; those tests now parent.IgnoreMessages(m => m is ConnectivityChanged) since they assert on data/alarm/discovery forwards, not connectivity.
  • Bad_NoCommunication vs generic Bad: v1 maps OpcUaQuality.Bad → StatusCodes.Bad; refining StatusFromQuality to emit BadNoCommunication for the comms-loss case is a one-line nicety, noted in 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 qualityToSnapshot 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.csEmissionKind.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.csDependencyValueChanged.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. ScriptedAlarmSourceQualityChanged 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).