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
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 |
AlarmKind — AlarmCondition, 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 aDataValueSnapshotfrom the engine-maintained read cache. Missing path →DataValueSnapshot(null, 0x80340000u, null, now)(BadNodeIdUnknown). An empty path returns the same.SetVirtualTag(path, value)throwsInvalidOperationException. 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 inscripts-*.log.NowandLoggerare provided by the engine.
Evaluation cadence:
- On every upstream tag change that any alarm's input set references (
OnUpstreamChange→ReevaluateAsync). The engine maintains an inverse indextag path → alarm ids(_alarmsReferencing); only affected alarms re-run. - On a 5-second shelving-check timer (
_shelvingTimer) for timed-shelve expiry. - At
LoadAsyncfor every alarm, to re-deriveActiveStateper 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 → ActivesetsAcked = UnacknowledgedandConfirmed = Unconfirmed;Active → InactiveupdatesLastClearedUtcand consumesOneShotshelving. Disabled alarms no-op.ApplyAcknowledge/ApplyConfirm— operator ack/confirm. Require a non-empty user string (audit requirement). Each appends anAlarmCommentwithKind = "Acknowledge"/"Confirm".ApplyOneShotShelve/ApplyTimedShelve(unshelveAtUtc)/ApplyUnshelve— shelving transitions.TimedrequiresunshelveAtUtc > nowUtc.ApplyEnable/ApplyDisable— operator enable/disable. Disabled alarms ignore predicate results until re-enabled; on enable,ActiveStateis re-derived from the next evaluation.ApplyAddComment(text)— append-only audit entry, no state change.ApplyShelvingCheck(nowUtc)— called by the 5s timer; promotes expiredTimedshelving toUnshelvedwith asystem / AutoUnshelveaudit entry.
Two invariants the machine enforces:
- Disabled alarms ignore every predicate evaluation — they never transition
ActiveState/AckedState/ConfirmedStateuntil re-enabled. - Shelved alarms still advance their internal state but emit
EmissionKind.Suppressedinstead ofActivated/Cleared. The engine advances the state record (so startup recovery reflects reality) butScriptedAlarmSourcedoes not publish the suppressed transition to subscribers.OneShotexpires on the next clear;Timedexpires atShelvingState.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 atLoadAsyncso 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:
SubscribeAlarmsAsyncaccepts 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.AcknowledgeAsyncdoes 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 richerAcknowledgeAsync/ConfirmAsync/OneShotShelveAsync/TimedShelveAsync/UnshelveAsync/AddCommentAsyncdirectly 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:
- The host reads the generation's
ScriptedAlarm+Scriptrows and resolves each row'sPredicateScriptIdto produce aScriptedAlarmDefinitionlist. Unknown or disabled scripts fail fast — the DB publish guarantees referential integrity but this is a belt-and-braces check. - A
ScriptedAlarmEngineis constructed with the upstream-tag source, anIAlarmStateStore(production:EfAlarmActorStateStore), a sharedScriptLoggerFactorykeyed toscripts-*.log, and the root Serilog logger. alarmEngine.OnEventis wired to the historian sink. Fire-and-forget — the SQLite store-and-forward sink is already non-blocking.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-derivesActiveStatefrom a fresh predicate evaluation, and starts the 5s shelving timer. Compile failures are aggregated into oneInvalidOperationExceptionso operators see every bad predicate in one startup log line rather than one at a time.- A
ScriptedAlarmSourceis created for the event stream. The v2ScriptedAlarmActor(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 returnBadNodeIdUnknownrather than silently readingfalse.
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,OnEventemissionsrc/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmSource.cs—IAlarmSourceadapter over the enginesrc/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmDefinition.cs— runtime definition recordsrc/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/Part9StateMachine.cs— pure-function state machine +TransitionResult/EmissionKindsrc/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/AlarmConditionState.cs— persisted state record +AlarmCommentaudit entry +ShelvingStatesrc/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/AlarmPredicateContext.cs— script-sideScriptContext(read-only, write rejected)src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/AlarmTypes.cs—AlarmKind+ShelvingKind+ four Part 9 state enums (AlarmEnabledState,AlarmActiveState,AlarmAckedState,AlarmConfirmedState);AlarmSeverity(Low/Medium/High/Critical) lives insrc/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cssrc/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/MessageTemplate.cs—{path}placeholder resolversrc/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/IAlarmStateStore.cs— persistence contract +InMemoryAlarmStateStoredefaultsrc/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 managersrc/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmActor.cs— actor that owns the per-alarm state machine; publishesAlarmTransitionEventon the clusteralertsDPS topicsrc/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/EfAlarmActorStateStore.cs— productionIAlarmActorStateStorebacked by theScriptedAlarmStateconfig-DB tablesrc/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynScriptedAlarmEvaluator.cs— production Roslyn predicate evaluator