feat(scripted-alarms): materialise real Part 9 AlarmConditionState nodes (T14)

This commit is contained in:
Joseph Doherty
2026-06-10 19:19:10 -04:00
parent 4217b213b0
commit 60d48a2a0a
14 changed files with 443 additions and 12 deletions
@@ -285,6 +285,36 @@ public sealed class Phase7Applier
composition.EquipmentVirtualTags.Select(v => v.EquipmentId).Distinct(StringComparer.Ordinal).Count());
}
/// <summary>
/// T14 — materialise real OPC UA Part 9 <c>AlarmConditionState</c> nodes from a composition
/// snapshot. For each <b>enabled</b> <see cref="EquipmentScriptedAlarmPlan"/>, register a
/// condition node (keyed by its <see cref="EquipmentScriptedAlarmPlan.ScriptedAlarmId"/>, which
/// is the same id <c>OpcUaPublishActor.AlarmStateUpdate</c> targets) under its equipment folder.
/// Disabled alarms are skipped — they expose no node. Must run AFTER
/// <see cref="MaterialiseHierarchy"/> so the equipment folders exist. Idempotent (the sink's
/// <c>MaterialiseAlarmCondition</c> re-creates cleanly on re-apply).
/// </summary>
/// <param name="composition">The composition result containing the scripted alarms to materialise.</param>
public void MaterialiseScriptedAlarms(Phase7CompositionResult composition)
{
ArgumentNullException.ThrowIfNull(composition);
if (composition.EquipmentScriptedAlarms.Count == 0) return;
var materialised = 0;
foreach (var alarm in composition.EquipmentScriptedAlarms)
{
if (!alarm.Enabled) continue;
SafeMaterialiseAlarmCondition(alarm.ScriptedAlarmId, alarm.EquipmentId, alarm.Name, alarm.AlarmType, alarm.Severity);
materialised++;
}
_logger.LogInformation(
"Phase7Applier: scripted alarms materialised (alarms={Alarms}, equipment={Equipment})",
materialised,
composition.EquipmentScriptedAlarms.Where(a => a.Enabled)
.Select(a => a.EquipmentId).Distinct(StringComparer.Ordinal).Count());
}
/// <summary>Deterministic NodeId for a tag's FolderPath sub-folder, scoped under its equipment
/// folder so two equipments' identically-named sub-folders never collide.</summary>
private static string EquipmentSubFolderNodeId(string equipmentId, string folderPath) =>
@@ -307,6 +337,12 @@ public sealed class Phase7Applier
try { _sink.WriteAlarmState(nodeId, active, acknowledged, ts); }
catch (Exception ex) { _logger.LogWarning(ex, "Phase7Applier: WriteAlarmState threw for {Node}", nodeId); }
}
private void SafeMaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity)
{
try { _sink.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity); }
catch (Exception ex) { _logger.LogWarning(ex, "Phase7Applier: MaterialiseAlarmCondition threw for {Node}", alarmNodeId); }
}
}
/// <summary>Summary of one apply pass. Useful for tests + audit-log entries on the deploy path.</summary>