feat(scripted-alarms): ScriptedAlarmHostActor — engine runtime host (T9)
This commit is contained in:
@@ -0,0 +1,289 @@
|
|||||||
|
using Akka.Actor;
|
||||||
|
using Akka.Cluster.Tools.PublishSubscribe;
|
||||||
|
using Akka.Event;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Akka host that owns one <see cref="ScriptedAlarmEngine"/> for an Equipment-namespace
|
||||||
|
/// driver node, feeds it live tag values, and bridges its emissions to OPC UA publish + the
|
||||||
|
/// cluster <c>alerts</c> DistributedPubSub topic.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// The host is the engine's lifecycle owner: the caller (the driver-role startup) builds
|
||||||
|
/// the <see cref="DependencyMuxTagUpstreamSource"/>, constructs a <see cref="ScriptedAlarmEngine"/>
|
||||||
|
/// <b>around that same upstream</b>, and passes both here. The host disposes the engine in
|
||||||
|
/// <see cref="PostStop"/>.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>Data flow.</b> <see cref="DependencyMuxActor"/> delivers a
|
||||||
|
/// <see cref="VirtualTagActor.DependencyValueChanged"/> for every tag the loaded alarms
|
||||||
|
/// depend on; the host pushes each into the upstream. The engine self-evaluates: its own
|
||||||
|
/// <see cref="ITagUpstreamSource.SubscribeTag"/> observer re-runs the predicates and raises
|
||||||
|
/// <see cref="ScriptedAlarmEngine.OnEvent"/>. The host does NOT call evaluate — it only
|
||||||
|
/// feeds values in and fans emissions out.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>Thread-safety.</b> <see cref="ScriptedAlarmEngine.OnEvent"/> fires on the engine's
|
||||||
|
/// background worker thread (its fire-and-forget re-evaluation), NOT on the actor thread.
|
||||||
|
/// The ctor subscribes a handler that does nothing but <c>Self.Tell(new EngineEmission(e))</c>
|
||||||
|
/// — <see cref="ICanTell.Tell"/> is thread-safe. ALL sink work (touching <see cref="Context"/>,
|
||||||
|
/// the publish actor ref, the DPS mediator) then happens on the actor thread in
|
||||||
|
/// <see cref="OnEngineEmission"/>. No <see cref="Context"/> or actor state is ever read or
|
||||||
|
/// written from the OnEvent callback.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>Historization.</b> The host publishes each transition as an
|
||||||
|
/// <see cref="AlarmTransitionEvent"/> to the <c>alerts</c> topic ONLY. That topic is the
|
||||||
|
/// historization path: <c>HistorianAdapterActor</c>'s upstream and the Admin UI Alerts page
|
||||||
|
/// both consume <c>alerts</c>. The host deliberately does NOT also <c>Tell</c> the historian
|
||||||
|
/// adapter directly — doing so would double-historize every transition. (T9 plan called for a
|
||||||
|
/// "direct historian tell"; that was dropped because the alerts topic already feeds the
|
||||||
|
/// historian path, so a direct tell would duplicate every row.)
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ScriptedAlarmHostActor : ReceiveActor
|
||||||
|
{
|
||||||
|
/// <summary>The cluster DistributedPubSub topic every alarm transition is published to. Matches
|
||||||
|
/// the constant the (retired) <c>ScriptedAlarmActor</c> used so subscribers stay wired.</summary>
|
||||||
|
public const string AlertsTopic = "alerts";
|
||||||
|
|
||||||
|
/// <summary>Reconcile the loaded alarm set to exactly the enabled subset of <paramref name="Plans"/>:
|
||||||
|
/// builds <see cref="ScriptedAlarmDefinition"/>s (skipping disabled plans), reloads the engine, and
|
||||||
|
/// re-registers mux interest for the union of dependency refs.</summary>
|
||||||
|
/// <param name="Plans">The desired Equipment-namespace scripted-alarm plans.</param>
|
||||||
|
public sealed record ApplyScriptedAlarms(IReadOnlyList<EquipmentScriptedAlarmPlan> Plans);
|
||||||
|
|
||||||
|
/// <summary>Marshals an engine emission off the engine's worker thread onto the actor thread.
|
||||||
|
/// Carries the <see cref="ScriptedAlarmEvent"/> the engine raised on <c>OnEvent</c>.</summary>
|
||||||
|
private sealed record EngineEmission(ScriptedAlarmEvent Event);
|
||||||
|
|
||||||
|
/// <summary>Pipe-back completion of an in-flight <see cref="ScriptedAlarmEngine.LoadAsync"/>:
|
||||||
|
/// carries the union of dependency refs to register mux interest for AFTER the load completed
|
||||||
|
/// (so the engine's upstream subscriptions exist before any value can arrive).</summary>
|
||||||
|
private sealed record AlarmsLoaded(IReadOnlyList<string> DepRefs);
|
||||||
|
|
||||||
|
private readonly IActorRef _publishActor;
|
||||||
|
private readonly IActorRef? _mux;
|
||||||
|
private readonly DependencyMuxTagUpstreamSource _upstream;
|
||||||
|
private readonly ScriptedAlarmEngine _engine;
|
||||||
|
private readonly Func<DateTime> _clock;
|
||||||
|
private readonly ILoggingAdapter _log = Context.GetLogger();
|
||||||
|
private readonly CancellationTokenSource _cts = new();
|
||||||
|
private readonly EventHandler<ScriptedAlarmEvent> _onEngineEvent;
|
||||||
|
|
||||||
|
/// <summary>Factory method to create Props for a <see cref="ScriptedAlarmHostActor"/>.</summary>
|
||||||
|
/// <param name="publishActor">The OPC UA publish actor that consumes
|
||||||
|
/// <see cref="OpcUaPublishActor.AlarmStateUpdate"/> bridged from engine emissions.</param>
|
||||||
|
/// <param name="mux">Optional dependency multiplexer the host registers interest with so it
|
||||||
|
/// receives a <see cref="VirtualTagActor.DependencyValueChanged"/> per dependency tag. Null on the
|
||||||
|
/// dev/Mac path (no live values).</param>
|
||||||
|
/// <param name="upstream">The mux-fed upstream the engine reads + subscribes from. MUST be the
|
||||||
|
/// same instance the <paramref name="engine"/> was constructed around.</param>
|
||||||
|
/// <param name="engine">The scripted-alarm engine this host owns + disposes.</param>
|
||||||
|
/// <param name="clock">Optional UTC clock; defaults to <see cref="DateTime.UtcNow"/>.</param>
|
||||||
|
public static Props Props(
|
||||||
|
IActorRef publishActor,
|
||||||
|
IActorRef? mux,
|
||||||
|
DependencyMuxTagUpstreamSource upstream,
|
||||||
|
ScriptedAlarmEngine engine,
|
||||||
|
Func<DateTime>? clock = null) =>
|
||||||
|
Akka.Actor.Props.Create(() => new ScriptedAlarmHostActor(publishActor, mux, upstream, engine, clock));
|
||||||
|
|
||||||
|
/// <summary>Initializes a new instance of the <see cref="ScriptedAlarmHostActor"/> class.</summary>
|
||||||
|
/// <param name="publishActor">The OPC UA publish actor emissions are bridged to.</param>
|
||||||
|
/// <param name="mux">Optional dependency multiplexer the host registers dependency interest with.</param>
|
||||||
|
/// <param name="upstream">The mux-fed upstream the engine reads + subscribes from (same instance the engine wraps).</param>
|
||||||
|
/// <param name="engine">The scripted-alarm engine this host owns + disposes.</param>
|
||||||
|
/// <param name="clock">Optional UTC clock; defaults to <see cref="DateTime.UtcNow"/>.</param>
|
||||||
|
public ScriptedAlarmHostActor(
|
||||||
|
IActorRef publishActor,
|
||||||
|
IActorRef? mux,
|
||||||
|
DependencyMuxTagUpstreamSource upstream,
|
||||||
|
ScriptedAlarmEngine engine,
|
||||||
|
Func<DateTime>? clock = null)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(publishActor);
|
||||||
|
ArgumentNullException.ThrowIfNull(upstream);
|
||||||
|
ArgumentNullException.ThrowIfNull(engine);
|
||||||
|
_publishActor = publishActor;
|
||||||
|
_mux = mux;
|
||||||
|
_upstream = upstream;
|
||||||
|
_engine = engine;
|
||||||
|
_clock = clock ?? (() => DateTime.UtcNow);
|
||||||
|
|
||||||
|
// OnEvent fires on the engine's worker thread. NEVER touch Context / actor state here —
|
||||||
|
// marshal onto the actor thread via the thread-safe Self.Tell. Keep the handler in a field
|
||||||
|
// so PostStop can unsubscribe it. (Self is captured once; it is stable for the actor's life.)
|
||||||
|
var self = Self;
|
||||||
|
_onEngineEvent = (_, e) => self.Tell(new EngineEmission(e));
|
||||||
|
_engine.OnEvent += _onEngineEvent;
|
||||||
|
|
||||||
|
Receive<ApplyScriptedAlarms>(OnApply);
|
||||||
|
Receive<AlarmsLoaded>(OnAlarmsLoaded);
|
||||||
|
Receive<VirtualTagActor.DependencyValueChanged>(OnDependencyChanged);
|
||||||
|
Receive<EngineEmission>(OnEngineEmission);
|
||||||
|
// A faulted LoadAsync pipes back a Status.Failure (see OnApply) — log it and stay inert so the
|
||||||
|
// failure doesn't hit the dead-letter log.
|
||||||
|
Receive<Status.Failure>(OnLoadFailed);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnApply(ApplyScriptedAlarms msg)
|
||||||
|
{
|
||||||
|
// Skip disabled plans entirely — the engine has no Enabled flag, so a disabled alarm is simply
|
||||||
|
// not loaded (no predicate, no upstream subscription, no events).
|
||||||
|
var enabled = msg.Plans.Where(p => p.Enabled).ToList();
|
||||||
|
|
||||||
|
var defs = enabled.Select(ToDefinition).ToList();
|
||||||
|
|
||||||
|
// Union of dependency refs across the loaded (enabled) alarms — interest is registered with
|
||||||
|
// the mux only AFTER LoadAsync completes (see OnAlarmsLoaded), because the engine establishes
|
||||||
|
// its upstream SubscribeTag subscriptions inside LoadAsync; values must not arrive before then.
|
||||||
|
var depRefs = enabled
|
||||||
|
.SelectMany(p => p.DependencyRefs)
|
||||||
|
.Distinct(StringComparer.Ordinal)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// PipeTo marshals the completion back onto the actor thread; on failure we log a Warning and
|
||||||
|
// do NOT register interest (the prior generation's subscription, if any, stays — a failed
|
||||||
|
// reload should not silently drop live values). Self-message AlarmsLoaded carries the refs.
|
||||||
|
_engine.LoadAsync(defs, _cts.Token)
|
||||||
|
.ContinueWith(
|
||||||
|
t => t.IsFaulted
|
||||||
|
? (object)new Status.Failure(t.Exception!)
|
||||||
|
: new AlarmsLoaded(depRefs),
|
||||||
|
_cts.Token,
|
||||||
|
TaskContinuationOptions.ExecuteSynchronously,
|
||||||
|
TaskScheduler.Default)
|
||||||
|
.PipeTo(Self);
|
||||||
|
|
||||||
|
_log.Debug("ScriptedAlarmHost: applying (enabled={Enabled}/{Total}, depRefs={Refs})",
|
||||||
|
enabled.Count, msg.Plans.Count, depRefs.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnLoadFailed(Status.Failure msg)
|
||||||
|
=> _log.Warning(msg.Cause, "ScriptedAlarmHost: engine LoadAsync failed — alarms not (re)loaded");
|
||||||
|
|
||||||
|
private void OnAlarmsLoaded(AlarmsLoaded msg)
|
||||||
|
{
|
||||||
|
// Register mux interest only now that LoadAsync has returned — the engine's upstream
|
||||||
|
// subscriptions exist, so any DependencyValueChanged the mux delivers will be observed by the
|
||||||
|
// engine. RegisterInterest replaces a subscriber's prior interest set (see DependencyMuxActor),
|
||||||
|
// so a re-Apply with a fresh union simply supersedes the old one — no explicit unregister needed.
|
||||||
|
_mux?.Tell(new DependencyMuxActor.RegisterInterest(msg.DepRefs, Self));
|
||||||
|
_log.Debug("ScriptedAlarmHost: loaded; registered mux interest for {Count} dep refs", msg.DepRefs.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDependencyChanged(VirtualTagActor.DependencyValueChanged msg)
|
||||||
|
{
|
||||||
|
// Feed the live value into the upstream the engine subscribes from. StatusCode 0 = Good; the
|
||||||
|
// mux only forwards values it received from a driver publish, so we treat them as Good-quality.
|
||||||
|
_upstream.Push(msg.TagId, new DataValueSnapshot(msg.Value, 0u, msg.TimestampUtc, msg.TimestampUtc));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnEngineEmission(EngineEmission msg)
|
||||||
|
{
|
||||||
|
var e = msg.Event;
|
||||||
|
|
||||||
|
// None = no meaningful change; Suppressed = shelving ate the emission. Neither should reach a
|
||||||
|
// sink. (The engine already filters these out of BuildEmission, but guard defensively.)
|
||||||
|
if (e.Emission is EmissionKind.None or EmissionKind.Suppressed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bridge to OPC UA: drive the alarm node's Active / Acknowledged sub-vars. We use e.AlarmId as
|
||||||
|
// the node id for now — T14 will materialise the real condition node at an aligned NodeId and
|
||||||
|
// this id will line up with it.
|
||||||
|
_publishActor.Tell(new OpcUaPublishActor.AlarmStateUpdate(
|
||||||
|
AlarmNodeId: e.AlarmId,
|
||||||
|
Active: e.Condition.Active == AlarmActiveState.Active,
|
||||||
|
Acknowledged: e.Condition.Acked == AlarmAckedState.Acknowledged,
|
||||||
|
TimestampUtc: e.TimestampUtc));
|
||||||
|
|
||||||
|
// Publish the transition to the cluster `alerts` topic — the single historization + live
|
||||||
|
// fan-out path. The mediator is obtained on the ACTOR thread (here), never off-thread.
|
||||||
|
var evt = new AlarmTransitionEvent(
|
||||||
|
AlarmId: e.AlarmId,
|
||||||
|
EquipmentPath: e.EquipmentPath,
|
||||||
|
AlarmName: e.AlarmName,
|
||||||
|
TransitionKind: e.Emission.ToString(),
|
||||||
|
Severity: SeverityToInt(e.Severity),
|
||||||
|
Message: e.Message,
|
||||||
|
User: TransitionUser(e),
|
||||||
|
TimestampUtc: e.TimestampUtc);
|
||||||
|
|
||||||
|
DistributedPubSub.Get(Context.System).Mediator.Tell(new Publish(AlertsTopic, evt));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void PostStop()
|
||||||
|
{
|
||||||
|
// Unregister mux interest first so no further DependencyValueChanged arrives while we tear down.
|
||||||
|
_mux?.Tell(new DependencyMuxActor.UnregisterInterest(Self));
|
||||||
|
// Cancel any in-flight LoadAsync, detach the OnEvent handler (so a late engine emission can't
|
||||||
|
// Tell a stopping actor), then dispose the engine (which drains its background work + clears).
|
||||||
|
_cts.Cancel();
|
||||||
|
_engine.OnEvent -= _onEngineEvent;
|
||||||
|
_engine.Dispose();
|
||||||
|
_cts.Dispose();
|
||||||
|
base.PostStop();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Maps an <see cref="EquipmentScriptedAlarmPlan"/> to the engine's
|
||||||
|
/// <see cref="ScriptedAlarmDefinition"/>. <see cref="EquipmentScriptedAlarmPlan.AlarmType"/> is a
|
||||||
|
/// string that must parse to an <see cref="AlarmKind"/>; an unrecognised type falls back to
|
||||||
|
/// <see cref="AlarmKind.AlarmCondition"/> rather than dropping the alarm.</summary>
|
||||||
|
private static ScriptedAlarmDefinition ToDefinition(EquipmentScriptedAlarmPlan p) => new(
|
||||||
|
AlarmId: p.ScriptedAlarmId,
|
||||||
|
EquipmentPath: p.EquipmentId,
|
||||||
|
AlarmName: p.Name,
|
||||||
|
Kind: Enum.TryParse<AlarmKind>(p.AlarmType, out var k) ? k : AlarmKind.AlarmCondition,
|
||||||
|
Severity: SeverityFromInt(p.Severity),
|
||||||
|
MessageTemplate: p.MessageTemplate,
|
||||||
|
PredicateScriptSource: p.PredicateSource,
|
||||||
|
HistorizeToAveva: p.HistorizeToAveva,
|
||||||
|
Retain: p.Retain);
|
||||||
|
|
||||||
|
/// <summary>The acting user for an <see cref="AlarmTransitionEvent"/>. Engine-driven
|
||||||
|
/// Activated / Cleared transitions are <c>"system"</c>; operator Acknowledged / Confirmed carry the
|
||||||
|
/// recorded user from the condition state, falling back to <c>"system"</c> when none was recorded.</summary>
|
||||||
|
private static string TransitionUser(ScriptedAlarmEvent e) => e.Emission switch
|
||||||
|
{
|
||||||
|
EmissionKind.Acknowledged => e.Condition.LastAckUser ?? "system",
|
||||||
|
EmissionKind.Confirmed => e.Condition.LastConfirmUser ?? "system",
|
||||||
|
_ => "system",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Severity conversion convention: the engine + plan disagree on type (engine = AlarmSeverity enum,
|
||||||
|
// plan + AlarmTransitionEvent = OPC UA 1–1000 int). We bucket the int into quartiles on the way IN
|
||||||
|
// and emit the quartile ceiling on the way OUT, so a round-trip is stable within a bucket.
|
||||||
|
|
||||||
|
/// <summary>Buckets an OPC UA 1–1000 severity into the engine's coarse <see cref="AlarmSeverity"/>
|
||||||
|
/// enum: ≤250 Low, ≤500 Medium, ≤750 High, else Critical.</summary>
|
||||||
|
private static AlarmSeverity SeverityFromInt(int s) =>
|
||||||
|
s <= 250 ? AlarmSeverity.Low
|
||||||
|
: s <= 500 ? AlarmSeverity.Medium
|
||||||
|
: s <= 750 ? AlarmSeverity.High
|
||||||
|
: AlarmSeverity.Critical;
|
||||||
|
|
||||||
|
/// <summary>Maps the engine's coarse <see cref="AlarmSeverity"/> back to an OPC UA 1–1000 severity
|
||||||
|
/// at each bucket's ceiling: Low=250, Medium=500, High=750, Critical=1000.</summary>
|
||||||
|
private static int SeverityToInt(AlarmSeverity s) => s switch
|
||||||
|
{
|
||||||
|
AlarmSeverity.Low => 250,
|
||||||
|
AlarmSeverity.Medium => 500,
|
||||||
|
AlarmSeverity.High => 750,
|
||||||
|
AlarmSeverity.Critical => 1000,
|
||||||
|
_ => 500,
|
||||||
|
};
|
||||||
|
}
|
||||||
+173
@@ -0,0 +1,173 @@
|
|||||||
|
using Akka.Actor;
|
||||||
|
using Akka.Cluster.Tools.PublishSubscribe;
|
||||||
|
using Akka.TestKit;
|
||||||
|
using Serilog;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.ScriptedAlarms;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies <see cref="ScriptedAlarmHostActor"/> loads the enabled subset of
|
||||||
|
/// <see cref="EquipmentScriptedAlarmPlan"/>s into its <see cref="ScriptedAlarmEngine"/>, registers mux
|
||||||
|
/// interest for their dependency refs after the load completes, feeds live
|
||||||
|
/// <see cref="VirtualTagActor.DependencyValueChanged"/> values into the engine, and bridges the
|
||||||
|
/// engine's emissions to both an <see cref="OpcUaPublishActor.AlarmStateUpdate"/> and an
|
||||||
|
/// <see cref="AlarmTransitionEvent"/> on the cluster <c>alerts</c> topic.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(8);
|
||||||
|
|
||||||
|
/// <summary>Plan whose predicate compares the single tag "M.T" against 90 — enabled by default.</summary>
|
||||||
|
private static EquipmentScriptedAlarmPlan Plan(
|
||||||
|
string id = "alm-1",
|
||||||
|
string equipmentId = "Plant/Line1/M",
|
||||||
|
string name = "HighTemp",
|
||||||
|
string depRef = "M.T",
|
||||||
|
int threshold = 90,
|
||||||
|
bool enabled = true,
|
||||||
|
int severity = 800) =>
|
||||||
|
new(
|
||||||
|
ScriptedAlarmId: id,
|
||||||
|
EquipmentId: equipmentId,
|
||||||
|
Name: name,
|
||||||
|
AlarmType: "AlarmCondition",
|
||||||
|
Severity: severity,
|
||||||
|
MessageTemplate: "condition",
|
||||||
|
PredicateScriptId: $"{id}-script",
|
||||||
|
PredicateSource: $"return (int)ctx.GetTag(\"{depRef}\").Value > {threshold};",
|
||||||
|
DependencyRefs: new[] { depRef },
|
||||||
|
HistorizeToAveva: true,
|
||||||
|
Retain: true,
|
||||||
|
Enabled: enabled);
|
||||||
|
|
||||||
|
private static ScriptedAlarmEngine BuildEngine(DependencyMuxTagUpstreamSource upstream)
|
||||||
|
{
|
||||||
|
var logger = new LoggerConfiguration().CreateLogger();
|
||||||
|
return new ScriptedAlarmEngine(upstream, new InMemoryAlarmStateStore(), new ScriptLoggerFactory(logger), logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
private (IActorRef Host, DependencyMuxTagUpstreamSource Upstream) Spawn(
|
||||||
|
TestProbe publish, TestProbe mux)
|
||||||
|
{
|
||||||
|
var upstream = new DependencyMuxTagUpstreamSource();
|
||||||
|
var engine = BuildEngine(upstream);
|
||||||
|
var host = Sys.ActorOf(ScriptedAlarmHostActor.Props(publish.Ref, mux.Ref, upstream, engine));
|
||||||
|
return (host, upstream);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Subscribe <paramref name="probe"/> to the <c>alerts</c> DPS topic and wait for the ack.
|
||||||
|
/// The Subscribe is sent FROM the probe so the SubscribeAck returns to it.</summary>
|
||||||
|
private void SubscribeToAlerts(TestProbe probe)
|
||||||
|
{
|
||||||
|
DistributedPubSub.Get(Sys).Mediator.Tell(
|
||||||
|
new Subscribe(ScriptedAlarmHostActor.AlertsTopic, probe.Ref), probe.Ref);
|
||||||
|
probe.ExpectMsg<SubscribeAck>(Timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Load + interest: applying one enabled alarm registers mux interest for its dep ref
|
||||||
|
/// AFTER the engine load completes; a disabled alarm in the same apply contributes no dep ref.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Apply_loads_enabled_alarm_and_registers_interest()
|
||||||
|
{
|
||||||
|
var publish = CreateTestProbe();
|
||||||
|
var mux = CreateTestProbe();
|
||||||
|
var (host, _) = Spawn(publish, mux);
|
||||||
|
|
||||||
|
host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[]
|
||||||
|
{
|
||||||
|
Plan(id: "alm-1", depRef: "M.T"),
|
||||||
|
Plan(id: "alm-2", depRef: "M.X", enabled: false), // disabled — not loaded, dep absent
|
||||||
|
}));
|
||||||
|
|
||||||
|
var reg = mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(Timeout);
|
||||||
|
reg.TagRefs.ShouldContain("M.T");
|
||||||
|
reg.TagRefs.ShouldNotContain("M.X");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Activation path: with the alarm loaded, pushing a value above the threshold drives an
|
||||||
|
/// Inactive→Active transition — the host publishes an AlarmStateUpdate(Active=true) and an
|
||||||
|
/// AlarmTransitionEvent("Activated") on the alerts topic.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Dependency_change_above_threshold_activates_alarm()
|
||||||
|
{
|
||||||
|
var publish = CreateTestProbe();
|
||||||
|
var mux = CreateTestProbe();
|
||||||
|
var alerts = CreateTestProbe();
|
||||||
|
SubscribeToAlerts(alerts);
|
||||||
|
|
||||||
|
var (host, _) = Spawn(publish, mux);
|
||||||
|
host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan(severity: 800) }));
|
||||||
|
mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(Timeout); // load completed
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
host.Tell(new VirtualTagActor.DependencyValueChanged("M.T", 99, now));
|
||||||
|
|
||||||
|
var state = publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(Timeout);
|
||||||
|
state.AlarmNodeId.ShouldBe("alm-1");
|
||||||
|
state.Active.ShouldBeTrue();
|
||||||
|
|
||||||
|
var evt = alerts.ExpectMsg<AlarmTransitionEvent>(Timeout);
|
||||||
|
evt.AlarmId.ShouldBe("alm-1");
|
||||||
|
evt.TransitionKind.ShouldBe("Activated");
|
||||||
|
evt.Severity.ShouldBe(1000); // 800 → Critical bucket → 1000
|
||||||
|
evt.User.ShouldBe("system");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Clear path: after activating, pushing a value below the threshold drives Active→Inactive
|
||||||
|
/// — AlarmStateUpdate(Active=false) + AlarmTransitionEvent("Cleared").</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Dependency_change_below_threshold_clears_alarm()
|
||||||
|
{
|
||||||
|
var publish = CreateTestProbe();
|
||||||
|
var mux = CreateTestProbe();
|
||||||
|
var alerts = CreateTestProbe();
|
||||||
|
SubscribeToAlerts(alerts);
|
||||||
|
|
||||||
|
var (host, _) = Spawn(publish, mux);
|
||||||
|
host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan() }));
|
||||||
|
mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(Timeout);
|
||||||
|
|
||||||
|
// Activate first.
|
||||||
|
host.Tell(new VirtualTagActor.DependencyValueChanged("M.T", 99, DateTime.UtcNow));
|
||||||
|
publish.FishForMessage<OpcUaPublishActor.AlarmStateUpdate>(m => m.Active, Timeout);
|
||||||
|
alerts.FishForMessage<AlarmTransitionEvent>(e => e.TransitionKind == "Activated", Timeout);
|
||||||
|
|
||||||
|
// Now clear.
|
||||||
|
host.Tell(new VirtualTagActor.DependencyValueChanged("M.T", 10, DateTime.UtcNow));
|
||||||
|
|
||||||
|
var cleared = publish.FishForMessage<OpcUaPublishActor.AlarmStateUpdate>(m => !m.Active, Timeout);
|
||||||
|
cleared.AlarmNodeId.ShouldBe("alm-1");
|
||||||
|
|
||||||
|
var evt = alerts.FishForMessage<AlarmTransitionEvent>(e => e.TransitionKind == "Cleared", Timeout);
|
||||||
|
evt.AlarmId.ShouldBe("alm-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Re-apply reloads: a second ApplyScriptedAlarms with a different alarm set loads the new
|
||||||
|
/// alarm — a fresh RegisterInterest reflecting the new dependency refs lands on the mux.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Reapply_reloads_with_new_dependency_refs()
|
||||||
|
{
|
||||||
|
var publish = CreateTestProbe();
|
||||||
|
var mux = CreateTestProbe();
|
||||||
|
var (host, _) = Spawn(publish, mux);
|
||||||
|
|
||||||
|
host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan(id: "alm-1", depRef: "M.T") }));
|
||||||
|
var first = mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(Timeout);
|
||||||
|
first.TagRefs.ShouldContain("M.T");
|
||||||
|
|
||||||
|
host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan(id: "alm-9", depRef: "M.Y") }));
|
||||||
|
var second = mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(Timeout);
|
||||||
|
second.TagRefs.ShouldContain("M.Y");
|
||||||
|
second.TagRefs.ShouldNotContain("M.T");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user