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:
@@ -38,6 +38,12 @@ public class AlarmActor : ReceiveActor
|
||||
private readonly ISiteHealthCollector? _healthCollector;
|
||||
|
||||
private AlarmState _currentState = AlarmState.Normal;
|
||||
/// <summary>
|
||||
/// Always <see cref="AlarmLevel.None"/> for binary trigger types. For
|
||||
/// <see cref="AlarmTriggerType.HiLo"/> this is the source of truth — the
|
||||
/// state machine transitions when the computed level changes.
|
||||
/// </summary>
|
||||
private AlarmLevel _currentLevel = AlarmLevel.None;
|
||||
private readonly AlarmTriggerType _triggerType;
|
||||
private readonly AlarmEvalConfig _evalConfig;
|
||||
private readonly int _priority;
|
||||
@@ -126,6 +132,12 @@ public class AlarmActor : ReceiveActor
|
||||
|
||||
try
|
||||
{
|
||||
if (_triggerType == AlarmTriggerType.HiLo)
|
||||
{
|
||||
HandleHiLoTransition(EvaluateHiLo(changed.Value));
|
||||
return;
|
||||
}
|
||||
|
||||
var isTriggered = _triggerType switch
|
||||
{
|
||||
AlarmTriggerType.ValueMatch => EvaluateValueMatch(changed.Value),
|
||||
@@ -150,7 +162,7 @@ public class AlarmActor : ReceiveActor
|
||||
// Spawn AlarmExecutionActor if on-trigger script defined
|
||||
if (_onTriggerCompiledScript != null)
|
||||
{
|
||||
SpawnAlarmExecution();
|
||||
SpawnAlarmExecution(AlarmLevel.None, _priority, string.Empty);
|
||||
}
|
||||
}
|
||||
else if (!isTriggered && _currentState == AlarmState.Active)
|
||||
@@ -176,6 +188,78 @@ public class AlarmActor : ReceiveActor
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HiLo state machine: emit an AlarmStateChanged whenever the evaluated
|
||||
/// level changes. Spawns the on-trigger script only on the Normal→Active
|
||||
/// edge (i.e., when entering an alarm band from the normal band) — not on
|
||||
/// level escalations like Hi→HiHi or Low→LowLow.
|
||||
/// </summary>
|
||||
private void HandleHiLoTransition(AlarmLevel newLevel)
|
||||
{
|
||||
if (newLevel == _currentLevel) return;
|
||||
|
||||
var previousLevel = _currentLevel;
|
||||
_currentLevel = newLevel;
|
||||
_currentState = newLevel == AlarmLevel.None ? AlarmState.Normal : AlarmState.Active;
|
||||
var priority = LevelPriority(newLevel);
|
||||
var message = LevelMessage(newLevel);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Alarm {Alarm} on {Instance} transitioned {Prev} → {New} (priority={Priority})",
|
||||
_alarmName, _instanceName, previousLevel, newLevel, priority);
|
||||
|
||||
var alarmChanged = new AlarmStateChanged(
|
||||
_instanceName, _alarmName, _currentState, priority, DateTimeOffset.UtcNow)
|
||||
{
|
||||
Level = newLevel,
|
||||
Message = message
|
||||
};
|
||||
_instanceActor.Tell(alarmChanged);
|
||||
|
||||
if (previousLevel == AlarmLevel.None
|
||||
&& newLevel != AlarmLevel.None
|
||||
&& _onTriggerCompiledScript != null)
|
||||
{
|
||||
SpawnAlarmExecution(newLevel, priority, message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the per-setpoint priority for the given level. Falls back to
|
||||
/// the alarm-level <see cref="_priority"/> when the HiLo config did not
|
||||
/// override the priority for that band, or for <see cref="AlarmLevel.None"/>.
|
||||
/// </summary>
|
||||
private int LevelPriority(AlarmLevel level)
|
||||
{
|
||||
if (_evalConfig is not HiLoEvalConfig hiLo) return _priority;
|
||||
return level switch
|
||||
{
|
||||
AlarmLevel.LowLow => hiLo.LoLoPriority ?? _priority,
|
||||
AlarmLevel.Low => hiLo.LoPriority ?? _priority,
|
||||
AlarmLevel.High => hiLo.HiPriority ?? _priority,
|
||||
AlarmLevel.HighHigh => hiLo.HiHiPriority ?? _priority,
|
||||
_ => _priority
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-band operator message. Empty string when no message is configured
|
||||
/// for the band, or for non-HiLo trigger types, or for the None level
|
||||
/// (alarm clear).
|
||||
/// </summary>
|
||||
private string LevelMessage(AlarmLevel level)
|
||||
{
|
||||
if (_evalConfig is not HiLoEvalConfig hiLo) return string.Empty;
|
||||
return level switch
|
||||
{
|
||||
AlarmLevel.LowLow => hiLo.LoLoMessage ?? string.Empty,
|
||||
AlarmLevel.Low => hiLo.LoMessage ?? string.Empty,
|
||||
AlarmLevel.High => hiLo.HiMessage ?? string.Empty,
|
||||
AlarmLevel.HighHigh => hiLo.HiHiMessage ?? string.Empty,
|
||||
_ => string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private bool IsMonitoredAttribute(string attributeName)
|
||||
{
|
||||
return _evalConfig.MonitoredAttributeName == attributeName;
|
||||
@@ -254,9 +338,57 @@ public class AlarmActor : ReceiveActor
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spawns an AlarmExecutionActor to run the on-trigger script.
|
||||
/// HiLo level evaluator: returns the most-severe matching band for the
|
||||
/// given value. Severity order checked from highest to lowest so that a
|
||||
/// value at exactly Hi==HiHi resolves to HighHigh. Unset setpoints (null)
|
||||
/// are skipped, allowing partial configs (e.g., HighHigh only).
|
||||
///
|
||||
/// Hysteresis: when the alarm is already in a level whose threshold the
|
||||
/// value would re-cross from inside, the threshold is relaxed by the
|
||||
/// configured deadband. This prevents flapping at the boundary — once at
|
||||
/// HighHigh with HiHi=100 and hiHiDeadband=5, the alarm stays HighHigh
|
||||
/// until the value drops below 95.
|
||||
/// </summary>
|
||||
private void SpawnAlarmExecution()
|
||||
private AlarmLevel EvaluateHiLo(object? value)
|
||||
{
|
||||
if (_evalConfig is not HiLoEvalConfig config) return AlarmLevel.None;
|
||||
if (value == null) return _currentLevel;
|
||||
|
||||
double numericValue;
|
||||
try { numericValue = Convert.ToDouble(value); }
|
||||
catch { return _currentLevel; }
|
||||
|
||||
// When the current level is at-or-above HighHigh, relax the HiHi exit.
|
||||
// Same for the other directions.
|
||||
var hiHiThreshold = config.HiHi;
|
||||
if (hiHiThreshold is { } hh && _currentLevel == AlarmLevel.HighHigh)
|
||||
hiHiThreshold = hh - Math.Max(0, config.HiHiDeadband ?? 0);
|
||||
|
||||
var hiThreshold = config.Hi;
|
||||
if (hiThreshold is { } h && (_currentLevel == AlarmLevel.High || _currentLevel == AlarmLevel.HighHigh))
|
||||
hiThreshold = h - Math.Max(0, config.HiDeadband ?? 0);
|
||||
|
||||
var loLoThreshold = config.LoLo;
|
||||
if (loLoThreshold is { } ll && _currentLevel == AlarmLevel.LowLow)
|
||||
loLoThreshold = ll + Math.Max(0, config.LoLoDeadband ?? 0);
|
||||
|
||||
var loThreshold = config.Lo;
|
||||
if (loThreshold is { } l && (_currentLevel == AlarmLevel.Low || _currentLevel == AlarmLevel.LowLow))
|
||||
loThreshold = l + Math.Max(0, config.LoDeadband ?? 0);
|
||||
|
||||
if (hiHiThreshold is { } effHiHi && numericValue >= effHiHi) return AlarmLevel.HighHigh;
|
||||
if (hiThreshold is { } effHi && numericValue >= effHi) return AlarmLevel.High;
|
||||
if (loLoThreshold is { } effLoLo && numericValue <= effLoLo) return AlarmLevel.LowLow;
|
||||
if (loThreshold is { } effLo && numericValue <= effLo) return AlarmLevel.Low;
|
||||
return AlarmLevel.None;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spawns an AlarmExecutionActor to run the on-trigger script.
|
||||
/// Passes the firing alarm's level/priority/message so the script can
|
||||
/// branch on severity via the <c>Alarm</c> global.
|
||||
/// </summary>
|
||||
private void SpawnAlarmExecution(AlarmLevel level, int priority, string message)
|
||||
{
|
||||
if (_onTriggerCompiledScript == null) return;
|
||||
|
||||
@@ -266,6 +398,9 @@ public class AlarmActor : ReceiveActor
|
||||
var props = Props.Create(() => new AlarmExecutionActor(
|
||||
_alarmName,
|
||||
_instanceName,
|
||||
level,
|
||||
priority,
|
||||
message,
|
||||
_onTriggerCompiledScript,
|
||||
_instanceActor,
|
||||
_sharedScriptLibrary,
|
||||
@@ -319,6 +454,25 @@ public class AlarmActor : ReceiveActor
|
||||
? ParseDirection(dirEl.GetString())
|
||||
: RateOfChangeDirection.Either),
|
||||
|
||||
AlarmTriggerType.HiLo => new HiLoEvalConfig(
|
||||
attr,
|
||||
LoLo: TryReadDouble(root, "loLo"),
|
||||
Lo: TryReadDouble(root, "lo"),
|
||||
Hi: TryReadDouble(root, "hi"),
|
||||
HiHi: TryReadDouble(root, "hiHi"),
|
||||
LoLoPriority: TryReadInt(root, "loLoPriority"),
|
||||
LoPriority: TryReadInt(root, "loPriority"),
|
||||
HiPriority: TryReadInt(root, "hiPriority"),
|
||||
HiHiPriority: TryReadInt(root, "hiHiPriority"),
|
||||
LoLoDeadband: TryReadDouble(root, "loLoDeadband"),
|
||||
LoDeadband: TryReadDouble(root, "loDeadband"),
|
||||
HiDeadband: TryReadDouble(root, "hiDeadband"),
|
||||
HiHiDeadband: TryReadDouble(root, "hiHiDeadband"),
|
||||
LoLoMessage: TryReadString(root, "loLoMessage"),
|
||||
LoMessage: TryReadString(root, "loMessage"),
|
||||
HiMessage: TryReadString(root, "hiMessage"),
|
||||
HiHiMessage: TryReadString(root, "hiHiMessage")),
|
||||
|
||||
_ => new ValueMatchEvalConfig(attr, null)
|
||||
};
|
||||
}
|
||||
@@ -336,6 +490,35 @@ public class AlarmActor : ReceiveActor
|
||||
_ => RateOfChangeDirection.Either
|
||||
};
|
||||
|
||||
private static double? TryReadDouble(JsonElement el, string name)
|
||||
{
|
||||
if (!el.TryGetProperty(name, out var p)) return null;
|
||||
return p.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Number => p.GetDouble(),
|
||||
JsonValueKind.String when double.TryParse(p.GetString(), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var v) => v,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static int? TryReadInt(JsonElement el, string name)
|
||||
{
|
||||
if (!el.TryGetProperty(name, out var p)) return null;
|
||||
return p.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Number when p.TryGetInt32(out var i) => i,
|
||||
JsonValueKind.Number => (int)p.GetDouble(),
|
||||
JsonValueKind.String when int.TryParse(p.GetString(), System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var v) => v,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static string? TryReadString(JsonElement el, string name)
|
||||
{
|
||||
if (!el.TryGetProperty(name, out var p)) return null;
|
||||
return p.ValueKind == JsonValueKind.String ? p.GetString() : null;
|
||||
}
|
||||
|
||||
// ── Internal messages ──
|
||||
internal record AlarmExecutionCompleted(string AlarmName, bool Success);
|
||||
}
|
||||
@@ -351,3 +534,27 @@ internal record RateOfChangeEvalConfig(
|
||||
double ThresholdPerSecond,
|
||||
TimeSpan WindowDuration,
|
||||
RateOfChangeDirection Direction) : AlarmEvalConfig(MonitoredAttributeName);
|
||||
|
||||
/// <summary>
|
||||
/// HiLo evaluation config: any subset of the four setpoints may be set; null
|
||||
/// means "don't evaluate that band". Per-setpoint priorities override the
|
||||
/// alarm-level priority for AlarmStateChanged messages emitted for that band.
|
||||
/// </summary>
|
||||
internal record HiLoEvalConfig(
|
||||
string MonitoredAttributeName,
|
||||
double? LoLo,
|
||||
double? Lo,
|
||||
double? Hi,
|
||||
double? HiHi,
|
||||
int? LoLoPriority,
|
||||
int? LoPriority,
|
||||
int? HiPriority,
|
||||
int? HiHiPriority,
|
||||
double? LoLoDeadband = null,
|
||||
double? LoDeadband = null,
|
||||
double? HiDeadband = null,
|
||||
double? HiHiDeadband = null,
|
||||
string? LoLoMessage = null,
|
||||
string? LoMessage = null,
|
||||
string? HiMessage = null,
|
||||
string? HiHiMessage = null) : AlarmEvalConfig(MonitoredAttributeName);
|
||||
|
||||
@@ -2,6 +2,8 @@ using Akka.Actor;
|
||||
using Microsoft.CodeAnalysis.Scripting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ScadaLink.Commons.Types;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
using ScadaLink.Commons.Types.Scripts;
|
||||
using ScadaLink.SiteRuntime.Scripts;
|
||||
|
||||
namespace ScadaLink.SiteRuntime.Actors;
|
||||
@@ -18,6 +20,9 @@ public class AlarmExecutionActor : ReceiveActor
|
||||
public AlarmExecutionActor(
|
||||
string alarmName,
|
||||
string instanceName,
|
||||
AlarmLevel level,
|
||||
int priority,
|
||||
string message,
|
||||
Script<object?> compiledScript,
|
||||
IActorRef instanceActor,
|
||||
SharedScriptLibrary sharedScriptLibrary,
|
||||
@@ -28,13 +33,17 @@ public class AlarmExecutionActor : ReceiveActor
|
||||
var parent = Context.Parent;
|
||||
|
||||
ExecuteAlarmScript(
|
||||
alarmName, instanceName, compiledScript, instanceActor,
|
||||
alarmName, instanceName, level, priority, message,
|
||||
compiledScript, instanceActor,
|
||||
sharedScriptLibrary, options, self, parent, logger);
|
||||
}
|
||||
|
||||
private static void ExecuteAlarmScript(
|
||||
string alarmName,
|
||||
string instanceName,
|
||||
AlarmLevel level,
|
||||
int priority,
|
||||
string message,
|
||||
Script<object?> compiledScript,
|
||||
IActorRef instanceActor,
|
||||
SharedScriptLibrary sharedScriptLibrary,
|
||||
@@ -66,7 +75,14 @@ public class AlarmExecutionActor : ReceiveActor
|
||||
{
|
||||
Instance = context,
|
||||
Parameters = new ScriptParameters(),
|
||||
CancellationToken = cts.Token
|
||||
CancellationToken = cts.Token,
|
||||
Alarm = new AlarmContext
|
||||
{
|
||||
Name = alarmName,
|
||||
Level = level,
|
||||
Priority = priority,
|
||||
Message = message
|
||||
}
|
||||
};
|
||||
|
||||
await compiledScript.RunAsync(globals, cts.Token);
|
||||
|
||||
Reference in New Issue
Block a user