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:
Joseph Doherty
2026-05-13 03:23:32 -04:00
parent 783da8e21a
commit 751248feb6
46 changed files with 4693 additions and 204 deletions

View File

@@ -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);

View File

@@ -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);

View File

@@ -181,6 +181,14 @@ public class ScriptGlobals
public ScriptParameters Parameters { get; set; } = new ScriptParameters();
public CancellationToken CancellationToken { get; set; }
/// <summary>
/// Alarm context when this script is invoked as an on-trigger handler.
/// Null for instance scripts, shared scripts, and inbound-API-routed
/// scripts. Lets on-trigger scripts read the firing alarm's Name, Level
/// (HiLo only), Priority, and per-band Message to branch routing logic.
/// </summary>
public Commons.Types.Scripts.AlarmContext? Alarm { get; set; }
/// <summary>
/// Where this script sits in the composition tree. Defaults to root for
/// scripts on top-level templates; a flattened composed script gets