diff --git a/docs/AlarmTracking.md b/docs/AlarmTracking.md
index 8bf83275..8860e8f8 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,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
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..722d471b 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,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.
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.