From b28c6bdb62546864494d324b2ff0c3ede9f1a387 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 10 Jun 2026 14:21:28 -0400 Subject: [PATCH] feat(scripted-alarms): EquipmentScriptedAlarmPlan + Phase7Composer enrichment (T5) --- .../Phase7Composer.cs | 168 ++++++++++ .../Phase7ComposerScriptedAlarmTests.cs | 300 ++++++++++++++++++ 2 files changed, 468 insertions(+) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ComposerScriptedAlarmTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs index 212b26f8..13c935a5 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs @@ -1,4 +1,6 @@ +using System.Diagnostics; using System.Text.Json; +using System.Text.RegularExpressions; using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; @@ -61,6 +63,17 @@ public sealed record Phase7CompositionResult( /// Equipment-namespace VirtualTags. See . Init-only, /// defaults empty so every existing constructor + call site keeps compiling. public IReadOnlyList EquipmentVirtualTags { get; init; } = Array.Empty(); + + /// + /// Per-equipment scripted-alarm host plans — the richer analogue of the thin + /// projection. Each carries the fully-resolved predicate + /// source (joined from its ) and the merged dependency graph (predicate + /// ctx.GetTag("…") reads UNION {TagPath} tokens in the message template) so the + /// runtime alarm host can subscribe to every signal and evaluate the predicate. See + /// . Init-only, defaults empty so every existing + /// constructor + call site keeps compiling unchanged. + /// + public IReadOnlyList EquipmentScriptedAlarms { get; init; } = Array.Empty(); } public sealed record UnsAreaProjection(string UnsAreaId, string DisplayName); @@ -149,6 +162,78 @@ public sealed record EquipmentVirtualTagPlan( } } +/// +/// One Equipment-owned scripted alarm from a row, joined to its +/// predicate (by ) for the source. The +/// richer host analogue of the thin : the runtime alarm host +/// spawns one Part 9 condition per plan, evaluates over +/// , and resolves the 's +/// {TagPath} tokens at emission time. = the distinct +/// ctx.GetTag("…") read literals in the predicate source UNION the distinct +/// {TagPath} token paths referenced in the message template (the reserved +/// {{equip}} double-brace form is excluded). is carried (never +/// dropped) so the host — not the composer — decides whether to host a disabled alarm. Designed +/// to be cleanly serializable so the artifact-decode seam (sibling task) can mirror it for +/// byte-parity, exactly like . +/// +/// Stable logical id — drives the condition name + diff identity. +/// Owning equipment folder the alarm hangs under. +/// Operator-facing alarm name. +/// Concrete Part 9 type ("AlarmCondition"/"LimitAlarm"/"OffNormalAlarm"/"DiscreteAlarm"). +/// Numeric severity 1..1000 per OPC UA Part 9. +/// Template with {TagPath} tokens resolved at emission time. +/// Logical FK to the predicate script. +/// The resolved predicate script source (joined by ). +/// Distinct predicate read refs UNION message-template token paths. +/// When true, transitions route to the Aveva Historian sink. +/// OPC UA Part 9 Retain flag. +/// Whether the alarm is enabled — carried for the host to decide on. +public sealed record EquipmentScriptedAlarmPlan( + string ScriptedAlarmId, + string EquipmentId, + string Name, + string AlarmType, + int Severity, + string MessageTemplate, + string PredicateScriptId, + string PredicateSource, + IReadOnlyList DependencyRefs, + bool HistorizeToAveva, + bool Retain, + bool Enabled) +{ + /// Structural equality: the auto-generated record equality would compare + /// (an interface-typed list) BY REFERENCE, flagging every alarm as + /// "changed" on every parse (fresh list instances). Compare it element-wise so a no-op redeploy + /// diffs empty (mirrors ). + public bool Equals(EquipmentScriptedAlarmPlan? other) => + other is not null && + ScriptedAlarmId == other.ScriptedAlarmId && + EquipmentId == other.EquipmentId && + Name == other.Name && + AlarmType == other.AlarmType && + Severity == other.Severity && + MessageTemplate == other.MessageTemplate && + PredicateScriptId == other.PredicateScriptId && + PredicateSource == other.PredicateSource && + HistorizeToAveva == other.HistorizeToAveva && + Retain == other.Retain && + Enabled == other.Enabled && + DependencyRefs.SequenceEqual(other.DependencyRefs, StringComparer.Ordinal); + + /// + public override int GetHashCode() + { + var hash = new HashCode(); + hash.Add(ScriptedAlarmId); hash.Add(EquipmentId); hash.Add(Name); + hash.Add(AlarmType); hash.Add(Severity); hash.Add(MessageTemplate); + hash.Add(PredicateScriptId); hash.Add(PredicateSource); + hash.Add(HistorizeToAveva); hash.Add(Retain); hash.Add(Enabled); + foreach (var r in DependencyRefs) hash.Add(r, StringComparer.Ordinal); + return hash.ToHashCode(); + } +} + /// /// Pure composer that flattens the live-edit DB tables into the address-space build plan a /// driver-role host needs. Same inputs → same outputs, no logging, no DB writes. The driver-role @@ -325,13 +410,96 @@ public static class Phase7Composer }) .ToList(); + // Equipment scripted alarms = each ScriptedAlarm joined to its predicate Script (by + // PredicateScriptId) for the source. An alarm whose PredicateScriptId has no matching Script + // row is SKIPPED (not emitted) with a structured warning rather than failing the whole + // compose — the upstream draft validator is the authority that should prevent the dangling + // reference. DependencyRefs = the predicate's distinct ctx.GetTag("…") reads UNION the + // distinct {TagPath} tokens in the MessageTemplate (predicate reads first, then first-seen + // template tokens; the reserved {{equip}} double-brace form is excluded). Enabled is carried + // (never dropped) — the runtime host decides whether to host a disabled alarm. Ordered by + // EquipmentId then ScriptedAlarmId so the upcoming artifact byte-parity test is reliable. + var equipmentScriptedAlarms = scriptedAlarms + .OrderBy(a => a.EquipmentId, StringComparer.Ordinal) + .ThenBy(a => a.ScriptedAlarmId, StringComparer.Ordinal) + .Select(a => + { + if (!scriptsById.TryGetValue(a.PredicateScriptId, out var s)) + { + Trace.TraceWarning( + "Phase7Composer: scripted alarm '{0}' (equipment '{1}') references predicate " + + "script '{2}' which is not in the supplied scripts — skipping.", + a.ScriptedAlarmId, a.EquipmentId, a.PredicateScriptId); + return null; + } + var source = s.SourceCode; + return new EquipmentScriptedAlarmPlan( + ScriptedAlarmId: a.ScriptedAlarmId, + EquipmentId: a.EquipmentId, + Name: a.Name, + AlarmType: a.AlarmType, + Severity: a.Severity, + MessageTemplate: a.MessageTemplate, + PredicateScriptId: a.PredicateScriptId, + PredicateSource: source, + DependencyRefs: MergeAlarmDependencyRefs(source, a.MessageTemplate), + HistorizeToAveva: a.HistorizeToAveva, + Retain: a.Retain, + Enabled: a.Enabled); + }) + .Where(p => p is not null) + .Select(p => p!) + .ToList(); + return new Phase7CompositionResult(areas, lines, nodes, plans, alarms, galaxyTags) { EquipmentTags = equipmentTags, EquipmentVirtualTags = equipmentVirtualTags, + EquipmentScriptedAlarms = equipmentScriptedAlarms, }; } + // {TagPath} message-template token — a single-brace {…} run. Reuses no existing helper because + // EquipmentScriptPaths only handles {{equip}} + ctx.GetTag literals; this is the alarm-message + // surface. The negative look-behind/-ahead reject the reserved {{equip}} double-brace form + // (and any other doubled brace), so "{A.X}" matches but "{{equip}}" does not yield "{equip}". + private static readonly Regex MessageTemplateTokenRegex = + new(@"(? + /// Merge the alarm's dependency graph: the predicate script's distinct ctx.GetTag("…") + /// read refs (first-seen order) UNION the distinct {TagPath} token paths referenced in + /// the message template (first-seen order, appended after the predicate reads). The reserved + /// {{equip}} double-brace form is excluded by the token regex. Deterministic so the + /// artifact-decode mirror (sibling task) can reproduce the exact same ordered list. + /// + /// The resolved predicate script source. + /// The alarm message template carrying {TagPath} tokens. + /// The merged, distinct, deterministically-ordered dependency refs. + private static IReadOnlyList MergeAlarmDependencyRefs(string predicateSource, string? messageTemplate) + { + var seen = new HashSet(StringComparer.Ordinal); + var result = new List(); + + // Predicate reads first — same regex extraction the VirtualTag projection uses. + foreach (var r in EquipmentScriptPaths.ExtractDependencyRefs(predicateSource)) + { + if (seen.Add(r)) result.Add(r); + } + + // Then message-template {TagPath} tokens (first-seen), trimmed, non-empty. + if (!string.IsNullOrEmpty(messageTemplate)) + { + foreach (Match m in MessageTemplateTokenRegex.Matches(messageTemplate)) + { + var token = m.Groups[1].Value.Trim(); + if (token.Length > 0 && seen.Add(token)) result.Add(token); + } + } + + return result; + } + /// /// Extract the driver-side full reference from a JSON blob: the /// CK_Tag_TagConfig_IsJson constraint guarantees a JSON object, and every shipped diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ComposerScriptedAlarmTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ComposerScriptedAlarmTests.cs new file mode 100644 index 00000000..0be9a0b2 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ComposerScriptedAlarmTests.cs @@ -0,0 +1,300 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; + +/// +/// Verifies the live-edit compose seam () builds the richer +/// per-equipment scripted-alarm projection (): each alarm +/// is joined to its predicate for the source, carries the parsed +/// ctx.GetTag("…") read refs UNION the {TagPath} tokens referenced in its +/// MessageTemplate as DependencyRefs, and preserves every flag the host decides on +/// (including disabled alarms). The projection is deterministic so the upcoming +/// artifact-byte-parity test (sibling task) is reliable. +/// +public sealed class Phase7ComposerScriptedAlarmTests +{ + /// Two equipments, each with a scripted alarm whose predicate script reads one tag via + /// ctx.GetTag("X.Y"). Each plan must carry the resolved PredicateSource, DependencyRefs + /// containing the read path, and every scalar field copied through faithfully. + [Fact] + public void Compose_emits_richer_scripted_alarm_plan_per_equipment_joined_to_script() + { + var script1 = new Script + { + ScriptId = "s-1", + Name = "hot-1", + SourceCode = "return System.Convert.ToDouble(ctx.GetTag(\"Mach1.Temp\").Value) > 80;", + SourceHash = "h1", + }; + var script2 = new Script + { + ScriptId = "s-2", + Name = "hot-2", + SourceCode = "return System.Convert.ToDouble(ctx.GetTag(\"Mach2.Temp\").Value) > 80;", + SourceHash = "h2", + }; + var alarm1 = new ScriptedAlarm + { + ScriptedAlarmId = "al-1", + EquipmentId = "eq-1", + Name = "Overheat-1", + AlarmType = "LimitAlarm", + Severity = 700, + MessageTemplate = "Machine 1 overheating", + PredicateScriptId = "s-1", + HistorizeToAveva = true, + Retain = true, + Enabled = true, + }; + var alarm2 = new ScriptedAlarm + { + ScriptedAlarmId = "al-2", + EquipmentId = "eq-2", + Name = "Overheat-2", + AlarmType = "OffNormalAlarm", + Severity = 250, + MessageTemplate = "Machine 2 overheating", + PredicateScriptId = "s-2", + HistorizeToAveva = false, + Retain = false, + Enabled = true, + }; + + var result = Phase7Composer.Compose( + Array.Empty(), Array.Empty(), Array.Empty(), + Array.Empty(), new[] { alarm1, alarm2 }, + Array.Empty(), Array.Empty(), + virtualTags: Array.Empty(), + scripts: new[] { script1, script2 }); + + result.EquipmentScriptedAlarms.Count.ShouldBe(2); + + var plan1 = result.EquipmentScriptedAlarms.Single(p => p.ScriptedAlarmId == "al-1"); + plan1.EquipmentId.ShouldBe("eq-1"); + plan1.Name.ShouldBe("Overheat-1"); + plan1.AlarmType.ShouldBe("LimitAlarm"); + plan1.Severity.ShouldBe(700); + plan1.MessageTemplate.ShouldBe("Machine 1 overheating"); + plan1.PredicateScriptId.ShouldBe("s-1"); + plan1.PredicateSource.ShouldBe(script1.SourceCode); + plan1.DependencyRefs.ShouldBe(new[] { "Mach1.Temp" }); + plan1.HistorizeToAveva.ShouldBeTrue(); + plan1.Retain.ShouldBeTrue(); + plan1.Enabled.ShouldBeTrue(); + + var plan2 = result.EquipmentScriptedAlarms.Single(p => p.ScriptedAlarmId == "al-2"); + plan2.EquipmentId.ShouldBe("eq-2"); + plan2.AlarmType.ShouldBe("OffNormalAlarm"); + plan2.Severity.ShouldBe(250); + plan2.PredicateSource.ShouldBe(script2.SourceCode); + plan2.DependencyRefs.ShouldBe(new[] { "Mach2.Temp" }); + plan2.HistorizeToAveva.ShouldBeFalse(); + plan2.Retain.ShouldBeFalse(); + } + + /// The {TagPath} tokens in the MessageTemplate are merged into DependencyRefs + /// alongside the predicate's ctx.GetTag reads, so the host can subscribe to every signal + /// the alarm references for either the predicate or the message. + [Fact] + public void Compose_merges_message_template_token_paths_into_dependency_refs() + { + var script = new Script + { + ScriptId = "s-1", + Name = "hot", + SourceCode = "return System.Convert.ToDouble(ctx.GetTag(\"Line.Pressure\").Value) > 5;", + SourceHash = "h1", + }; + var alarm = new ScriptedAlarm + { + ScriptedAlarmId = "al-1", + EquipmentId = "eq-1", + Name = "TempHigh", + AlarmType = "LimitAlarm", + Severity = 500, + MessageTemplate = "Temp {Line.Temp} high", + PredicateScriptId = "s-1", + }; + + var result = Phase7Composer.Compose( + Array.Empty(), Array.Empty(), Array.Empty(), + Array.Empty(), new[] { alarm }, + Array.Empty(), Array.Empty(), + scripts: new[] { script }); + + var plan = result.EquipmentScriptedAlarms.ShouldHaveSingleItem(); + // Predicate read + message token are both present (predicate reads first, then template tokens). + plan.DependencyRefs.ShouldBe(new[] { "Line.Pressure", "Line.Temp" }); + } + + /// A template token that duplicates a predicate read collapses to one ref, and a + /// repeated template token collapses too — DependencyRefs are distinct (predicate reads first, + /// then first-seen template tokens), and the reserved {{equip}} double-brace form is not + /// harvested as a token. + [Fact] + public void Compose_dependency_refs_are_distinct_and_exclude_the_reserved_equip_token() + { + var script = new Script + { + ScriptId = "s-1", + Name = "dup", + SourceCode = "return ctx.GetTag(\"A.X\").Value;", + SourceHash = "h1", + }; + var alarm = new ScriptedAlarm + { + ScriptedAlarmId = "al-1", + EquipmentId = "eq-1", + Name = "Dup", + AlarmType = "AlarmCondition", + Severity = 500, + // {A.X} duplicates the predicate read; {B.Y} appears twice; {{equip}}.Z must NOT be a token. + MessageTemplate = "v={A.X} w={B.Y} x={B.Y} base={{equip}}.Z", + PredicateScriptId = "s-1", + }; + + var result = Phase7Composer.Compose( + Array.Empty(), Array.Empty(), Array.Empty(), + Array.Empty(), new[] { alarm }, + Array.Empty(), Array.Empty(), + scripts: new[] { script }); + + var plan = result.EquipmentScriptedAlarms.ShouldHaveSingleItem(); + plan.DependencyRefs.ShouldBe(new[] { "A.X", "B.Y" }); + plan.DependencyRefs.ShouldNotContain("equip"); + } + + /// A scripted alarm whose PredicateScriptId matches no Script row is SKIPPED (not in the + /// result) and compose does NOT throw — the bad row is dropped, the rest of the projection stands. + [Fact] + public void Compose_skips_scripted_alarm_with_missing_predicate_script_without_throwing() + { + var goodScript = new Script + { + ScriptId = "s-ok", + Name = "ok", + SourceCode = "return ctx.GetTag(\"A.X\").Value;", + SourceHash = "h1", + }; + var goodAlarm = new ScriptedAlarm + { + ScriptedAlarmId = "al-ok", + EquipmentId = "eq-1", + Name = "Ok", + AlarmType = "AlarmCondition", + Severity = 500, + MessageTemplate = "ok", + PredicateScriptId = "s-ok", + }; + var orphanAlarm = new ScriptedAlarm + { + ScriptedAlarmId = "al-orphan", + EquipmentId = "eq-2", + Name = "Orphan", + AlarmType = "AlarmCondition", + Severity = 500, + MessageTemplate = "orphan", + PredicateScriptId = "s-does-not-exist", + }; + + var result = Phase7Composer.Compose( + Array.Empty(), Array.Empty(), Array.Empty(), + Array.Empty(), new[] { goodAlarm, orphanAlarm }, + Array.Empty(), Array.Empty(), + scripts: new[] { goodScript }); + + var plan = result.EquipmentScriptedAlarms.ShouldHaveSingleItem(); + plan.ScriptedAlarmId.ShouldBe("al-ok"); + result.EquipmentScriptedAlarms.ShouldNotContain(p => p.ScriptedAlarmId == "al-orphan"); + } + + /// A disabled alarm (Enabled=false) is STILL present in the projection — the host + /// decides whether to host it, the composer never drops it. The flag is carried through. + [Fact] + public void Compose_keeps_disabled_scripted_alarm_with_enabled_flag_carried() + { + var script = new Script + { + ScriptId = "s-1", + Name = "off", + SourceCode = "return ctx.GetTag(\"A.X\").Value;", + SourceHash = "h1", + }; + var alarm = new ScriptedAlarm + { + ScriptedAlarmId = "al-off", + EquipmentId = "eq-1", + Name = "Disabled", + AlarmType = "AlarmCondition", + Severity = 500, + MessageTemplate = "off", + PredicateScriptId = "s-1", + Enabled = false, + }; + + var result = Phase7Composer.Compose( + Array.Empty(), Array.Empty(), Array.Empty(), + Array.Empty(), new[] { alarm }, + Array.Empty(), Array.Empty(), + scripts: new[] { script }); + + var plan = result.EquipmentScriptedAlarms.ShouldHaveSingleItem(); + plan.ScriptedAlarmId.ShouldBe("al-off"); + plan.Enabled.ShouldBeFalse(); + } + + /// Determinism: the same inputs in different order produce identical ordered output + /// (stable sort by EquipmentId then ScriptedAlarmId) — this is the contract the upcoming + /// artifact byte-parity test relies on. + [Fact] + public void Compose_scripted_alarm_projection_is_deterministic_regardless_of_input_order() + { + var script1 = new Script { ScriptId = "s-1", Name = "a", SourceCode = "return ctx.GetTag(\"A.X\").Value;", SourceHash = "h1" }; + var script2 = new Script { ScriptId = "s-2", Name = "b", SourceCode = "return ctx.GetTag(\"B.Y\").Value;", SourceHash = "h2" }; + var script3 = new Script { ScriptId = "s-3", Name = "c", SourceCode = "return ctx.GetTag(\"C.Z\").Value;", SourceHash = "h3" }; + + // Two alarms on the same equipment to exercise the secondary (ScriptedAlarmId) sort key. + var a1 = NewAlarm("al-1", "eq-2", "s-1"); + var a2 = NewAlarm("al-2", "eq-1", "s-2"); + var a3 = NewAlarm("al-3", "eq-1", "s-3"); + var scripts = new[] { script1, script2, script3 }; + + var r1 = Phase7Composer.Compose( + Array.Empty(), Array.Empty(), Array.Empty(), + Array.Empty(), new[] { a1, a2, a3 }, + Array.Empty(), Array.Empty(), scripts: scripts); + + var r2 = Phase7Composer.Compose( + Array.Empty(), Array.Empty(), Array.Empty(), + Array.Empty(), new[] { a3, a1, a2 }, + Array.Empty(), Array.Empty(), scripts: scripts); + + // Sorted by EquipmentId then ScriptedAlarmId: (eq-1,al-2), (eq-1,al-3), (eq-2,al-1). + r1.EquipmentScriptedAlarms.Select(p => p.ScriptedAlarmId) + .ShouldBe(new[] { "al-2", "al-3", "al-1" }); + r1.EquipmentScriptedAlarms.ShouldBe(r2.EquipmentScriptedAlarms); + } + + /// The new projection defaults to empty when no scripts/alarms are supplied (init-only + /// member, no positional-parameter break for existing callers). + [Fact] + public void Composition_carries_empty_scripted_alarms_by_default() + { + var r = new Phase7CompositionResult( + Array.Empty(), Array.Empty(), Array.Empty()); + r.EquipmentScriptedAlarms.ShouldBeEmpty(); + } + + private static ScriptedAlarm NewAlarm(string id, string equipmentId, string scriptId) => new() + { + ScriptedAlarmId = id, + EquipmentId = equipmentId, + Name = id, + AlarmType = "AlarmCondition", + Severity = 500, + MessageTemplate = $"{id} alarm", + PredicateScriptId = scriptId, + }; +}