Files
lmxopcua/docs/ScriptedAlarms.md
Joseph Doherty f5552c23d4 docs(audit): ScriptedAlarms.md — accuracy pass
CODE-REALITY (file:line evidence)
- Definition section: removed reference to non-existent
  Phase7EngineComposer.ProjectScriptedAlarms; Phase7Composer is a pure
  data composer (entities → Phase7CompositionResult)
  (src/Server/.../OpcUaServer/Phase7Composer.cs:82-183)
- AlarmSeverity: removed "Phase7EngineComposer.MapSeverity bands it" —
  no such class exists; clarified that AlarmSeverity is defined in
  Core.Abstractions/IAlarmSource.cs not in AlarmTypes.cs
  (src/Core/.../Core.Abstractions/IAlarmSource.cs:87)
- State persistence: replaced "Stream E wires..." planning language with
  actual production class EfAlarmActorStateStore
  (src/Server/.../Runtime/ScriptedAlarms/EfAlarmActorStateStore.cs)
- Composition section: replaced Phase7EngineComposer / Phase7ComposedSources
  references (non-existent) with the actual v2 actor-system composition
  path (ScriptedAlarmEngine + ScriptedAlarmActor + driver-role host startup)
- Key source files: AlarmTypes.cs annotation corrected (adds ShelvingKind,
  names all four state enums, notes AlarmSeverity lives in Core.Abstractions)
- Key source files: Phase7Composer.cs annotation corrected to "pure data
  composer"
- Key source files: ScriptedAlarmActor.cs annotation corrected to describe
  AlarmTransitionEvent + DPS alerts topic (not "OPC UA variable reads")
- Key source files: added EfAlarmActorStateStore as the production
  IAlarmActorStateStore implementation

STALE-STATUS
- "Stream E wires the production implementation" — removed; production
  implementation ships and is named EfAlarmActorStateStore
2026-06-03 15:44:11 -04:00

16 KiB

Scripted Alarms

Core.ScriptedAlarms is the Phase 7 subsystem that raises OPC UA Part 9 alarms from operator-authored C# predicates rather than from driver-native alarm streams. Scripted alarms are additive: Galaxy, AB CIP, FOCAS, and OPC UA Client drivers keep their native IAlarmSource implementations unchanged, and a ScriptedAlarmSource simply registers as another source in the same fan-out. Predicates read tags from any source (driver tags or virtual tags) through the shared ITagUpstreamSource and emit condition transitions through the engine's Part 9 state machine.

This file covers the engine internals — predicate evaluation, state machine, persistence, and the engine-to-IAlarmSource adapter. The server-side plumbing that turns those emissions into OPC UA AlarmConditionState nodes, applies retries, persists alarm transitions to the Historian, and routes operator acks through the session's AlarmAck permission lives in AlarmTracking.md and is not repeated here.

Definition shape

ScriptedAlarmDefinition (src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmDefinition.cs) is the runtime contract the engine consumes. The generation-publish path materialises these from the ScriptedAlarm + Script config tables via Phase7Composer.Compose + the driver-role host actor startup path.

Field Notes
AlarmId Stable identity. Also the OPC UA ConditionId and the key in IAlarmStateStore. Convention: {EquipmentPath}::{AlarmName}.
EquipmentPath UNS path the alarm hangs under in the address space. ACL scope inherits from the equipment node.
AlarmName Browse-tree display name.
Kind AlarmKindAlarmCondition, LimitAlarm, DiscreteAlarm, or OffNormalAlarm. Controls only the OPC UA ObjectType the node surfaces as; the internal state machine is identical for all four.
Severity AlarmSeverity enum (Low / Medium / High / Critical), defined in Core.Abstractions/IAlarmSource.cs. Static per decision #13 — the predicate does not compute severity. The publish path bands the configured value into this four-value enum before materialising the ScriptedAlarmDefinition.
MessageTemplate String with {TagPath} placeholders, resolved at emission time. See below.
PredicateScriptSource Roslyn C# script returning bool. true = condition active; false = cleared.
HistorizeToAveva When true, every emission is enqueued to IAlarmHistorianSink. Default true. Galaxy-native alarms default false since Galaxy historises them directly.
Retain Part 9 retain flag — keep the condition visible after clear while un-acked/un-confirmed transitions remain. Default true.

