a25593a9c6
Group all 69 projects into category subfolders under src/ and tests/ so the Rider Solution Explorer mirrors the module structure. Folders: Core, Server, Drivers (with a nested Driver CLIs subfolder), Client, Tooling. - Move every project folder on disk with git mv (history preserved as renames). - Recompute relative paths in 57 .csproj files: cross-category ProjectReferences, the lib/ HintPath+None refs in Driver.Historian.Wonderware, and the external mxaccessgw refs in Driver.Galaxy and its test project. - Rebuild ZB.MOM.WW.OtOpcUa.slnx with nested solution folders. - Re-prefix project paths in functional scripts (e2e, compliance, smoke SQL, integration, install). Build green (0 errors); unit tests pass. Docs left for a separate pass. 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,
|
|
}
|