feat(alarms): HiLo trigger type with per-band level, hysteresis, messages, overrides
Adds a new HiLo alarm trigger type with four configurable setpoints
(LoLo / Lo / Hi / HiHi). Each setpoint carries an optional priority,
deadband (for hysteresis), and operator message. The site runtime emits
AlarmStateChanged with an AlarmLevel field so consumers can differentiate
warning vs critical bands.
Plumbing:
- new AlarmLevel enum + AlarmStateChanged.Level/Message init properties
- AlarmTriggerEditor (Blazor) gets a HiLo render with severity tinting
- AlarmTriggerConfigCodec extracted from the editor for testability
- sitestream.proto carries level + message over gRPC
- SemanticValidator enforces numeric attribute, setpoint ordering,
non-negative deadband
- on-trigger scripts get an Alarm global (Name/Level/Priority/Message)
so notification routing can branch by severity
- per-instance InstanceAlarmOverride entity + EF migration + flattening
step + CLI commands; HiLo overrides merge setpoint-by-setpoint, binary
types whole-replace
- DebugView shows a Level badge + per-band message tooltip
- App.razor auto-reloads on permanent Blazor circuit failure
- docker/regen-proto.sh automates the proto regen workflow (the linux/arm64
protoc segfault means generated files are checked in for now)
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ScadaLink.Commons.Messages.Streaming;
|
||||
@@ -257,4 +258,618 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
// No additional messages (no script execution side effects)
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
// ── RateOfChange ───────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Builds a RateOfChange config JSON with the given threshold (units/sec),
|
||||
/// window (seconds), and direction. Used by the rate-of-change tests.
|
||||
/// </summary>
|
||||
private static string RocConfig(double thresholdPerSecond, double windowSeconds, string direction) =>
|
||||
$"{{\"attributeName\":\"Pressure\",\"thresholdPerSecond\":{thresholdPerSecond.ToString(System.Globalization.CultureInfo.InvariantCulture)},\"windowSeconds\":{windowSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture)},\"direction\":\"{direction}\"}}";
|
||||
|
||||
private IActorRef SpawnRocAlarm(string config, TestProbe instanceProbe)
|
||||
{
|
||||
var alarmConfig = new ResolvedAlarm
|
||||
{
|
||||
CanonicalName = "RocAlarm",
|
||||
TriggerType = "RateOfChange",
|
||||
TriggerConfiguration = config,
|
||||
PriorityLevel = 3
|
||||
};
|
||||
return ActorOf(Props.Create(() => new AlarmActor(
|
||||
"RocAlarm", "Pump1", instanceProbe.Ref, alarmConfig,
|
||||
null, _sharedLibrary, _options,
|
||||
NullLogger<AlarmActor>.Instance)));
|
||||
}
|
||||
|
||||
private static AttributeValueChanged PressureSample(double value, DateTimeOffset ts) =>
|
||||
new("Pump1", "Pressure", "Pressure",
|
||||
value.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
"Good", ts);
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_RateOfChange_Either_ActivatesOnRapidRise()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
// 50 units/sec threshold, 2 sec window
|
||||
var alarm = SpawnRocAlarm(RocConfig(50, 2, "either"), instanceProbe);
|
||||
|
||||
var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
// First sample establishes the window baseline; needs ≥2 samples to compute a rate.
|
||||
alarm.Tell(PressureSample(0, t0));
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
|
||||
// 100 over 1 sec = 100 units/sec > 50 threshold → activate
|
||||
alarm.Tell(PressureSample(100, t0.AddSeconds(1)));
|
||||
|
||||
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmState.Active, msg.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_RateOfChange_Either_ActivatesOnRapidFall()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
var alarm = SpawnRocAlarm(RocConfig(50, 2, "either"), instanceProbe);
|
||||
|
||||
var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
alarm.Tell(PressureSample(100, t0));
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
|
||||
// -100 over 1 sec → |rate| = 100 > 50 → activate (Either covers both signs)
|
||||
alarm.Tell(PressureSample(0, t0.AddSeconds(1)));
|
||||
|
||||
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmState.Active, msg.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_RateOfChange_Either_DoesNotActivateWhenBelowThreshold()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
var alarm = SpawnRocAlarm(RocConfig(50, 2, "either"), instanceProbe);
|
||||
|
||||
var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
alarm.Tell(PressureSample(0, t0));
|
||||
// 10 over 1 sec = 10 units/sec < 50 threshold → no alarm
|
||||
alarm.Tell(PressureSample(10, t0.AddSeconds(1)));
|
||||
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_RateOfChange_Rising_IgnoresFallingSpikes()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
var alarm = SpawnRocAlarm(RocConfig(50, 2, "rising"), instanceProbe);
|
||||
|
||||
var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
alarm.Tell(PressureSample(100, t0));
|
||||
// -100 over 1 sec → would trigger Either, but Rising only fires on positive rate
|
||||
alarm.Tell(PressureSample(0, t0.AddSeconds(1)));
|
||||
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_RateOfChange_Falling_IgnoresRisingSpikes()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
var alarm = SpawnRocAlarm(RocConfig(50, 2, "falling"), instanceProbe);
|
||||
|
||||
var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
alarm.Tell(PressureSample(0, t0));
|
||||
alarm.Tell(PressureSample(100, t0.AddSeconds(1)));
|
||||
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_RateOfChange_Falling_ActivatesOnFallingRate()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
var alarm = SpawnRocAlarm(RocConfig(50, 2, "falling"), instanceProbe);
|
||||
|
||||
var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
alarm.Tell(PressureSample(100, t0));
|
||||
alarm.Tell(PressureSample(0, t0.AddSeconds(1))); // -100/sec, |rate| > threshold, falling
|
||||
|
||||
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmState.Active, msg.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_RateOfChange_SingleSample_DoesNotActivate()
|
||||
{
|
||||
// The evaluator needs at least two samples in the window to compute a rate.
|
||||
var instanceProbe = CreateTestProbe();
|
||||
var alarm = SpawnRocAlarm(RocConfig(50, 2, "either"), instanceProbe);
|
||||
|
||||
var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
alarm.Tell(PressureSample(1000, t0));
|
||||
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_RateOfChange_WindowRollsOff_OldSamplesDiscarded()
|
||||
{
|
||||
// 1-second window. Sample at t=0 with value 0 should fall out before the
|
||||
// sample at t=3, so the in-window history is just the two recent samples
|
||||
// (t=2.5, v=99) and (t=3, v=100) → rate = 1 unit / 0.5s = 2/sec — below
|
||||
// the threshold, so no alarm even though the long-term delta is huge.
|
||||
var instanceProbe = CreateTestProbe();
|
||||
var alarm = SpawnRocAlarm(RocConfig(50, 1, "either"), instanceProbe);
|
||||
|
||||
var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
alarm.Tell(PressureSample(0, t0));
|
||||
alarm.Tell(PressureSample(99, t0.AddSeconds(2.5)));
|
||||
alarm.Tell(PressureSample(100, t0.AddSeconds(3)));
|
||||
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_RateOfChange_ClearsWhenRateDropsBack()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
var alarm = SpawnRocAlarm(RocConfig(50, 1, "either"), instanceProbe);
|
||||
|
||||
var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
// Spike: activate
|
||||
alarm.Tell(PressureSample(0, t0));
|
||||
alarm.Tell(PressureSample(100, t0.AddSeconds(0.5))); // 200/sec > 50
|
||||
var activate = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmState.Active, activate.State);
|
||||
|
||||
// Now sample again well past the 1-second window with only a tiny change
|
||||
// → rate falls below threshold → clears.
|
||||
alarm.Tell(PressureSample(101, t0.AddSeconds(3)));
|
||||
|
||||
var clear = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmState.Normal, clear.State);
|
||||
}
|
||||
|
||||
// ── Legacy JSON aliases & not-equals prefix ────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_ValueMatch_LegacyAttributeAndValueKeys_StillFire()
|
||||
{
|
||||
// Old configs used "attribute" / "value" before the canonical names landed.
|
||||
var alarmConfig = new ResolvedAlarm
|
||||
{
|
||||
CanonicalName = "Legacy",
|
||||
TriggerType = "ValueMatch",
|
||||
TriggerConfiguration = "{\"attribute\":\"Status\",\"value\":\"Critical\"}",
|
||||
PriorityLevel = 1
|
||||
};
|
||||
|
||||
var instanceProbe = CreateTestProbe();
|
||||
var alarm = ActorOf(Props.Create(() => new AlarmActor(
|
||||
"Legacy", "Pump1", instanceProbe.Ref, alarmConfig,
|
||||
null, _sharedLibrary, _options,
|
||||
NullLogger<AlarmActor>.Instance)));
|
||||
|
||||
alarm.Tell(new AttributeValueChanged(
|
||||
"Pump1", "Status", "Status", "Critical", "Good", DateTimeOffset.UtcNow));
|
||||
|
||||
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmState.Active, msg.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_ValueMatch_NotEqualsPrefix_FiresWhenValueDiffers()
|
||||
{
|
||||
// matchValue "!=Good" means: alarm when Status is anything other than Good.
|
||||
var alarmConfig = new ResolvedAlarm
|
||||
{
|
||||
CanonicalName = "Inverted",
|
||||
TriggerType = "ValueMatch",
|
||||
TriggerConfiguration = "{\"attributeName\":\"Status\",\"matchValue\":\"!=Good\"}",
|
||||
PriorityLevel = 1
|
||||
};
|
||||
|
||||
var instanceProbe = CreateTestProbe();
|
||||
var alarm = ActorOf(Props.Create(() => new AlarmActor(
|
||||
"Inverted", "Pump1", instanceProbe.Ref, alarmConfig,
|
||||
null, _sharedLibrary, _options,
|
||||
NullLogger<AlarmActor>.Instance)));
|
||||
|
||||
// Status=Critical (not "Good") → alarm activates
|
||||
alarm.Tell(new AttributeValueChanged(
|
||||
"Pump1", "Status", "Status", "Critical", "Good", DateTimeOffset.UtcNow));
|
||||
var activate = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmState.Active, activate.State);
|
||||
|
||||
// Status=Good → alarm clears
|
||||
alarm.Tell(new AttributeValueChanged(
|
||||
"Pump1", "Status", "Status", "Good", "Critical", DateTimeOffset.UtcNow));
|
||||
var clear = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmState.Normal, clear.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_RangeViolation_LegacyLowHighKeys_StillFire()
|
||||
{
|
||||
// Older configs used "low" / "high" instead of the current "min" / "max".
|
||||
var alarmConfig = new ResolvedAlarm
|
||||
{
|
||||
CanonicalName = "Legacy",
|
||||
TriggerType = "RangeViolation",
|
||||
TriggerConfiguration = "{\"attributeName\":\"Temperature\",\"low\":0,\"high\":100}",
|
||||
PriorityLevel = 1
|
||||
};
|
||||
|
||||
var instanceProbe = CreateTestProbe();
|
||||
var alarm = ActorOf(Props.Create(() => new AlarmActor(
|
||||
"Legacy", "Pump1", instanceProbe.Ref, alarmConfig,
|
||||
null, _sharedLibrary, _options,
|
||||
NullLogger<AlarmActor>.Instance)));
|
||||
|
||||
// Within range → no alarm
|
||||
alarm.Tell(new AttributeValueChanged(
|
||||
"Pump1", "Temperature", "Temperature", "50", "Good", DateTimeOffset.UtcNow));
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
|
||||
// Outside range → activate
|
||||
alarm.Tell(new AttributeValueChanged(
|
||||
"Pump1", "Temperature", "Temperature", "150", "Good", DateTimeOffset.UtcNow));
|
||||
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmState.Active, msg.State);
|
||||
}
|
||||
|
||||
// ── HiLo ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Spawns a HiLo alarm with the given JSON config and alarm-level priority fallback.</summary>
|
||||
private IActorRef SpawnHiLoAlarm(string config, TestProbe instanceProbe, int priority = 500)
|
||||
{
|
||||
var alarmConfig = new ResolvedAlarm
|
||||
{
|
||||
CanonicalName = "TempAlarm",
|
||||
TriggerType = "HiLo",
|
||||
TriggerConfiguration = config,
|
||||
PriorityLevel = priority
|
||||
};
|
||||
return ActorOf(Props.Create(() => new AlarmActor(
|
||||
"TempAlarm", "Pump1", instanceProbe.Ref, alarmConfig,
|
||||
null, _sharedLibrary, _options,
|
||||
NullLogger<AlarmActor>.Instance)));
|
||||
}
|
||||
|
||||
private static AttributeValueChanged TempSample(double value) =>
|
||||
new("Pump1", "Temperature", "Temperature",
|
||||
value.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
"Good", DateTimeOffset.UtcNow);
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_EntersHigh_WhenValueCrossesHi()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(50)); // normal band — no emit
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
|
||||
alarm.Tell(TempSample(85)); // crosses Hi but not HiHi
|
||||
|
||||
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmState.Active, msg.State);
|
||||
Assert.Equal(AlarmLevel.High, msg.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_EscalatesToHighHigh_WhenValueClimbsPastHiHi()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(85));
|
||||
var first = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmLevel.High, first.Level);
|
||||
|
||||
alarm.Tell(TempSample(120)); // crosses HiHi
|
||||
|
||||
var second = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmState.Active, second.State);
|
||||
Assert.Equal(AlarmLevel.HighHigh, second.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_DescalatesFromHighHighToHigh_WhenValueDrops()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(120));
|
||||
instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
|
||||
alarm.Tell(TempSample(85)); // back into the Hi band but still alarmed
|
||||
|
||||
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmState.Active, msg.State);
|
||||
Assert.Equal(AlarmLevel.High, msg.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_ClearsToNormal_WhenValueReturnsToNormalBand()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(85));
|
||||
instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
|
||||
alarm.Tell(TempSample(50)); // back to normal
|
||||
|
||||
var clear = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmState.Normal, clear.State);
|
||||
Assert.Equal(AlarmLevel.None, clear.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_EntersLow_WhenValueCrossesLo()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""loLo"":0,""lo"":10}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(8));
|
||||
|
||||
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmLevel.Low, msg.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_EntersLowLow_WhenValueCrossesLoLo()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""loLo"":0,""lo"":10}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(-5));
|
||||
|
||||
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmLevel.LowLow, msg.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_PerSetpointPriority_OverridesAlarmLevelPriority()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
// Alarm-level priority is 500; HiHi explicitly bumps to 900.
|
||||
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100,""hiPriority"":600,""hiHiPriority"":900}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe, priority: 500);
|
||||
|
||||
alarm.Tell(TempSample(85));
|
||||
var hi = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmLevel.High, hi.Level);
|
||||
Assert.Equal(600, hi.Priority);
|
||||
|
||||
alarm.Tell(TempSample(120));
|
||||
var hiHi = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmLevel.HighHigh, hiHi.Level);
|
||||
Assert.Equal(900, hiHi.Priority);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_MissingPerSetpointPriority_FallsBackToAlarmLevel()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""hi"":80}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe, priority: 432);
|
||||
|
||||
alarm.Tell(TempSample(85));
|
||||
|
||||
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(432, msg.Priority);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_PartialConfig_OnlyHiHiSet_NoEffectInLowRange()
|
||||
{
|
||||
// Only HiHi is configured — values that would have hit a Lo or Hi band
|
||||
// (in a fully-configured alarm) are inside the implicit normal band here.
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""hiHi"":100}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(-1000));
|
||||
alarm.Tell(TempSample(95));
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
|
||||
alarm.Tell(TempSample(110));
|
||||
|
||||
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmLevel.HighHigh, msg.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_BoundaryValue_AtHiHi_ResolvesToHighHigh()
|
||||
{
|
||||
// When the value exactly equals the boundary, the most-severe matching
|
||||
// band wins. value == HiHi → HighHigh (not High).
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(100));
|
||||
|
||||
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmLevel.HighHigh, msg.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_StaysAtSameLevel_NoRedundantEmission()
|
||||
{
|
||||
// Two updates that resolve to the same level should produce exactly one
|
||||
// AlarmStateChanged — the second is a no-op.
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(85));
|
||||
instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
|
||||
alarm.Tell(TempSample(90)); // still in the Hi band
|
||||
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(400));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_NoSetpointsConfigured_NeverFires()
|
||||
{
|
||||
// Validation flags this as a warning at design time; runtime behavior
|
||||
// is "evaluates to None forever".
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature""}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(99999));
|
||||
alarm.Tell(TempSample(-99999));
|
||||
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(400));
|
||||
}
|
||||
|
||||
// ── HiLo hysteresis ────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_Hysteresis_StaysAtHighHigh_UntilDropsBelowDeadband()
|
||||
{
|
||||
// HiHi=100 with 5-unit deadband. Once at HighHigh, the alarm stays there
|
||||
// until the value drops below 95 — at 96 it should still be HighHigh.
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100,""hiHiDeadband"":5}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(120));
|
||||
var entered = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmLevel.HighHigh, entered.Level);
|
||||
|
||||
// 96 > 95 (HiHi - deadband) → still HighHigh, no state change emitted
|
||||
alarm.Tell(TempSample(96));
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_Hysteresis_DropsToHigh_OnlyAfterDeadbandCleared()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100,""hiHiDeadband"":5}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(120));
|
||||
instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
|
||||
// 94 < 95 (HiHi - deadband) → drops to High (still above Hi=80)
|
||||
alarm.Tell(TempSample(94));
|
||||
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmLevel.High, msg.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_Hysteresis_HiDeadband_PreventsFlapping()
|
||||
{
|
||||
// Hi=80 with 5-unit deadband. After entering Hi, stays Hi until value drops below 75.
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100,""hiDeadband"":5}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(85));
|
||||
instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
|
||||
alarm.Tell(TempSample(78)); // 78 > 75 → still High
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
|
||||
alarm.Tell(TempSample(74)); // 74 < 75 → clears to Normal
|
||||
var clear = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmLevel.None, clear.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_Hysteresis_LowSide_Symmetric()
|
||||
{
|
||||
// Lo=10 with 3-unit deadband. After entering Lo, stays Lo until value rises above 13.
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""loLo"":0,""lo"":10,""loDeadband"":3}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(8));
|
||||
var entered = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmLevel.Low, entered.Level);
|
||||
|
||||
alarm.Tell(TempSample(12)); // 12 <= 13 → still Low
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
|
||||
alarm.Tell(TempSample(14)); // 14 > 13 → clears
|
||||
var clear = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmLevel.None, clear.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_PerBandMessage_FlowsToAlarmStateChanged()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100,""hiMessage"":""Coolant warm — check tank"",""hiHiMessage"":""Coolant critical — shut down""}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(85));
|
||||
var hi = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("Coolant warm — check tank", hi.Message);
|
||||
|
||||
alarm.Tell(TempSample(120));
|
||||
var hiHi = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("Coolant critical — shut down", hiHi.Message);
|
||||
|
||||
// Clearing back to normal carries an empty message.
|
||||
alarm.Tell(TempSample(50));
|
||||
var clear = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(string.Empty, clear.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_Hysteresis_DoesNotDelayEscalation()
|
||||
{
|
||||
// Deadband is only on de-escalation. Escalating up to HighHigh should not be delayed.
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100,""hiHiDeadband"":50}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(85));
|
||||
instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
|
||||
// Despite the large deadband, escalation uses the activation threshold (100).
|
||||
alarm.Tell(TempSample(101));
|
||||
var escalated = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmLevel.HighHigh, escalated.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_MalformedTriggerConfig_DoesNotCrash()
|
||||
{
|
||||
// ParseEvalConfig falls back to a safe default on JSON failure; the actor
|
||||
// should accept messages without throwing and just never trigger.
|
||||
var alarmConfig = new ResolvedAlarm
|
||||
{
|
||||
CanonicalName = "Bad",
|
||||
TriggerType = "ValueMatch",
|
||||
TriggerConfiguration = "{not valid json",
|
||||
PriorityLevel = 1
|
||||
};
|
||||
|
||||
var instanceProbe = CreateTestProbe();
|
||||
var alarm = ActorOf(Props.Create(() => new AlarmActor(
|
||||
"Bad", "Pump1", instanceProbe.Ref, alarmConfig,
|
||||
null, _sharedLibrary, _options,
|
||||
NullLogger<AlarmActor>.Instance)));
|
||||
|
||||
alarm.Tell(new AttributeValueChanged(
|
||||
"Pump1", "Anything", "Anything", "anything", "Good", DateTimeOffset.UtcNow));
|
||||
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user