8c5e2be92e
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
177 lines
12 KiB
Markdown
177 lines
12 KiB
Markdown
# 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-level** — `NodeManagerAlarmSourceFieldsTests`: 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 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.
|