Ships the Part 9 alarm fidelity layer Phase 7 committed to in plan decision #5. Every scripted alarm gets a full OPC UA AlarmConditionType state machine — EnabledState, ActiveState, AckedState, ConfirmedState, ShelvingState — with persistent operator-supplied state across server restarts per Phase 7 plan decision #14. Runtime shape matches the Galaxy-native + AB CIP ALMD alarm sources: scripted alarms fan out through the existing IAlarmSource surface so Phase 6.1 AlarmTracker composition consumes them without per-source branching. Part9StateMachine is a pure-functions module — no instance state, no I/O, no mutation. Every transition (ApplyPredicate, ApplyAcknowledge, ApplyConfirm, ApplyOneShotShelve, ApplyTimedShelve, ApplyUnshelve, ApplyEnable, ApplyDisable, ApplyAddComment, ApplyShelvingCheck) takes the current AlarmConditionState record plus the event and returns a fresh state + EmissionKind hint. Two structural invariants enforced: disabled alarms never transition ActiveState / AckedState / ConfirmedState; shelved alarms still advance state (so startup recovery reflects reality) but emit a Suppressed hint so subscribers do not see the transition. OneShot shelving expires on clear; Timed shelving expires via ApplyShelvingCheck against the UnshelveAtUtc timestamp. Comments are append-only — every acknowledge, confirm, shelve, unshelve, enable, disable, explicit add-comment, and auto-unshelve appends an AlarmComment record with user identity + timestamp + kind + text for the GxP / 21 CFR Part 11 audit surface. AlarmConditionState is the persistent record the store saves. Fields: AlarmId, Enabled, Active, Acked, Confirmed, Shelving (kind + UnshelveAtUtc), LastTransitionUtc, LastActiveUtc, LastClearedUtc, LastAckUtc + LastAckUser + LastAckComment, LastConfirmUtc + LastConfirmUser + LastConfirmComment, Comments. Fresh factory initializes everything to the no-event position. IAlarmStateStore is the persistence abstraction — LoadAsync, LoadAllAsync, SaveAsync, RemoveAsync. Stream E wires this to a SQL-backed store with IAuditLogger hooks; tests use InMemoryAlarmStateStore. Startup recovery per Phase 7 plan decision #14: LoadAsync runs every configured alarm predicate against current tag values to rederive ActiveState, but EnabledState / AckedState / ConfirmedState / ShelvingState + audit history are loaded verbatim from the store so operators do not re-ack after an outage and shelved alarms stay shelved through maintenance windows. MessageTemplate implements Phase 7 plan decision #13 — static-with-substitution. {TagPath} tokens resolved at event emission time from the engine value cache. Missing paths, non-Good quality, or null values all resolve to {?} so the event still fires but the operator sees where the reference broke. ExtractTokenPaths enumerates tokens at publish time so the engine knows to subscribe to every template-referenced tag in addition to predicate-referenced tags. AlarmPredicateContext is the ScriptContext subclass alarm scripts see. GetTag reads from the engine shared cache; SetVirtualTag is explicitly rejected at runtime with a pointed error message — alarm predicates must be pure so their output does not couple to virtual-tag state in ways that become impossible to reason about. If cross-tag side effects are needed, the operator authors a virtual tag and the alarm predicate reads it. ScriptedAlarmEngine orchestrates. LoadAsync compiles every predicate through Stream A ScriptSandbox + ForbiddenTypeAnalyzer, runs DependencyExtractor to find the read set, adds template token paths to the input set, reports every compile failure as one aggregated InvalidOperationException (not one-at-a-time), subscribes to each unique referenced upstream path, seeds the value cache, loads persisted state for each alarm (falling back to Fresh for first-load), re-evaluates the predicate, and saves the recovered state. ChangeTrigger — when an upstream tag changes, look up every alarm referencing that path in a per-path inverse index, enqueue all of them for re-evaluation via a SemaphoreSlim-gated path. Unlike the virtual-tag engine, scripted alarms are leaves in the evaluation DAG (no alarm drives another alarm), so no topological sort is needed. Operator actions (AcknowledgeAsync, ConfirmAsync, OneShotShelveAsync, TimedShelveAsync, UnshelveAsync, EnableAsync, DisableAsync, AddCommentAsync) route through the state machine, persist, and emit if there is an emission. A 5-second shelving-check timer auto-expires Timed shelving and emits Unshelved events at the right moment. Predicate evaluation errors (script throws, timeout, compile-time reads bad tag) leave the state unchanged — the engine does NOT invent a clear transition on predicate failure. Logged as scripts-*.log Error; companion WARN in main log. ScriptedAlarmSource implements IAlarmSource. SubscribeAlarmsAsync filter is a set of equipment-path prefixes; empty means all. AcknowledgeAsync from the base interface routes to the engine with user identity "opcua-client" — Stream G will replace this with the authenticated principal from the OPC UA dispatch layer. The adapter implements only the base IAlarmSource methods; richer Part 9 methods (Confirm, Shelve, Unshelve, AddComment) remain on the engine and will bind to OPC UA method nodes in Stream G. 47 unit tests across 5 files. Part9StateMachineTests (16) — every transition + noop edge cases: predicate true/false, same-state noop, disabled ignores predicate, acknowledge records user/comment/adds audit, idempotent acknowledge, reject no-user ack, full activate-ack-clear-confirm walk, one-shot shelve suppresses next activation, one-shot expires on clear, timed shelve requires future unshelve time, timed shelve expires via shelving-check, explicit unshelve emits, add-comment appends to audit, comments append-only through multiple operations, full lifecycle walk emits every expected EmissionKind. MessageTemplateTests (11) — no-token passthrough, single+multiple token substitution, bad quality becomes {?}, unknown path becomes {?}, null value becomes {?}, tokens with slashes+dots, empty + null template, ExtractTokenPaths returns every distinct path, whitespace inside tokens trimmed. ScriptedAlarmEngineTests (13) — load compiles+subscribes, compile failures aggregated, upstream change emits Activated, clearing emits Cleared, message template resolves at emission, ack persists to store, startup recovery preserves ack but rederives active, shelved activation state-advances but suppresses emission, runtime exception isolates to owning alarm, disable prevents activation until re-enable, AddComment appends audit without state change, SetVirtualTag from predicate rejected (state unchanged), Dispose releases upstream subscriptions. ScriptedAlarmSourceTests (5) — empty filter matches all, equipment-prefix filter, Unsubscribe stops events, AcknowledgeAsync routes with default user, null arguments rejected. FakeUpstream fixture gives tests an in-memory driver mock with subscription count tracking. Full Phase 7 test count after Stream C: 146 green (63 Scripting + 36 VirtualTags + 47 ScriptedAlarms). Stream D (historian alarm sink with SQLite store-and-forward + Galaxy.Host IPC) consumes ScriptedAlarmEvent + similar Galaxy / AB CIP emissions to produce the unified alarm timeline. Stream G wires the OPC UA method calls and AlarmSource into DriverNodeManager dispatch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
295 lines
12 KiB
C#
295 lines
12 KiB
C#
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
|
|
|
/// <summary>
|
|
/// Pure functions for OPC UA Part 9 alarm-condition state transitions. Input = the
|
|
/// current <see cref="AlarmConditionState"/> + the event; output = the new state +
|
|
/// optional emission hint. The engine calls these; persistence happens around them.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// No instance state, no I/O, no mutation of the input record. Every transition
|
|
/// returns a fresh record. Makes the state machine trivially unit-testable —
|
|
/// tests assert on (input, event) -> (output) without standing anything else up.
|
|
/// </para>
|
|
/// <para>
|
|
/// Two invariants the machine enforces:
|
|
/// (1) Disabled alarms never transition ActiveState / AckedState / ConfirmedState
|
|
/// — all predicate evaluations while disabled produce a no-op result and a
|
|
/// diagnostic log line. Re-enable restores normal flow with ActiveState
|
|
/// re-derived from the next predicate evaluation.
|
|
/// (2) Shelved alarms (OneShot / Timed) don't fire active transitions to
|
|
/// subscribers, but the state record still advances so that when shelving
|
|
/// expires the ActiveState reflects current reality. OneShot expires on the
|
|
/// next clear; Timed expires at <see cref="ShelvingState.UnshelveAtUtc"/>.
|
|
/// </para>
|
|
/// </remarks>
|
|
public static class Part9StateMachine
|
|
{
|
|
/// <summary>
|
|
/// Apply a predicate re-evaluation result. Handles activation, clearing,
|
|
/// branch-stack increment when a new active arrives while prior active is
|
|
/// still un-acked, and shelving suppression.
|
|
/// </summary>
|
|
public static TransitionResult ApplyPredicate(
|
|
AlarmConditionState current,
|
|
bool predicateTrue,
|
|
DateTime nowUtc)
|
|
{
|
|
if (current.Enabled == AlarmEnabledState.Disabled)
|
|
return TransitionResult.NoOp(current, "disabled — predicate result ignored");
|
|
|
|
// Expire timed shelving if the configured clock has passed.
|
|
var shelving = MaybeExpireShelving(current.Shelving, nowUtc);
|
|
var stateWithShelving = current with { Shelving = shelving };
|
|
|
|
// Shelved alarms still update state but skip event emission.
|
|
var shelved = shelving.Kind != ShelvingKind.Unshelved;
|
|
|
|
if (predicateTrue && current.Active == AlarmActiveState.Inactive)
|
|
{
|
|
// Inactive -> Active transition.
|
|
// OneShotShelving is consumed on the NEXT clear, not activation — so we
|
|
// still suppress this transition's emission.
|
|
var next = stateWithShelving with
|
|
{
|
|
Active = AlarmActiveState.Active,
|
|
Acked = AlarmAckedState.Unacknowledged,
|
|
Confirmed = AlarmConfirmedState.Unconfirmed,
|
|
LastActiveUtc = nowUtc,
|
|
LastTransitionUtc = nowUtc,
|
|
};
|
|
return new TransitionResult(next, shelved ? EmissionKind.Suppressed : EmissionKind.Activated);
|
|
}
|
|
|
|
if (!predicateTrue && current.Active == AlarmActiveState.Active)
|
|
{
|
|
// Active -> Inactive transition.
|
|
var next = stateWithShelving with
|
|
{
|
|
Active = AlarmActiveState.Inactive,
|
|
LastClearedUtc = nowUtc,
|
|
LastTransitionUtc = nowUtc,
|
|
// OneShotShelving expires on clear — resetting here so the next
|
|
// activation fires normally.
|
|
Shelving = shelving.Kind == ShelvingKind.OneShot
|
|
? ShelvingState.Unshelved
|
|
: shelving,
|
|
};
|
|
return new TransitionResult(next, shelved ? EmissionKind.Suppressed : EmissionKind.Cleared);
|
|
}
|
|
|
|
// Predicate matches current Active — no state change beyond possible shelving
|
|
// expiry.
|
|
return new TransitionResult(stateWithShelving, EmissionKind.None);
|
|
}
|
|
|
|
/// <summary>Operator acknowledges the currently-active transition.</summary>
|
|
public static TransitionResult ApplyAcknowledge(
|
|
AlarmConditionState current,
|
|
string user,
|
|
string? comment,
|
|
DateTime nowUtc)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(user))
|
|
throw new ArgumentException("User identity required for audit.", nameof(user));
|
|
|
|
if (current.Acked == AlarmAckedState.Acknowledged)
|
|
return TransitionResult.NoOp(current, "already acknowledged");
|
|
|
|
var audit = AppendComment(current.Comments, nowUtc, user, "Acknowledge", comment);
|
|
var next = current with
|
|
{
|
|
Acked = AlarmAckedState.Acknowledged,
|
|
LastAckUtc = nowUtc,
|
|
LastAckUser = user,
|
|
LastAckComment = comment,
|
|
LastTransitionUtc = nowUtc,
|
|
Comments = audit,
|
|
};
|
|
return new TransitionResult(next, EmissionKind.Acknowledged);
|
|
}
|
|
|
|
/// <summary>Operator confirms the cleared transition. Part 9 requires confirm after clear for retain-flag alarms.</summary>
|
|
public static TransitionResult ApplyConfirm(
|
|
AlarmConditionState current,
|
|
string user,
|
|
string? comment,
|
|
DateTime nowUtc)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(user))
|
|
throw new ArgumentException("User identity required for audit.", nameof(user));
|
|
|
|
if (current.Confirmed == AlarmConfirmedState.Confirmed)
|
|
return TransitionResult.NoOp(current, "already confirmed");
|
|
|
|
var audit = AppendComment(current.Comments, nowUtc, user, "Confirm", comment);
|
|
var next = current with
|
|
{
|
|
Confirmed = AlarmConfirmedState.Confirmed,
|
|
LastConfirmUtc = nowUtc,
|
|
LastConfirmUser = user,
|
|
LastConfirmComment = comment,
|
|
LastTransitionUtc = nowUtc,
|
|
Comments = audit,
|
|
};
|
|
return new TransitionResult(next, EmissionKind.Confirmed);
|
|
}
|
|
|
|
public static TransitionResult ApplyOneShotShelve(
|
|
AlarmConditionState current, string user, DateTime nowUtc)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(user)) throw new ArgumentException("User required.", nameof(user));
|
|
if (current.Shelving.Kind == ShelvingKind.OneShot)
|
|
return TransitionResult.NoOp(current, "already one-shot shelved");
|
|
|
|
var audit = AppendComment(current.Comments, nowUtc, user, "ShelveOneShot", null);
|
|
var next = current with
|
|
{
|
|
Shelving = new ShelvingState(ShelvingKind.OneShot, null),
|
|
LastTransitionUtc = nowUtc,
|
|
Comments = audit,
|
|
};
|
|
return new TransitionResult(next, EmissionKind.Shelved);
|
|
}
|
|
|
|
public static TransitionResult ApplyTimedShelve(
|
|
AlarmConditionState current, string user, DateTime unshelveAtUtc, DateTime nowUtc)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(user)) throw new ArgumentException("User required.", nameof(user));
|
|
if (unshelveAtUtc <= nowUtc)
|
|
throw new ArgumentOutOfRangeException(nameof(unshelveAtUtc), "Unshelve time must be in the future.");
|
|
|
|
var audit = AppendComment(current.Comments, nowUtc, user, "ShelveTimed",
|
|
$"UnshelveAtUtc={unshelveAtUtc:O}");
|
|
var next = current with
|
|
{
|
|
Shelving = new ShelvingState(ShelvingKind.Timed, unshelveAtUtc),
|
|
LastTransitionUtc = nowUtc,
|
|
Comments = audit,
|
|
};
|
|
return new TransitionResult(next, EmissionKind.Shelved);
|
|
}
|
|
|
|
public static TransitionResult ApplyUnshelve(AlarmConditionState current, string user, DateTime nowUtc)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(user)) throw new ArgumentException("User required.", nameof(user));
|
|
if (current.Shelving.Kind == ShelvingKind.Unshelved)
|
|
return TransitionResult.NoOp(current, "not shelved");
|
|
|
|
var audit = AppendComment(current.Comments, nowUtc, user, "Unshelve", null);
|
|
var next = current with
|
|
{
|
|
Shelving = ShelvingState.Unshelved,
|
|
LastTransitionUtc = nowUtc,
|
|
Comments = audit,
|
|
};
|
|
return new TransitionResult(next, EmissionKind.Unshelved);
|
|
}
|
|
|
|
public static TransitionResult ApplyEnable(AlarmConditionState current, string user, DateTime nowUtc)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(user)) throw new ArgumentException("User required.", nameof(user));
|
|
if (current.Enabled == AlarmEnabledState.Enabled)
|
|
return TransitionResult.NoOp(current, "already enabled");
|
|
|
|
var audit = AppendComment(current.Comments, nowUtc, user, "Enable", null);
|
|
var next = current with
|
|
{
|
|
Enabled = AlarmEnabledState.Enabled,
|
|
LastTransitionUtc = nowUtc,
|
|
Comments = audit,
|
|
};
|
|
return new TransitionResult(next, EmissionKind.Enabled);
|
|
}
|
|
|
|
public static TransitionResult ApplyDisable(AlarmConditionState current, string user, DateTime nowUtc)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(user)) throw new ArgumentException("User required.", nameof(user));
|
|
if (current.Enabled == AlarmEnabledState.Disabled)
|
|
return TransitionResult.NoOp(current, "already disabled");
|
|
|
|
var audit = AppendComment(current.Comments, nowUtc, user, "Disable", null);
|
|
var next = current with
|
|
{
|
|
Enabled = AlarmEnabledState.Disabled,
|
|
LastTransitionUtc = nowUtc,
|
|
Comments = audit,
|
|
};
|
|
return new TransitionResult(next, EmissionKind.Disabled);
|
|
}
|
|
|
|
public static TransitionResult ApplyAddComment(
|
|
AlarmConditionState current, string user, string text, DateTime nowUtc)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(user)) throw new ArgumentException("User required.", nameof(user));
|
|
if (string.IsNullOrWhiteSpace(text)) throw new ArgumentException("Comment text required.", nameof(text));
|
|
|
|
var audit = AppendComment(current.Comments, nowUtc, user, "AddComment", text);
|
|
var next = current with { Comments = audit };
|
|
return new TransitionResult(next, EmissionKind.CommentAdded);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Re-evaluate whether a currently timed-shelved alarm has expired. Returns
|
|
/// the (possibly unshelved) state + emission hint so the engine knows to
|
|
/// publish an Unshelved event at the right moment.
|
|
/// </summary>
|
|
public static TransitionResult ApplyShelvingCheck(AlarmConditionState current, DateTime nowUtc)
|
|
{
|
|
if (current.Shelving.Kind != ShelvingKind.Timed) return TransitionResult.None(current);
|
|
if (current.Shelving.UnshelveAtUtc is DateTime t && nowUtc >= t)
|
|
{
|
|
var audit = AppendComment(current.Comments, nowUtc, "system", "AutoUnshelve",
|
|
$"Timed shelving expired at {nowUtc:O}");
|
|
var next = current with
|
|
{
|
|
Shelving = ShelvingState.Unshelved,
|
|
LastTransitionUtc = nowUtc,
|
|
Comments = audit,
|
|
};
|
|
return new TransitionResult(next, EmissionKind.Unshelved);
|
|
}
|
|
return TransitionResult.None(current);
|
|
}
|
|
|
|
private static ShelvingState MaybeExpireShelving(ShelvingState s, DateTime nowUtc)
|
|
{
|
|
if (s.Kind != ShelvingKind.Timed) return s;
|
|
return s.UnshelveAtUtc is DateTime t && nowUtc >= t ? ShelvingState.Unshelved : s;
|
|
}
|
|
|
|
private static IReadOnlyList<AlarmComment> AppendComment(
|
|
IReadOnlyList<AlarmComment> existing, DateTime ts, string user, string kind, string? text)
|
|
{
|
|
var list = new List<AlarmComment>(existing.Count + 1);
|
|
list.AddRange(existing);
|
|
list.Add(new AlarmComment(ts, user, kind, text ?? string.Empty));
|
|
return list;
|
|
}
|
|
}
|
|
|
|
/// <summary>Result of a state-machine operation — new state + what to emit (if anything).</summary>
|
|
public sealed record TransitionResult(AlarmConditionState State, EmissionKind Emission)
|
|
{
|
|
public static TransitionResult None(AlarmConditionState state) => new(state, EmissionKind.None);
|
|
public static TransitionResult NoOp(AlarmConditionState state, string reason) => new(state, EmissionKind.None);
|
|
}
|
|
|
|
/// <summary>What kind of event, if any, the engine should emit after a transition.</summary>
|
|
public enum EmissionKind
|
|
{
|
|
/// <summary>State did not change meaningfully — no event to emit.</summary>
|
|
None,
|
|
/// <summary>Predicate transitioned to true while shelving was suppressing events.</summary>
|
|
Suppressed,
|
|
Activated,
|
|
Cleared,
|
|
Acknowledged,
|
|
Confirmed,
|
|
Shelved,
|
|
Unshelved,
|
|
Enabled,
|
|
Disabled,
|
|
CommentAdded,
|
|
}
|