feat(alarms): NativeAlarmProjector maps transitions to condition snapshots (Phase B WS-4a)
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||
|
||||
/// <summary>
|
||||
/// Derives a full Part 9 <see cref="AlarmConditionSnapshot"/> from each native
|
||||
/// <see cref="AlarmEventArgs"/> delta, tracking per-condition-NodeId prior state. Owned by the
|
||||
/// single-threaded <c>DriverHostActor</c> (no locking). Native alarms carry only a transition
|
||||
/// <see cref="AlarmTransitionKind"/>, not a full state machine, so this is the translation the
|
||||
/// scripted-alarm engine does internally.
|
||||
/// </summary>
|
||||
public sealed class NativeAlarmProjector
|
||||
{
|
||||
private readonly Dictionary<string, (bool Active, bool Acked, ushort Severity, string Message)> _prior =
|
||||
new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Project an alarm transition onto the full condition snapshot for <paramref name="nodeId"/>.</summary>
|
||||
/// <param name="nodeId">The materialised condition node's id (the projection's state key).</param>
|
||||
/// <param name="e">The native alarm transition.</param>
|
||||
/// <returns>The full Part 9 condition snapshot to write to the node.</returns>
|
||||
public AlarmConditionSnapshot Project(string nodeId, AlarmEventArgs e)
|
||||
{
|
||||
var prev = _prior.TryGetValue(nodeId, out var p)
|
||||
? p
|
||||
: (Active: false, Acked: true, Severity: (ushort)0, Message: string.Empty);
|
||||
var sev = MapSeverity(e.Severity);
|
||||
var (active, acked) = e.Kind switch
|
||||
{
|
||||
AlarmTransitionKind.Raise or AlarmTransitionKind.Retrigger => (true, false),
|
||||
AlarmTransitionKind.Acknowledge => (prev.Active, true),
|
||||
AlarmTransitionKind.Clear => (false, prev.Acked),
|
||||
_ => (prev.Active, prev.Acked),
|
||||
};
|
||||
_prior[nodeId] = (active, acked, sev, e.Message);
|
||||
return new AlarmConditionSnapshot(
|
||||
Active: active, Acknowledged: acked, Confirmed: true, Enabled: true,
|
||||
Shelving: AlarmShelvingKind.Unshelved, Severity: sev, Message: e.Message);
|
||||
}
|
||||
|
||||
/// <summary>Clears all tracked per-node state (call on address-space rebuild).</summary>
|
||||
public void Clear() => _prior.Clear();
|
||||
|
||||
private static ushort MapSeverity(AlarmSeverity s) => s switch
|
||||
{
|
||||
AlarmSeverity.Low => 200,
|
||||
AlarmSeverity.Medium => 500,
|
||||
AlarmSeverity.High => 700,
|
||||
AlarmSeverity.Critical => 900,
|
||||
_ => 500,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
||||
|
||||
public class NativeAlarmProjectorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Raise_projects_active_unacked_with_constant_fields_and_mapped_severity()
|
||||
{
|
||||
var sut = new NativeAlarmProjector();
|
||||
|
||||
var snap = sut.Project("n1", Evt(AlarmTransitionKind.Raise, AlarmSeverity.High, "boom"));
|
||||
|
||||
snap.Active.ShouldBeTrue();
|
||||
snap.Acknowledged.ShouldBeFalse();
|
||||
snap.Confirmed.ShouldBeTrue();
|
||||
snap.Enabled.ShouldBeTrue();
|
||||
snap.Shelving.ShouldBe(AlarmShelvingKind.Unshelved);
|
||||
snap.Severity.ShouldBe((ushort)700);
|
||||
snap.Message.ShouldBe("boom");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Retrigger_projects_active_unacked()
|
||||
{
|
||||
var sut = new NativeAlarmProjector();
|
||||
|
||||
var snap = sut.Project("n1", Evt(AlarmTransitionKind.Retrigger));
|
||||
|
||||
snap.Active.ShouldBeTrue();
|
||||
snap.Acknowledged.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Acknowledge_after_raise_acks_and_keeps_active()
|
||||
{
|
||||
var sut = new NativeAlarmProjector();
|
||||
sut.Project("n1", Evt(AlarmTransitionKind.Raise));
|
||||
|
||||
var snap = sut.Project("n1", Evt(AlarmTransitionKind.Acknowledge));
|
||||
|
||||
snap.Active.ShouldBeTrue();
|
||||
snap.Acknowledged.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clear_after_raise_deactivates_and_keeps_prior_ack()
|
||||
{
|
||||
var sut = new NativeAlarmProjector();
|
||||
sut.Project("n1", Evt(AlarmTransitionKind.Raise)); // active, unacked
|
||||
|
||||
var snap = sut.Project("n1", Evt(AlarmTransitionKind.Clear));
|
||||
|
||||
snap.Active.ShouldBeFalse();
|
||||
snap.Acknowledged.ShouldBeFalse(); // preserved prior (unacked) ack state
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clear_after_acknowledge_deactivates_and_keeps_acked()
|
||||
{
|
||||
var sut = new NativeAlarmProjector();
|
||||
sut.Project("n1", Evt(AlarmTransitionKind.Raise));
|
||||
sut.Project("n1", Evt(AlarmTransitionKind.Acknowledge)); // active, acked
|
||||
|
||||
var snap = sut.Project("n1", Evt(AlarmTransitionKind.Clear));
|
||||
|
||||
snap.Active.ShouldBeFalse();
|
||||
snap.Acknowledged.ShouldBeTrue(); // preserved prior (acked) ack state
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unspecified_carries_prior_active_and_ack_state()
|
||||
{
|
||||
var sut = new NativeAlarmProjector();
|
||||
sut.Project("n1", Evt(AlarmTransitionKind.Raise)); // active, unacked
|
||||
|
||||
var snap = sut.Project("n1", Evt(AlarmTransitionKind.Unspecified));
|
||||
|
||||
snap.Active.ShouldBeTrue();
|
||||
snap.Acknowledged.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cold_state_defaults_to_inactive_acked()
|
||||
{
|
||||
var sut = new NativeAlarmProjector();
|
||||
|
||||
// Acknowledge on a never-seen node: prior Active defaults false, ack set true.
|
||||
var snap = sut.Project("nNew", Evt(AlarmTransitionKind.Acknowledge));
|
||||
|
||||
snap.Active.ShouldBeFalse();
|
||||
snap.Acknowledged.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Per_node_state_is_isolated()
|
||||
{
|
||||
var sut = new NativeAlarmProjector();
|
||||
sut.Project("n1", Evt(AlarmTransitionKind.Raise)); // n1 active
|
||||
|
||||
// n2 is cold; a Clear on it must stay inactive and not borrow n1's active state.
|
||||
var n2 = sut.Project("n2", Evt(AlarmTransitionKind.Clear));
|
||||
|
||||
n2.Active.ShouldBeFalse();
|
||||
|
||||
// n1 is unaffected by the n2 transition.
|
||||
var n1 = sut.Project("n1", Evt(AlarmTransitionKind.Acknowledge));
|
||||
n1.Active.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AlarmSeverity.Low, (ushort)200)]
|
||||
[InlineData(AlarmSeverity.Medium, (ushort)500)]
|
||||
[InlineData(AlarmSeverity.High, (ushort)700)]
|
||||
[InlineData(AlarmSeverity.Critical, (ushort)900)]
|
||||
public void Severity_buckets_map_to_opcua_scale(AlarmSeverity sev, ushort expected)
|
||||
{
|
||||
var sut = new NativeAlarmProjector();
|
||||
|
||||
var snap = sut.Project("n1", Evt(AlarmTransitionKind.Raise, sev));
|
||||
|
||||
snap.Severity.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Severity_and_message_always_come_from_the_event()
|
||||
{
|
||||
var sut = new NativeAlarmProjector();
|
||||
sut.Project("n1", Evt(AlarmTransitionKind.Raise, AlarmSeverity.High, "first"));
|
||||
|
||||
// A later Clear carries its own severity + message; the snapshot reflects the event, not prior.
|
||||
var snap = sut.Project("n1", Evt(AlarmTransitionKind.Clear, AlarmSeverity.Low, "second"));
|
||||
|
||||
snap.Severity.ShouldBe((ushort)200);
|
||||
snap.Message.ShouldBe("second");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clear_resets_tracked_state()
|
||||
{
|
||||
var sut = new NativeAlarmProjector();
|
||||
sut.Project("n1", Evt(AlarmTransitionKind.Raise)); // n1 active, unacked
|
||||
|
||||
sut.Clear();
|
||||
|
||||
// After Clear() the node is cold again: an Acknowledge sees prior Active=false.
|
||||
var snap = sut.Project("n1", Evt(AlarmTransitionKind.Acknowledge));
|
||||
snap.Active.ShouldBeFalse();
|
||||
snap.Acknowledged.ShouldBeTrue();
|
||||
}
|
||||
|
||||
private static AlarmEventArgs Evt(
|
||||
AlarmTransitionKind kind,
|
||||
AlarmSeverity sev = AlarmSeverity.High,
|
||||
string msg = "m")
|
||||
=> new(new H(), "Tank1.Hi", "c1", "LimitAlarm.Hi", msg, sev, DateTime.UnixEpoch, Kind: kind);
|
||||
|
||||
private sealed class H : IAlarmSubscriptionHandle
|
||||
{
|
||||
public string DiagnosticId => "t";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user