8c5e2be92e
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
386 lines
17 KiB
C#
386 lines
17 KiB
C#
using System.Collections.Immutable;
|
|
|
|
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>
|
|
/// <param name="current">The current alarm condition state.</param>
|
|
/// <param name="predicateTrue">Whether the predicate is true.</param>
|
|
/// <param name="nowUtc">The current UTC time.</param>
|
|
/// <returns>The transition result.</returns>
|
|
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>
|
|
/// <param name="current">The current alarm condition state.</param>
|
|
/// <param name="user">The user who is acknowledging.</param>
|
|
/// <param name="comment">An optional comment.</param>
|
|
/// <param name="nowUtc">The current UTC time.</param>
|
|
/// <returns>The transition result.</returns>
|
|
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>
|
|
/// <param name="current">The current alarm condition state.</param>
|
|
/// <param name="user">The user who is confirming.</param>
|
|
/// <param name="comment">An optional comment.</param>
|
|
/// <param name="nowUtc">The current UTC time.</param>
|
|
/// <returns>The transition result.</returns>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Applies a one-shot shelving action.
|
|
/// </summary>
|
|
/// <param name="current">The current alarm condition state.</param>
|
|
/// <param name="user">The user applying the shelving.</param>
|
|
/// <param name="nowUtc">The current UTC time.</param>
|
|
/// <returns>The transition result.</returns>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Applies a timed shelving action.
|
|
/// </summary>
|
|
/// <param name="current">The current alarm condition state.</param>
|
|
/// <param name="user">The user applying the shelving.</param>
|
|
/// <param name="unshelveAtUtc">The UTC time at which the alarm should be unshelved.</param>
|
|
/// <param name="nowUtc">The current UTC time.</param>
|
|
/// <returns>The transition result.</returns>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Applies an unshelving action.
|
|
/// </summary>
|
|
/// <param name="current">The current alarm condition state.</param>
|
|
/// <param name="user">The user applying the unshelving.</param>
|
|
/// <param name="nowUtc">The current UTC time.</param>
|
|
/// <returns>The transition result.</returns>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Applies an enable action.
|
|
/// </summary>
|
|
/// <param name="current">The current alarm condition state.</param>
|
|
/// <param name="user">The user enabling the alarm.</param>
|
|
/// <param name="nowUtc">The current UTC time.</param>
|
|
/// <returns>The transition result.</returns>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Applies a disable action.
|
|
/// </summary>
|
|
/// <param name="current">The current alarm condition state.</param>
|
|
/// <param name="user">The user disabling the alarm.</param>
|
|
/// <param name="nowUtc">The current UTC time.</param>
|
|
/// <returns>The transition result.</returns>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Applies an add comment action.
|
|
/// </summary>
|
|
/// <param name="current">The current alarm condition state.</param>
|
|
/// <param name="user">The user adding the comment.</param>
|
|
/// <param name="text">The comment text.</param>
|
|
/// <param name="nowUtc">The current UTC time.</param>
|
|
/// <returns>The transition result.</returns>
|
|
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>
|
|
/// <param name="current">The current alarm condition state.</param>
|
|
/// <param name="nowUtc">The current UTC time.</param>
|
|
/// <returns>The transition result.</returns>
|
|
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 ImmutableList<AlarmComment> AppendComment(
|
|
ImmutableList<AlarmComment> existing, DateTime ts, string user, string kind, string? text)
|
|
=> existing.Add(new AlarmComment(ts, user, kind, text ?? string.Empty));
|
|
}
|
|
|
|
/// <summary>Result of a state-machine operation — new state + what to emit (if anything).</summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <see cref="NoOpReason"/> carries a short diagnostic string for the
|
|
/// <see cref="NoOp(AlarmConditionState, string)"/> case (e.g.
|
|
/// "disabled — predicate result ignored", "already acknowledged"). The
|
|
/// engine logs this at debug level when a no-op result is observed, so
|
|
/// the class-level remarks on <see cref="Part9StateMachine"/> hold:
|
|
/// disabled-alarm and idempotent ack/confirm/shelve/unshelve
|
|
/// transitions do produce a diagnostic log line. Plain
|
|
/// <see cref="None(AlarmConditionState)"/> results (state unchanged,
|
|
/// no operator intent recorded — e.g. a predicate re-evaluation that
|
|
/// confirms the existing active state) leave <see cref="NoOpReason"/>
|
|
/// null because there is nothing to surface to an operator.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed record TransitionResult(AlarmConditionState State, EmissionKind Emission, string? NoOpReason = null)
|
|
{
|
|
/// <summary>
|
|
/// Creates a transition result with no change and no emission.
|
|
/// </summary>
|
|
/// <param name="state">The alarm condition state.</param>
|
|
/// <returns>The transition result.</returns>
|
|
public static TransitionResult None(AlarmConditionState state) => new(state, EmissionKind.None);
|
|
|
|
/// <summary>
|
|
/// Creates a transition result indicating a no-op operation with a reason.
|
|
/// </summary>
|
|
/// <param name="state">The alarm condition state.</param>
|
|
/// <param name="reason">The reason for the no-op.</param>
|
|
/// <returns>The transition result.</returns>
|
|
public static TransitionResult NoOp(AlarmConditionState state, string reason)
|
|
=> new(state, EmissionKind.None, reason);
|
|
}
|
|
|
|
/// <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,
|
|
/// <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,
|
|
}
|