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:
@@ -0,0 +1,255 @@
|
||||
using System.Text.Json;
|
||||
using ScadaLink.Commons.Entities.Instances;
|
||||
using ScadaLink.Commons.Entities.Sites;
|
||||
using ScadaLink.Commons.Entities.Templates;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
using ScadaLink.Commons.Types.Flattening;
|
||||
using ScadaLink.TemplateEngine.Flattening;
|
||||
|
||||
namespace ScadaLink.TemplateEngine.Tests.Flattening;
|
||||
|
||||
public class FlatteningServiceMergeTests
|
||||
{
|
||||
// ── MergeHiLoConfig ────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void MergeHiLoConfig_DerivedKeysWin_InheritedKeysSurvive()
|
||||
{
|
||||
const string inherited = @"{""attributeName"":""Temp"",""loLo"":0,""lo"":10,""hi"":80,""hiHi"":100}";
|
||||
const string derived = @"{""hi"":90}"; // derived only overrides Hi
|
||||
|
||||
var result = FlatteningService.MergeHiLoConfig(inherited, derived);
|
||||
|
||||
Assert.NotNull(result);
|
||||
using var doc = JsonDocument.Parse(result!);
|
||||
Assert.Equal("Temp", doc.RootElement.GetProperty("attributeName").GetString());
|
||||
Assert.Equal(0, doc.RootElement.GetProperty("loLo").GetDouble());
|
||||
Assert.Equal(10, doc.RootElement.GetProperty("lo").GetDouble());
|
||||
Assert.Equal(90, doc.RootElement.GetProperty("hi").GetDouble()); // overridden
|
||||
Assert.Equal(100, doc.RootElement.GetProperty("hiHi").GetDouble()); // inherited
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MergeHiLoConfig_DerivedCanOverrideAttribute()
|
||||
{
|
||||
const string inherited = @"{""attributeName"":""Temp"",""hi"":80}";
|
||||
const string derived = @"{""attributeName"":""Pressure"",""hi"":50}";
|
||||
|
||||
var result = FlatteningService.MergeHiLoConfig(inherited, derived);
|
||||
|
||||
using var doc = JsonDocument.Parse(result!);
|
||||
Assert.Equal("Pressure", doc.RootElement.GetProperty("attributeName").GetString());
|
||||
Assert.Equal(50, doc.RootElement.GetProperty("hi").GetDouble());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MergeHiLoConfig_DerivedNull_ReturnsInherited()
|
||||
{
|
||||
const string inherited = @"{""hi"":80}";
|
||||
|
||||
var result = FlatteningService.MergeHiLoConfig(inherited, null);
|
||||
|
||||
Assert.Equal(inherited, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MergeHiLoConfig_InheritedNull_ReturnsDerived()
|
||||
{
|
||||
const string derived = @"{""hi"":80}";
|
||||
|
||||
var result = FlatteningService.MergeHiLoConfig(null, derived);
|
||||
|
||||
Assert.Equal(derived, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MergeHiLoConfig_BothNull_ReturnsNull()
|
||||
{
|
||||
Assert.Null(FlatteningService.MergeHiLoConfig(null, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MergeHiLoConfig_MalformedInherited_FallsBackToDerived()
|
||||
{
|
||||
// Safe fallback — never throw on malformed input.
|
||||
const string derived = @"{""hi"":80}";
|
||||
|
||||
var result = FlatteningService.MergeHiLoConfig("{not valid", derived);
|
||||
|
||||
Assert.Equal(derived, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MergeHiLoConfig_DerivedAddsNewKey_PreservesInheritedRest()
|
||||
{
|
||||
// Derived adds a deadband that the base didn't have.
|
||||
const string inherited = @"{""hi"":80,""hiHi"":100}";
|
||||
const string derived = @"{""hiDeadband"":3}";
|
||||
|
||||
var result = FlatteningService.MergeHiLoConfig(inherited, derived);
|
||||
|
||||
using var doc = JsonDocument.Parse(result!);
|
||||
Assert.Equal(80, doc.RootElement.GetProperty("hi").GetDouble());
|
||||
Assert.Equal(100, doc.RootElement.GetProperty("hiHi").GetDouble());
|
||||
Assert.Equal(3, doc.RootElement.GetProperty("hiDeadband").GetDouble());
|
||||
}
|
||||
|
||||
// ── Instance-level alarm override (end-to-end Flatten) ─────────────────
|
||||
|
||||
private static (Template, Instance) BuildHiLoFixture(string inheritedJson, InstanceAlarmOverride? ovr = null, bool locked = false)
|
||||
{
|
||||
var template = new Template("PumpTpl")
|
||||
{
|
||||
Id = 1,
|
||||
Alarms = new List<TemplateAlarm>
|
||||
{
|
||||
new("Temp")
|
||||
{
|
||||
Id = 10,
|
||||
TemplateId = 1,
|
||||
TriggerType = AlarmTriggerType.HiLo,
|
||||
TriggerConfiguration = inheritedJson,
|
||||
PriorityLevel = 500,
|
||||
IsLocked = locked
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var instance = new Instance("Pump-1") { Id = 100, TemplateId = 1, SiteId = 1 };
|
||||
if (ovr != null) instance.AlarmOverrides.Add(ovr);
|
||||
return (template, instance);
|
||||
}
|
||||
|
||||
private static FlattenedConfiguration Flatten(Template template, Instance instance)
|
||||
{
|
||||
var sut = new FlatteningService();
|
||||
var result = sut.Flatten(
|
||||
instance,
|
||||
new[] { template },
|
||||
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
|
||||
new Dictionary<int, IReadOnlyList<Template>>(),
|
||||
new Dictionary<int, DataConnection>());
|
||||
if (!result.IsSuccess) Assert.Fail(result.Error);
|
||||
return result.Value!;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flatten_InstanceAlarmOverride_HiLo_MergesSetpoints()
|
||||
{
|
||||
// Template has {hi=80, hiHi=100, lo=10, loLo=0}. Instance overrides hi=90 only.
|
||||
var (tpl, inst) = BuildHiLoFixture(
|
||||
@"{""attributeName"":""Temp"",""loLo"":0,""lo"":10,""hi"":80,""hiHi"":100}",
|
||||
new InstanceAlarmOverride("Temp")
|
||||
{
|
||||
InstanceId = 100,
|
||||
TriggerConfigurationOverride = @"{""hi"":90}"
|
||||
});
|
||||
|
||||
var flat = Flatten(tpl, inst);
|
||||
var alarm = flat.Alarms.Single();
|
||||
|
||||
using var doc = JsonDocument.Parse(alarm.TriggerConfiguration!);
|
||||
Assert.Equal(0, doc.RootElement.GetProperty("loLo").GetDouble());
|
||||
Assert.Equal(10, doc.RootElement.GetProperty("lo").GetDouble());
|
||||
Assert.Equal(90, doc.RootElement.GetProperty("hi").GetDouble()); // overridden
|
||||
Assert.Equal(100, doc.RootElement.GetProperty("hiHi").GetDouble()); // inherited
|
||||
Assert.Equal("Override", alarm.Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flatten_InstanceAlarmOverride_OverridesPriority()
|
||||
{
|
||||
var (tpl, inst) = BuildHiLoFixture(
|
||||
@"{""attributeName"":""Temp"",""hi"":80}",
|
||||
new InstanceAlarmOverride("Temp")
|
||||
{
|
||||
InstanceId = 100,
|
||||
PriorityLevelOverride = 950
|
||||
});
|
||||
|
||||
var flat = Flatten(tpl, inst);
|
||||
var alarm = flat.Alarms.Single();
|
||||
|
||||
Assert.Equal(950, alarm.PriorityLevel);
|
||||
Assert.Equal("Override", alarm.Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flatten_InstanceAlarmOverride_LockedAlarm_OverrideSilentlyIgnored()
|
||||
{
|
||||
// Locked alarm — override should be a no-op at flatten time. (The
|
||||
// InstanceService.SetAlarmOverrideAsync write-time check is what
|
||||
// prevents the override from being persisted in the first place;
|
||||
// this test covers the runtime safety net.)
|
||||
var (tpl, inst) = BuildHiLoFixture(
|
||||
@"{""attributeName"":""Temp"",""hi"":80}",
|
||||
new InstanceAlarmOverride("Temp")
|
||||
{
|
||||
InstanceId = 100,
|
||||
TriggerConfigurationOverride = @"{""hi"":999}"
|
||||
},
|
||||
locked: true);
|
||||
|
||||
var flat = Flatten(tpl, inst);
|
||||
var alarm = flat.Alarms.Single();
|
||||
|
||||
using var doc = JsonDocument.Parse(alarm.TriggerConfiguration!);
|
||||
Assert.Equal(80, doc.RootElement.GetProperty("hi").GetDouble()); // not overridden
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flatten_InstanceAlarmOverride_UnknownName_DoesNothing()
|
||||
{
|
||||
// Override targets an alarm name that doesn't exist on the template —
|
||||
// silently ignored (same behavior as attribute overrides).
|
||||
var (tpl, inst) = BuildHiLoFixture(
|
||||
@"{""attributeName"":""Temp"",""hi"":80}",
|
||||
new InstanceAlarmOverride("DoesNotExist")
|
||||
{
|
||||
InstanceId = 100,
|
||||
TriggerConfigurationOverride = @"{""hi"":999}"
|
||||
});
|
||||
|
||||
var flat = Flatten(tpl, inst);
|
||||
var alarm = flat.Alarms.Single();
|
||||
|
||||
using var doc = JsonDocument.Parse(alarm.TriggerConfiguration!);
|
||||
Assert.Equal(80, doc.RootElement.GetProperty("hi").GetDouble());
|
||||
Assert.NotEqual("Override", alarm.Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flatten_InstanceAlarmOverride_BinaryTrigger_ReplacesWholeConfig()
|
||||
{
|
||||
// For non-HiLo trigger types, an instance override replaces the whole
|
||||
// TriggerConfiguration (no per-key merge).
|
||||
var template = new Template("PumpTpl")
|
||||
{
|
||||
Id = 1,
|
||||
Alarms = new List<TemplateAlarm>
|
||||
{
|
||||
new("Temp")
|
||||
{
|
||||
Id = 10,
|
||||
TemplateId = 1,
|
||||
TriggerType = AlarmTriggerType.RangeViolation,
|
||||
TriggerConfiguration = @"{""attributeName"":""T"",""min"":0,""max"":100}",
|
||||
PriorityLevel = 500
|
||||
}
|
||||
}
|
||||
};
|
||||
var instance = new Instance("Pump-1") { Id = 100, TemplateId = 1, SiteId = 1 };
|
||||
instance.AlarmOverrides.Add(new InstanceAlarmOverride("Temp")
|
||||
{
|
||||
InstanceId = 100,
|
||||
TriggerConfigurationOverride = @"{""attributeName"":""T"",""min"":-50,""max"":50}"
|
||||
});
|
||||
|
||||
var flat = Flatten(template, instance);
|
||||
var alarm = flat.Alarms.Single();
|
||||
|
||||
using var doc = JsonDocument.Parse(alarm.TriggerConfiguration!);
|
||||
Assert.Equal(-50, doc.RootElement.GetProperty("min").GetDouble());
|
||||
Assert.Equal(50, doc.RootElement.GetProperty("max").GetDouble());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user