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 |
|
| `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) |
|
| `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) |
|
| `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
|
**Why `BaseConditionClassType` and not `ProcessConditionClassType`.** We hold no per-alarm
|
||||||
classification at the materialize seam, and `ConditionClassId` is a wire contract clients bucket on.
|
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
|
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.
|
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
|
**Scripted alarms (Layer 3, #478).** A scripted condition's state is computed from one or more input tags,
|
||||||
not connectivity-driven. Deriving a scripted condition's quality from the worst of its input tags'
|
so its `Quality` is the **worst** quality across those inputs at evaluation time ("can I trust this
|
||||||
qualities is a deferred follow-up (Layer 3).
|
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`
|
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
|
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.
|
the issue.
|
||||||
- `docs/AlarmTracking.md` §"Condition event identity fields" gains a Quality subsection (Good/Bad
|
- `docs/AlarmTracking.md` §"Condition event identity fields" gains a Quality subsection (Good/Bad
|
||||||
semantics, annotation-not-state-change, quality-bucket change fires an event).
|
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.
|
||||||
|
|||||||
@@ -377,4 +377,9 @@ public enum EmissionKind
|
|||||||
Enabled,
|
Enabled,
|
||||||
Disabled,
|
Disabled,
|
||||||
CommentAdded,
|
CommentAdded,
|
||||||
|
/// <summary>#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
|
||||||
|
/// <c>WriteAlarmQuality</c> node path, never through the <c>IAlarmSource</c> fan-out (it is not a
|
||||||
|
/// state change and must not materialize or historize a condition).</summary>
|
||||||
|
QualityChanged,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,17 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
|||||||
private readonly ConcurrentDictionary<string, AlarmScratch> _scratchByAlarmId =
|
private readonly ConcurrentDictionary<string, AlarmScratch> _scratchByAlarmId =
|
||||||
new(StringComparer.Ordinal);
|
new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #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
|
||||||
|
/// <see cref="EmissionKind.QualityChanged"/> 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 <c>_evalGate</c>; cleared alongside <see cref="_alarms"/> on load/dispose.
|
||||||
|
/// </summary>
|
||||||
|
private readonly ConcurrentDictionary<string, int> _lastQualityBucketByAlarmId =
|
||||||
|
new(StringComparer.Ordinal);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Compile cache for every alarm predicate. Routes <see cref="LoadAsync"/>'s
|
/// Compile cache for every alarm predicate. Routes <see cref="LoadAsync"/>'s
|
||||||
/// <see cref="ScriptEvaluator{TContext, TResult}.Compile"/> calls through the
|
/// <see cref="ScriptEvaluator{TContext, TResult}.Compile"/> calls through the
|
||||||
@@ -203,6 +214,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
|||||||
// have changed (different Inputs, different Logger), so any reuse would be
|
// have changed (different Inputs, different Logger), so any reuse would be
|
||||||
// unsafe.
|
// unsafe.
|
||||||
_scratchByAlarmId.Clear();
|
_scratchByAlarmId.Clear();
|
||||||
|
_lastQualityBucketByAlarmId.Clear();
|
||||||
// Dispose every compiled-predicate ALC from the prior generation BEFORE we
|
// Dispose every compiled-predicate ALC from the prior generation BEFORE we
|
||||||
// recompile this one. Skipping this is what made the earlier fix a
|
// recompile this one. Skipping this is what made the earlier fix a
|
||||||
// no-op in production.
|
// no-op in production.
|
||||||
@@ -412,7 +424,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
|||||||
// OnEvent dispatch until after Release() so a slow subscriber or a
|
// OnEvent dispatch until after Release() so a slow subscriber or a
|
||||||
// subscriber that re-enters the engine doesn't block / deadlock.
|
// subscriber that re-enters the engine doesn't block / deadlock.
|
||||||
if (result.Emission != EmissionKind.None)
|
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)
|
else if (result.NoOpReason is { } reason)
|
||||||
{
|
{
|
||||||
// The Part9StateMachine remarks promise a diagnostic log line for
|
// 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));
|
_ => new AlarmScratch(state.Inputs, state.Logger, _clock));
|
||||||
RefillReadCache(scratch.ReadCache, state.Inputs);
|
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
|
// 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).
|
// 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
|
// 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.
|
// 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
|
// 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.
|
// 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;
|
var context = scratch.Context;
|
||||||
|
|
||||||
@@ -544,10 +570,19 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
var result = Part9StateMachine.ApplyPredicate(seed, predicateTrue, nowUtc);
|
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);
|
// A real transition carries the current worst quality so the projected full-snapshot
|
||||||
if (evt is not null) pendingEmissions.Add(evt);
|
// 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;
|
return result.State;
|
||||||
}
|
}
|
||||||
@@ -599,7 +634,8 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
|||||||
/// done by <see cref="FireEvent(ScriptedAlarmEvent)"/> AFTER the gate is
|
/// done by <see cref="FireEvent(ScriptedAlarmEvent)"/> AFTER the gate is
|
||||||
/// released.
|
/// released.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
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
|
// Suppressed kind means shelving ate the emission — we don't fire for subscribers
|
||||||
// but the state record still advanced so startup recovery reflects reality.
|
// 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
|
// 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
|
// adapter honors it to suppress ONLY the durable sink write; the live alerts fan-out is
|
||||||
// unaffected (it is not gated on this flag).
|
// 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #478 — build a standalone <see cref="EmissionKind.QualityChanged"/> 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 <c>WriteAlarmQuality</c> node path (annotate quality only,
|
||||||
|
/// no <c>/alerts</c> row, no historian write); the <see cref="IAlarmSource"/> fan-out skips it.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
|
||||||
|
/// <summary>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
|
||||||
|
/// <see cref="AreInputsReady"/> 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).</summary>
|
||||||
|
private static uint WorstInputStatus(IReadOnlyDictionary<string, DataValueSnapshot> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>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
|
||||||
|
/// <c>_evalGate</c>.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>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).</summary>
|
||||||
|
private static int QualityBucket(uint statusCode)
|
||||||
|
{
|
||||||
|
var severity = statusCode >> 30;
|
||||||
|
return severity >= 2 ? 2 : (int)severity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>#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.</summary>
|
||||||
|
private uint LastWorstStatus(string alarmId)
|
||||||
|
=> (_lastQualityBucketByAlarmId.TryGetValue(alarmId, out var bucket) ? bucket : 0) switch
|
||||||
|
{
|
||||||
|
2 => 0x80000000u, // Bad
|
||||||
|
1 => 0x40000000u, // Uncertain
|
||||||
|
_ => 0u, // Good
|
||||||
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Invoke the <see cref="OnEvent"/> handler for a built emission. Must be
|
/// Invoke the <see cref="OnEvent"/> handler for a built emission. Must be
|
||||||
/// called OUTSIDE <c>_evalGate</c>: a slow subscriber would otherwise
|
/// called OUTSIDE <c>_evalGate</c>: a slow subscriber would otherwise
|
||||||
@@ -708,7 +824,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
|||||||
_alarms[id] = state with { Condition = result.State };
|
_alarms[id] = state with { Condition = result.State };
|
||||||
if (result.Emission != EmissionKind.None)
|
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);
|
if (evt is not null) pending.Add(evt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -780,6 +896,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
|||||||
_alarms.Clear();
|
_alarms.Clear();
|
||||||
_alarmsReferencing.Clear();
|
_alarmsReferencing.Clear();
|
||||||
_scratchByAlarmId.Clear();
|
_scratchByAlarmId.Clear();
|
||||||
|
_lastQualityBucketByAlarmId.Clear();
|
||||||
// Dispose every compiled-predicate ALC so the engine's shutdown actually
|
// Dispose every compiled-predicate ALC so the engine's shutdown actually
|
||||||
// releases the emitted assemblies. The drain above ensures no evaluator is
|
// releases the emitted assemblies. The drain above ensures no evaluator is
|
||||||
// mid-call; CompiledScriptCache.Dispose internally guards against use-after-
|
// mid-call; CompiledScriptCache.Dispose internally guards against use-after-
|
||||||
@@ -851,7 +968,11 @@ public sealed record ScriptedAlarmEvent(
|
|||||||
EmissionKind Emission,
|
EmissionKind Emission,
|
||||||
DateTime TimestampUtc,
|
DateTime TimestampUtc,
|
||||||
string? Comment = null,
|
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);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Upstream source abstraction — intentionally identical shape to the virtual-tag
|
/// Upstream source abstraction — intentionally identical shape to the virtual-tag
|
||||||
|
|||||||
@@ -81,6 +81,11 @@ public sealed class ScriptedAlarmSource : IAlarmSource, IDisposable
|
|||||||
{
|
{
|
||||||
if (_disposed) return;
|
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)
|
foreach (var sub in _subscriptions.Values)
|
||||||
{
|
{
|
||||||
if (!Matches(sub, evt)) continue;
|
if (!Matches(sub, evt)) continue;
|
||||||
|
|||||||
@@ -278,11 +278,31 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
|
|||||||
|
|
||||||
private void OnDependencyChanged(VirtualTagActor.DependencyValueChanged msg)
|
private void OnDependencyChanged(VirtualTagActor.DependencyValueChanged msg)
|
||||||
{
|
{
|
||||||
// Feed the live value into the upstream the engine subscribes from. StatusCode 0 = Good; the
|
// Feed the live value into the upstream the engine subscribes from. #478 — carry the source
|
||||||
// mux only forwards values it received from a driver publish, so we treat them as Good-quality.
|
// driver's quality (mapped to an OPC UA StatusCode) so the engine can derive a scripted
|
||||||
_upstream.Push(msg.TagId, new DataValueSnapshot(msg.Value, 0u, msg.TimestampUtc, msg.TimestampUtc));
|
// 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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>#478 — map the 3-state <see cref="OpcUaQuality"/> to an OPC UA StatusCode (severity bits)
|
||||||
|
/// for the engine's read cache. The inverse of <see cref="QualityFromStatus"/>.</summary>
|
||||||
|
private static uint StatusFromQuality(OpcUaQuality quality) => quality switch
|
||||||
|
{
|
||||||
|
OpcUaQuality.Bad => 0x80000000u,
|
||||||
|
OpcUaQuality.Uncertain => 0x40000000u,
|
||||||
|
_ => 0u, // Good
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>#478 — collapse an engine <see cref="ScriptedAlarmEvent.WorstInputStatusCode"/> (top-2
|
||||||
|
/// severity bits) back to the 3-state <see cref="OpcUaQuality"/> the Commons snapshot / node path use.</summary>
|
||||||
|
private static OpcUaQuality QualityFromStatus(uint statusCode) => (statusCode >> 30) switch
|
||||||
|
{
|
||||||
|
0 => OpcUaQuality.Good,
|
||||||
|
1 => OpcUaQuality.Uncertain,
|
||||||
|
_ => OpcUaQuality.Bad,
|
||||||
|
};
|
||||||
|
|
||||||
private void OnEngineEmission(EngineEmission msg)
|
private void OnEngineEmission(EngineEmission msg)
|
||||||
{
|
{
|
||||||
var e = msg.Event;
|
var e = msg.Event;
|
||||||
@@ -294,6 +314,21 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
|
|||||||
return;
|
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/
|
// 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.
|
// 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).
|
// 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),
|
Shelving: MapShelving(e.Condition.Shelving.Kind),
|
||||||
Severity: (ushort)SeverityToInt(e.Severity),
|
Severity: (ushort)SeverityToInt(e.Severity),
|
||||||
Message: e.Message,
|
Message: e.Message,
|
||||||
// #477 — scripted conditions are script-computed and always live in this scope, so they report Good.
|
// #478 — the condition's Quality is the worst quality across the script's input tags at evaluation
|
||||||
// Deriving quality from the worst of a script's input-tag qualities is a deferred follow-up (Layer 3).
|
// time (carried on the event by the engine). A transition fired while an input is Uncertain projects
|
||||||
Quality: OpcUaQuality.Good);
|
// Uncertain here so the full-snapshot write doesn't clobber quality back to Good.
|
||||||
|
Quality: QualityFromStatus(e.WorstInputStatusCode));
|
||||||
|
|
||||||
/// <summary>Maps the Core <see cref="ShelvingKind"/> onto the Commons <see cref="AlarmShelvingKind"/>
|
/// <summary>Maps the Core <see cref="ShelvingKind"/> onto the Commons <see cref="AlarmShelvingKind"/>
|
||||||
/// mirror (the Commons assembly can't see the Core enum).</summary>
|
/// mirror (the Commons assembly can't see the Core enum).</summary>
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ public sealed class DependencyMuxActor : ReceiveActor
|
|||||||
// space carries thousands of tags and only a fraction feed virtual-tag expressions.
|
// space carries thousands of tags and only a fraction feed virtual-tag expressions.
|
||||||
return;
|
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)
|
foreach (var sub in subscribers)
|
||||||
{
|
{
|
||||||
sub.Tell(dep);
|
sub.Tell(dep);
|
||||||
|
|||||||
@@ -29,7 +29,11 @@ public sealed class VirtualTagActor : ReceiveActor
|
|||||||
{
|
{
|
||||||
public const string ScriptLogsTopic = "script-logs";
|
public const string ScriptLogsTopic = "script-logs";
|
||||||
|
|
||||||
public sealed record DependencyValueChanged(string TagId, object? Value, DateTime TimestampUtc);
|
/// <summary>A dependency's value changed. <paramref name="Quality"/> (#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 <see cref="OpcUaQuality.Good"/>, and the virtual-tag engine ignores it.</summary>
|
||||||
|
public sealed record DependencyValueChanged(
|
||||||
|
string TagId, object? Value, DateTime TimestampUtc, OpcUaQuality Quality = OpcUaQuality.Good);
|
||||||
|
|
||||||
/// <summary>Re-assert the last emitted value/quality to the parent bridge, bypassing the value dedup.
|
/// <summary>Re-assert the last emitted value/quality to the parent bridge, bypassing the value dedup.
|
||||||
/// Sent by <see cref="VirtualTagHostActor"/> on every apply (deploy) to a surviving child: a deploy
|
/// Sent by <see cref="VirtualTagHostActor"/> on every apply (deploy) to a surviving child: a deploy
|
||||||
|
|||||||
@@ -1005,6 +1005,104 @@ public sealed class ScriptedAlarmEngineTests
|
|||||||
// If Dispose threw or hung, the WaitAsync would surface it.
|
// 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;
|
||||||
|
|
||||||
|
/// <summary>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).</summary>
|
||||||
|
[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<ScriptedAlarmEvent>();
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
[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<ScriptedAlarmEvent>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Restoring a Good input after a Bad one flips the bucket back and emits QualityChanged(Good).</summary>
|
||||||
|
[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<ScriptedAlarmEvent>();
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A value change that keeps the same quality bucket (Good→Good) emits no QualityChanged.</summary>
|
||||||
|
[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<ScriptedAlarmEvent>();
|
||||||
|
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<bool> cond, int timeoutMs = 2000)
|
private static async Task WaitForAsync(Func<bool> cond, int timeoutMs = 2000)
|
||||||
{
|
{
|
||||||
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
|
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
|
||||||
|
|||||||
@@ -107,6 +107,28 @@ public sealed class ScriptedAlarmSourceTests
|
|||||||
events.Count.ShouldBe(0);
|
events.Count.ShouldBe(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>#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).</summary>
|
||||||
|
[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<AlarmEventArgs>();
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that AcknowledgeAsync routes to the engine with a default user.</summary>
|
/// <summary>Verifies that AcknowledgeAsync routes to the engine with a default user.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AcknowledgeAsync_routes_to_engine_with_default_user()
|
public async Task AcknowledgeAsync_routes_to_engine_with_default_user()
|
||||||
|
|||||||
+51
@@ -664,6 +664,57 @@ public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase
|
|||||||
evtFalse.HistorizeToAveva.ShouldBe(false);
|
evtFalse.HistorizeToAveva.ShouldBe(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>#478 — a Bad-quality dependency (no threshold crossing → no state transition) drives the
|
||||||
|
/// scripted condition's Quality out of band: the host publishes an <see cref="OpcUaPublishActor.AlarmQualityUpdate"/>
|
||||||
|
/// (Bad, Uns realm) and NO <c>/alerts</c> transition — quality is an annotation, not a state change.</summary>
|
||||||
|
[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<DependencyMuxActor.RegisterInterest>(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<OpcUaPublishActor.AlarmQualityUpdate>(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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>#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.</summary>
|
||||||
|
[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<DependencyMuxActor.RegisterInterest>(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<OpcUaPublishActor.AlarmStateUpdate>(m => m.State.Active, Timeout);
|
||||||
|
state.AlarmNodeId.ShouldBe("alm-1");
|
||||||
|
state.State.Quality.ShouldBe(OpcUaQuality.Uncertain);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>OneShotShelve transition carries the operator's identity: an operator-driven OneShotShelve
|
/// <summary>OneShotShelve transition carries the operator's identity: an operator-driven OneShotShelve
|
||||||
/// drives <c>OneShotShelveAsync</c> — the resulting <see cref="AlarmTransitionEvent"/>(<c>"Shelved"</c>)
|
/// drives <c>OneShotShelveAsync</c> — the resulting <see cref="AlarmTransitionEvent"/>(<c>"Shelved"</c>)
|
||||||
/// on the alerts topic must carry <c>User == cmd.User</c> (the acting operator), NOT the generic
|
/// on the alerts topic must carry <c>User == cmd.User</c> (the acting operator), NOT the generic
|
||||||
|
|||||||
@@ -149,6 +149,21 @@ public sealed class DependencyMuxActorTests : RuntimeActorTestBase
|
|||||||
subscriber.ExpectMsg<VirtualTagActor.DependencyValueChanged>().TagId.ShouldBe("ref-1");
|
subscriber.ExpectMsg<VirtualTagActor.DependencyValueChanged>().TagId.ShouldBe("ref-1");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>#478 — the mux forwards the published source quality onto DependencyValueChanged so the
|
||||||
|
/// scripted-alarm host can derive a condition's worst-of-input quality.</summary>
|
||||||
|
[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<VirtualTagActor.DependencyValueChanged>().Quality.ShouldBe(OpcUaQuality.Bad);
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class EchoSumEvaluator : ZB.MOM.WW.OtOpcUa.Commons.Engines.IVirtualTagEvaluator
|
private sealed class EchoSumEvaluator : ZB.MOM.WW.OtOpcUa.Commons.Engines.IVirtualTagEvaluator
|
||||||
{
|
{
|
||||||
/// <summary>Evaluates the expression by summing all dependency values as integers.</summary>
|
/// <summary>Evaluates the expression by summing all dependency values as integers.</summary>
|
||||||
|
|||||||
Reference in New Issue
Block a user