Illustrative definition:

new ScriptedAlarmDefinition(
    AlarmId:       "Plant/Line1/Oven::OverTemp",
    EquipmentPath: "Plant/Line1/Oven",
    AlarmName:     "OverTemp",
    Kind:          AlarmKind.LimitAlarm,
    Severity:      AlarmSeverity.High,
    MessageTemplate: "Oven {Plant/Line1/Oven/Temp} exceeds limit {Plant/Line1/Oven/TempLimit}",
    PredicateScriptSource: "return GetTag(\"Plant/Line1/Oven/Temp\").AsDouble() > GetTag(\"Plant/Line1/Oven/TempLimit\").AsDouble();");

Predicate evaluation

Alarm predicates reuse the same Roslyn sandbox as virtual tags — ScriptEvaluator<AlarmPredicateContext, bool> compiles the source, TimedScriptEvaluator wraps it with the configured timeout (default from TimedScriptEvaluator.DefaultTimeout), and DependencyExtractor statically harvests the tag paths the script reads. The sandbox rules (forbidden types, cancellation, logging sinks) are documented in VirtualTags.md; ScriptedAlarms does not redefine them. The known resource limits — unbounded script-side memory and the orphan-thread CPU-budget caveat — are documented in that file as well; per-publish assembly accretion was resolved by the Core.Scripting-008 collectible-AssemblyLoadContext rewrite and no longer requires periodic server restarts.

AlarmPredicateContext (AlarmPredicateContext.cs) is the script's ScriptContext subclass:

  • GetTag(path) returns a DataValueSnapshot from the engine-maintained read cache. Missing path → DataValueSnapshot(null, 0x80340000u, null, now) (BadNodeIdUnknown). An empty path returns the same.
  • SetVirtualTag(path, value) throws InvalidOperationException. Predicates must be side-effect free per plan decision #6; writes would couple alarm state to virtual-tag state in ways that are near-impossible to reason about. Operators see the rejection in scripts-*.log.
  • Now and Logger are provided by the engine.

Evaluation cadence:

  • On every upstream tag change that any alarm's input set references (OnUpstreamChangeReevaluateAsync). The engine maintains an inverse index tag path → alarm ids (_alarmsReferencing); only affected alarms re-run.
  • On a 5-second shelving-check timer (_shelvingTimer) for timed-shelve expiry.
  • At LoadAsync for every alarm, to re-derive ActiveState per plan decision #14 (startup recovery).

If a predicate throws or times out, the engine logs the failure and leaves the prior ActiveState intact — it does not synthesise a clear. Operators investigating a broken predicate should never see a phantom clear preceding the error.

Part 9 state machine

Part9StateMachine (Part9StateMachine.cs) is a pure static function set. Every transition takes the current AlarmConditionState plus the event, returns a new record and an EmissionKind. No I/O, no mutation, trivially unit-testable. Transitions map to OPC UA Part 9:

  • ApplyPredicate(current, predicateTrue, nowUtc) — predicate re-evaluation. Inactive → Active sets Acked = Unacknowledged and Confirmed = Unconfirmed; Active → Inactive updates LastClearedUtc and consumes OneShot shelving. Disabled alarms no-op.
  • ApplyAcknowledge / ApplyConfirm — operator ack/confirm. Require a non-empty user string (audit requirement). Each appends an AlarmComment with Kind = "Acknowledge" / "Confirm".
  • ApplyOneShotShelve / ApplyTimedShelve(unshelveAtUtc) / ApplyUnshelve — shelving transitions. Timed requires unshelveAtUtc > nowUtc.
  • ApplyEnable / ApplyDisable — operator enable/disable. Disabled alarms ignore predicate results until re-enabled; on enable, ActiveState is re-derived from the next evaluation.
  • ApplyAddComment(text) — append-only audit entry, no state change.
  • ApplyShelvingCheck(nowUtc) — called by the 5s timer; promotes expired Timed shelving to Unshelved with a system / AutoUnshelve audit entry.

