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,
+ };
+}