namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms; /// /// Pure functions for OPC UA Part 9 alarm-condition state transitions. Input = the /// current + the event; output = the new state + /// optional emission hint. The engine calls these; persistence happens around them. /// /// /// /// 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. /// /// /// 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 . /// /// public static class Part9StateMachine { /// /// 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. /// 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); } /// Operator acknowledges the currently-active transition. 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); } /// Operator confirms the cleared transition. Part 9 requires confirm after clear for retain-flag alarms. 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); } /// /// 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. /// 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 AppendComment( IReadOnlyList existing, DateTime ts, string user, string kind, string? text) { var list = new List(existing.Count + 1); list.AddRange(existing); list.Add(new AlarmComment(ts, user, kind, text ?? string.Empty)); return list; } } /// Result of a state-machine operation — new state + what to emit (if anything). 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); } /// What kind of event, if any, the engine should emit after a transition. public enum EmissionKind { /// State did not change meaningfully — no event to emit. None, /// Predicate transitioned to true while shelving was suppressing events. Suppressed, Activated, Cleared, Acknowledged, Confirmed, Shelved, Unshelved, Enabled, Disabled, CommentAdded, }