Two invariants the machine enforces:

  1. Disabled alarms ignore every predicate evaluation — they never transition ActiveState / AckedState / ConfirmedState until re-enabled.
  2. Shelved alarms still advance their internal state but emit EmissionKind.Suppressed instead of Activated / Cleared. The engine advances the state record (so startup recovery reflects reality) but ScriptedAlarmSource does not publish the suppressed transition to subscribers. OneShot expires on the next clear; Timed expires at ShelvingState.UnshelveAtUtc.

EmissionKind values: None, Suppressed, Activated, Cleared, Acknowledged, Confirmed, Shelved, Unshelved, Enabled, Disabled, CommentAdded.

Message templates

MessageTemplate (MessageTemplate.cs) resolves {path} placeholders in the configured message at emission time. Syntax:

  • {path/with/slashes} — brace-stripped contents are looked up via the engine's tag cache.
  • No escaping. Literal braces in messages are not currently supported.
  • ExtractTokenPaths(template) is called at LoadAsync so the engine subscribes to every referenced path (ensuring the value cache is populated before the first resolve).

Fallback rules: a resolved DataValueSnapshot with a non-zero StatusCode, a null Value, or an unknown path becomes {?}. The event still fires — the operator sees where the reference broke rather than having the alarm swallowed.

Input-quality policy

Predicate evaluation and message-template resolution deliberately treat tag-input quality differently:

Surface Quality bar Rationale
ScriptedAlarmEngine.AreInputsReady (predicate gate) Bad rejected (StatusCode bit 31 set). Good and Uncertain are both accepted. Uncertain quality still carries a value the predicate can inspect; rejecting it would mask a transitional alarm condition. Predicate evaluation is a state-machine input — operators want it to track reality as closely as the quality allows.
MessageTemplate.Resolve (operator-facing message) Any non-zero StatusCode rejected — only Good substitutes; Uncertain / Bad / unknown all render as {?}. The message is a human-readable signal; substituting an Uncertain value would let operators act on a questionable reading without seeing the qualifier. Rendering {?} makes the doubt explicit.

AlarmPredicateContext.GetTag returns a BadNodeIdUnknown (0x80340000) snapshot for missing or empty paths, so a typo in the predicate flows through AreInputsReady (Bad → predicate skipped, prior state held) and MessageTemplate.Resolve (non-Good → {?}) without crashing the engine. (Core.ScriptedAlarms-010)

State persistence

IAlarmStateStore (IAlarmStateStore.cs) is the persistence contract: LoadAsync(alarmId), LoadAllAsync, SaveAsync(state), RemoveAsync(alarmId). InMemoryAlarmStateStore in the same file is the default for tests and dev deployments without a SQL backend. The production implementation is EfAlarmActorStateStore (src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/EfAlarmActorStateStore.cs), which persists to the ScriptedAlarmState config-DB table via IAlarmActorStateStore.

Persisted scope per plan decision #14: Enabled, Acked, Confirmed, Shelving, LastTransitionUtc, the LastAck* / LastConfirm* audit fields, and the append-only Comments list. Active is not trusted across restart — the engine re-runs the predicate at LoadAsync so operators never re-ack an alarm that was already acknowledged before an outage, and alarms whose condition cleared during downtime settle to Inactive without a spurious clear-event.

Every mutation the state machine produces is immediately persisted inside the engine's _evalGate semaphore, so the store's view is always consistent with the in-memory state.

Source integration

ScriptedAlarmSource (ScriptedAlarmSource.cs) adapts the engine to the driver-agnostic IAlarmSource interface. The existing AlarmSurfaceInvoker + GenericDriverNodeManager fan-out consumes it the same way it consumes Galaxy / AB CIP / FOCAS sources — there is no scripted-alarm-specific code path in the server plumbing. From that point on, the flow into AlarmConditionState nodes, the AlarmAck session check, and the Historian sink is shared — see AlarmTracking.md.

