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

@@ -251,4 +251,158 @@ public class SemanticValidatorTests
Assert.Empty(SemanticValidator.ParseParameterDefinitions(null));
Assert.Empty(SemanticValidator.ParseParameterDefinitions(""));
}
// ── HiLo validation ─────────────────────────────────────────────────────
private static FlattenedConfiguration HiLoConfig(string attrName, string dataType, string triggerJson) =>
new()
{
InstanceUniqueName = "Instance1",
Attributes = [new ResolvedAttribute { CanonicalName = attrName, DataType = dataType }],
Alarms =
[
new ResolvedAlarm
{
CanonicalName = "Hi/Lo Alarm",
TriggerType = "HiLo",
TriggerConfiguration = triggerJson
}
]
};
[Fact]
public void Validate_HiLoOnNonNumericAttribute_ReturnsError()
{
var config = HiLoConfig("Status", "String",
"{\"attributeName\":\"Status\",\"hi\":80}");
var result = _sut.Validate(config);
Assert.Contains(result.Errors,
e => e.Category == ValidationCategory.TriggerOperandType
&& e.Message.Contains("HiLo")
&& e.Message.Contains("non-numeric"));
}
[Fact]
public void Validate_HiLoOnNumericAttribute_NoOperandTypeError()
{
var config = HiLoConfig("Temp", "Double",
"{\"attributeName\":\"Temp\",\"hi\":80,\"hiHi\":100}");
var result = _sut.Validate(config);
Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.TriggerOperandType);
}
[Fact]
public void Validate_HiLoNoSetpoints_ReturnsWarning()
{
// No setpoints means the alarm can never fire — design-time warning.
var config = HiLoConfig("Temp", "Double",
"{\"attributeName\":\"Temp\"}");
var result = _sut.Validate(config);
Assert.Contains(result.Warnings,
w => w.Category == ValidationCategory.TriggerOperandType
&& w.Message.Contains("no setpoints"));
}
[Fact]
public void Validate_HiLoLoLoGreaterThanLo_ReturnsError()
{
var config = HiLoConfig("Temp", "Double",
"{\"attributeName\":\"Temp\",\"loLo\":20,\"lo\":10}");
var result = _sut.Validate(config);
Assert.Contains(result.Errors,
e => e.Category == ValidationCategory.TriggerOperandType
&& e.Message.Contains("LoLo")
&& e.Message.Contains("Lo"));
}
[Fact]
public void Validate_HiLoHiGreaterThanHiHi_ReturnsError()
{
var config = HiLoConfig("Temp", "Double",
"{\"attributeName\":\"Temp\",\"hi\":120,\"hiHi\":100}");
var result = _sut.Validate(config);
Assert.Contains(result.Errors,
e => e.Category == ValidationCategory.TriggerOperandType
&& e.Message.Contains("Hi")
&& e.Message.Contains("HiHi"));
}
[Fact]
public void Validate_HiLoLowSideOverlapsHighSide_ReturnsError()
{
// Lo (50) >= Hi (40) — bands overlap.
var config = HiLoConfig("Temp", "Double",
"{\"attributeName\":\"Temp\",\"lo\":50,\"hi\":40}");
var result = _sut.Validate(config);
Assert.Contains(result.Errors,
e => e.Category == ValidationCategory.TriggerOperandType
&& e.Message.Contains("overlap"));
}
[Fact]
public void Validate_HiLoOnlyHighSideConfigured_NoOrderingError()
{
// Only Hi/HiHi configured — no low-side comparison needed.
var config = HiLoConfig("Temp", "Double",
"{\"attributeName\":\"Temp\",\"hi\":80,\"hiHi\":100}");
var result = _sut.Validate(config);
Assert.DoesNotContain(result.Errors,
e => e.Category == ValidationCategory.TriggerOperandType);
}
[Fact]
public void Validate_HiLoNegativeDeadband_ReturnsError()
{
var config = HiLoConfig("Temp", "Double",
"{\"attributeName\":\"Temp\",\"hi\":80,\"hiHi\":100,\"hiDeadband\":-1}");
var result = _sut.Validate(config);
Assert.Contains(result.Errors,
e => e.Category == ValidationCategory.TriggerOperandType
&& e.Message.Contains("Hi deadband")
&& e.Message.Contains("non-negative"));
}
[Fact]
public void Validate_HiLoZeroDeadband_NoError()
{
// Zero deadband is the default (no hysteresis) and must be accepted.
var config = HiLoConfig("Temp", "Double",
"{\"attributeName\":\"Temp\",\"hi\":80,\"hiHi\":100,\"hiDeadband\":0,\"hiHiDeadband\":0}");
var result = _sut.Validate(config);
Assert.DoesNotContain(result.Errors,
e => e.Category == ValidationCategory.TriggerOperandType);
}
[Fact]
public void Validate_HiLoValidOrdering_NoErrors()
{
// LoLo (-10) < Lo (0) < Hi (90) < HiHi (100) — fully valid.
var config = HiLoConfig("Temp", "Double",
"{\"attributeName\":\"Temp\",\"loLo\":-10,\"lo\":0,\"hi\":90,\"hiHi\":100}");
var result = _sut.Validate(config);
Assert.DoesNotContain(result.Errors,
e => e.Category == ValidationCategory.TriggerOperandType);
Assert.DoesNotContain(result.Warnings,
w => w.Category == ValidationCategory.TriggerOperandType);
}
}