feat(alarms): materialise a Part 9 condition for an alarm equipment tag (Phase B WS-3)

This commit is contained in:
Joseph Doherty
2026-06-14 03:37:51 -04:00
parent 4c56a1719b
commit b50ef9fc2d
2 changed files with 75 additions and 1 deletions
@@ -189,7 +189,16 @@ public sealed class Phase7Applier
? tag.EquipmentId
: EquipmentNodeIds.SubFolder(tag.EquipmentId, tag.FolderPath);
var nodeId = EquipmentNodeIds.Variable(tag.EquipmentId, tag.FolderPath, tag.Name);
SafeEnsureVariable(nodeId, parent, tag.Name, tag.DataType, tag.Writable);
if (tag.Alarm is not null)
{
// Native alarm tag → a real Part 9 condition node (reuses the scripted-alarm path),
// NOT a value variable. Parent is the sub-folder when set, else the equipment folder.
SafeMaterialiseAlarmCondition(nodeId, parent, tag.Name, tag.Alarm.AlarmType, tag.Alarm.Severity);
}
else
{
SafeEnsureVariable(nodeId, parent, tag.Name, tag.DataType, tag.Writable);
}
}
_logger.LogInformation(
@@ -197,6 +197,71 @@ public sealed class Phase7ApplierTests
sink.VariableCalls.ShouldContain(("eq-2/Speed", "eq-2", "Speed", "Float", false));
}
/// <summary>Phase B WS-3 — an alarm-bearing equipment tag (<c>Alarm is not null</c>) materialises a
/// real OPC UA Part 9 condition node (via the same path scripted alarms use) instead of a value
/// variable; a plain tag (<c>Alarm == null</c>) stays a value variable. The alarm tag's condition
/// uses the tag's folder-scoped NodeId, the equipment folder as parent, and carries the tag's
/// AlarmType/Severity. Proves BOTH branches in one composition.</summary>
[Fact]
public void MaterialiseEquipmentTags_alarm_bearing_tag_becomes_condition_plain_tag_stays_variable()
{
var sink = new RecordingSink();
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
var composition = new Phase7CompositionResult(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentTags = new[]
{
new EquipmentTagPlan("tag-plain", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null),
new EquipmentTagPlan("tag-alarm", "eq-1", "drv", FolderPath: "", Name: "OverTemp", DataType: "Boolean", FullName: "00001", Writable: false, Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 700)),
},
};
applier.MaterialiseEquipmentTags(composition);
// The plain tag drove EnsureVariable at its folder-scoped NodeId, and NOT a condition.
var plainNodeId = EquipmentNodeIds.Variable("eq-1", "", "Speed");
sink.VariableCalls.ShouldHaveSingleItem().ShouldBe((plainNodeId, "eq-1", "Speed", "Float", true));
sink.AlarmConditionCalls.ShouldNotContain(c => c.AlarmNodeId == plainNodeId);
// The alarm tag drove MaterialiseAlarmCondition (folder-scoped NodeId, equipment parent,
// matching display/type/severity) and did NOT drive EnsureVariable.
var alarmNodeId = EquipmentNodeIds.Variable("eq-1", "", "OverTemp");
sink.AlarmConditionCalls.ShouldHaveSingleItem()
.ShouldBe((alarmNodeId, "eq-1", "OverTemp", "OffNormalAlarm", 700));
sink.VariableCalls.ShouldNotContain(v => v.NodeId == alarmNodeId);
}
/// <summary>Phase B WS-3 — an alarm-bearing equipment tag WITH a FolderPath still gets its
/// sub-folder created, and its condition is parented to that sub-folder (not the equipment folder),
/// using the folder-scoped NodeId.</summary>
[Fact]
public void MaterialiseEquipmentTags_alarm_bearing_tag_with_FolderPath_conditions_under_subfolder()
{
var sink = new RecordingSink();
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
var composition = new Phase7CompositionResult(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentTags = new[]
{
new EquipmentTagPlan("tag-alarm", "eq-1", "drv", FolderPath: "Diagnostics", Name: "OverTemp", DataType: "Boolean", FullName: "00001", Writable: false, Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 500)),
},
};
applier.MaterialiseEquipmentTags(composition);
// The sub-folder is still created for an alarm tag with a FolderPath.
sink.FolderCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics", "eq-1", "Diagnostics"));
// Condition is parented to the sub-folder, with the folder-scoped NodeId. No value variable.
var alarmNodeId = EquipmentNodeIds.Variable("eq-1", "Diagnostics", "OverTemp");
sink.AlarmConditionCalls.ShouldHaveSingleItem()
.ShouldBe((alarmNodeId, "eq-1/Diagnostics", "OverTemp", "OffNormalAlarm", 500));
sink.VariableCalls.ShouldBeEmpty();
}
/// <summary>Verifies MaterialiseEquipmentVirtualTags creates one Variable per VirtualTag directly
/// under its existing equipment folder, with a folder-scoped NodeId (EquipmentId/Name — NOT the
/// VirtualTagId or Expression), parent == EquipmentId, displayName == Name, and does NOT re-create