Two mapping notes specific to this adapter:

  • SubscribeAlarmsAsync accepts a list of source-node-id filters, interpreted as Equipment-path prefixes. Empty list matches every alarm. Each emission is matched against every live subscription — the adapter keeps no per-subscription cursor.
  • IAlarmSource.AcknowledgeAsync does not carry a user identity. The adapter defaults the audit user to "opcua-client" so callers using the base interface still produce an audit entry. The server's Part 9 method handlers (Stream G) call the engine's richer AcknowledgeAsync / ConfirmAsync / OneShotShelveAsync / TimedShelveAsync / UnshelveAsync / AddCommentAsync directly with the authenticated principal instead.

Emissions map into AlarmEventArgs as AlarmType = Kind.ToString(), SourceNodeId = EquipmentPath, ConditionId = AlarmId, Message = resolved template string, Severity carried verbatim, SourceTimestampUtc = emission time.

Composition

Phase7Composer (src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs) is a pure data composer; it has no knowledge of ScriptedAlarmEngine. It maps ScriptedAlarm config-DB rows into ScriptedAlarmPlan records that the driver-role host actor startup path consumes.

In the v2 actor system, scripted-alarm engine composition is owned by the driver-role host:

  1. The host reads the generation's ScriptedAlarm + Script rows and resolves each row's PredicateScriptId to produce a ScriptedAlarmDefinition list. Unknown or disabled scripts fail fast — the DB publish guarantees referential integrity but this is a belt-and-braces check.
  2. A ScriptedAlarmEngine is constructed with the upstream-tag source, an IAlarmStateStore (production: EfAlarmActorStateStore), a shared ScriptLoggerFactory keyed to scripts-*.log, and the root Serilog logger.
  3. alarmEngine.OnEvent is wired to the historian sink. Fire-and-forget — the SQLite store-and-forward sink is already non-blocking.
  4. LoadAsync(alarmDefs) runs on startup: it compiles every predicate, subscribes to the union of predicate inputs and message-template tokens, seeds the value cache, loads persisted state, re-derives ActiveState from a fresh predicate evaluation, and starts the 5s shelving timer. Compile failures are aggregated into one InvalidOperationException so operators see every bad predicate in one startup log line rather than one at a time.
  5. A ScriptedAlarmSource is created for the event stream. The v2 ScriptedAlarmActor (src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmActor.cs) owns the active-state surface for OPC UA variable reads on the alarm's condition-state node — unknown alarm ids return BadNodeIdUnknown rather than silently reading false.

Both engine and source are disposed on server shutdown via the driver-role host teardown path.

Key source files

  • src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs — orchestrator, cascade wiring, shelving timer, OnEvent emission
  • src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmSource.csIAlarmSource adapter over the engine
  • src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmDefinition.cs — runtime definition record
  • src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/Part9StateMachine.cs — pure-function state machine + TransitionResult / EmissionKind
  • src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/AlarmConditionState.cs — persisted state record + AlarmComment audit entry + ShelvingState
  • src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/AlarmPredicateContext.cs — script-side ScriptContext (read-only, write rejected)
  • src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/AlarmTypes.csAlarmKind + ShelvingKind + four Part 9 state enums (AlarmEnabledState, AlarmActiveState, AlarmAckedState, AlarmConfirmedState); AlarmSeverity (Low/Medium/High/Critical) lives in src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs
  • src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/MessageTemplate.cs{path} placeholder resolver
  • src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/IAlarmStateStore.cs — persistence contract + InMemoryAlarmStateStore default
  • src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs — pure data composer: config-DB entities → Phase7CompositionResult (UNS topology + driver/alarm plans)
  • src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs — applies the composed Phase 7 plan into the SDK node manager
  • src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmActor.cs — actor that owns the per-alarm state machine; publishes AlarmTransitionEvent on the cluster alerts DPS topic
  • src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/EfAlarmActorStateStore.cs — production IAlarmActorStateStore backed by the ScriptedAlarmState config-DB table
  • src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynScriptedAlarmEvaluator.cs — production Roslyn predicate evaluator