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:
+26
-4
@@ -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,31 @@ 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.
|
||||
|
||||
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
|
||||
|
||||
@@ -125,3 +125,52 @@ 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.
|
||||
|
||||
Reference in New Issue
Block a user