From 8e8ca9efe8eeed92a2da9c040797c9605a6228a8 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 10 Jun 2026 14:41:46 -0400 Subject: [PATCH] feat(scripted-alarms): DeploymentArtifact byte-parity for the alarm plan (T6) --- .../Types/EquipmentScriptPaths.cs | 44 ++++ .../Phase7Composer.cs | 51 +--- .../Drivers/DeploymentArtifact.cs | 84 ++++++ ...loymentArtifactScriptedAlarmParityTests.cs | 242 ++++++++++++++++++ 4 files changed, 376 insertions(+), 45 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactScriptedAlarmParityTests.cs diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/EquipmentScriptPaths.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/EquipmentScriptPaths.cs index 24ed133b..6128c6dd 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/EquipmentScriptPaths.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/EquipmentScriptPaths.cs @@ -20,6 +20,12 @@ public static class EquipmentScriptPaths private static readonly Regex GetTagRefRegex = new(@"ctx\s*\.\s*GetTag\s*\(\s*""([^""]+)""\s*\)", RegexOptions.Compiled); + // {TagPath} message-template token — a single-brace {…} run. 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 a scripted alarm's dependency graph: the predicate script's distinct + /// ctx.GetTag("…") read refs (via , first-seen order) + /// UNION the distinct {TagPath} token paths referenced in the message template (first-seen + /// order, appended after the predicate reads, trimmed + non-empty). The reserved + /// {{equip}} double-brace form is excluded by the token regex. Deterministic so the live + /// composer (Phase7Composer) and the artifact-decode mirror (DeploymentArtifact) + /// produce the exact same ordered list — the byte-parity contract EquipmentScriptedAlarmPlan + /// equality depends on. Scripted alarms do NOT use {{equip}} substitution (only virtual + /// tags do) — pass the predicate source as-is. + /// + /// The resolved predicate script source. + /// The alarm message template carrying {TagPath} tokens. + /// The merged, distinct, deterministically-ordered dependency refs. + public static IReadOnlyList ExtractAlarmDependencyRefs(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 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; + } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs index 23a628dd..dd527993 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs @@ -1,6 +1,5 @@ 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; @@ -208,10 +207,10 @@ public sealed record EquipmentScriptedAlarmPlan( /// diffs empty (mirrors ). /// /// DependencyRefs equality is order-sensitive (SequenceEqual). - /// is the canonical, deterministic + /// is the canonical, deterministic /// producer of that order (predicate ctx.GetTag reads first, then first-seen message /// template tokens). Downstream byte-parity between the live composer and the artifact-decode - /// mirror depends on both sides calling MergeAlarmDependencyRefs with identical inputs. + /// mirror depends on both sides calling ExtractAlarmDependencyRefs with identical inputs. /// public bool Equals(EquipmentScriptedAlarmPlan? other) => other is not null && @@ -452,7 +451,10 @@ public static class Phase7Composer MessageTemplate: a.MessageTemplate, PredicateScriptId: a.PredicateScriptId, PredicateSource: source, - DependencyRefs: MergeAlarmDependencyRefs(source, a.MessageTemplate), + // Scripted alarms do NOT use {{equip}} substitution (only virtual tags do) — pass the + // predicate source as-is. The merge (predicate reads first, then template tokens) lives + // in the shared EquipmentScriptPaths helper so the artifact-decode mirror agrees. + DependencyRefs: EquipmentScriptPaths.ExtractAlarmDependencyRefs(source, a.MessageTemplate), HistorizeToAveva: a.HistorizeToAveva, Retain: a.Retain, Enabled: a.Enabled)); @@ -466,47 +468,6 @@ public static class Phase7Composer }; } - // {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/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs index 07137975..3f590452 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs @@ -195,11 +195,13 @@ public static class DeploymentArtifact var galaxyTags = BuildGalaxyTagPlans(root, drivers); var equipmentTags = BuildEquipmentTagPlans(root); var equipmentVirtualTags = BuildEquipmentVirtualTagPlans(root, equipmentTags); + var equipmentScriptedAlarms = BuildEquipmentScriptedAlarmPlans(root); return new Phase7CompositionResult(areas, lines, equipment, drivers, alarms, galaxyTags) { EquipmentTags = equipmentTags, EquipmentVirtualTags = equipmentVirtualTags, + EquipmentScriptedAlarms = equipmentScriptedAlarms, }; } catch (JsonException) @@ -261,6 +263,7 @@ public static class DeploymentArtifact { EquipmentTags = full.EquipmentTags.Where(t => sets.DriverIds.Contains(t.DriverInstanceId)).ToArray(), EquipmentVirtualTags = full.EquipmentVirtualTags.Where(v => sets.EquipmentIds.Contains(v.EquipmentId)).ToArray(), + EquipmentScriptedAlarms = full.EquipmentScriptedAlarms.Where(a => sets.EquipmentIds.Contains(a.EquipmentId)).ToArray(), }; } @@ -606,6 +609,87 @@ public static class DeploymentArtifact return result; } + /// + /// Join the artifact's ScriptedAlarms array to its Scripts array (by PredicateScriptId) to emit + /// one per alarm. The artifact-decode mirror of + /// Phase7Composer.Compose's scripted-alarm producer — so the compose-side + artifact-decode + /// plans agree byte-for-byte. An alarm whose PredicateScriptId has no matching Script row is + /// SKIPPED (matching the composer's skip behaviour) to preserve parity. PredicateSource = the + /// joined script source ("" when missing — but such alarms are skipped above); DependencyRefs + /// = the shared merge of the predicate's + /// distinct ctx.GetTag("…") reads UNION the message template's {TagPath} tokens. Scripted + /// alarms do NOT use {{equip}} substitution (only virtual tags do) — the predicate source is + /// used as-is. Ordered by EquipmentId then ScriptedAlarmId to match the composer's deterministic order. + /// + private static IReadOnlyList BuildEquipmentScriptedAlarmPlans(JsonElement root) + { + if (!root.TryGetProperty("ScriptedAlarms", out var alarmsArr) || alarmsArr.ValueKind != JsonValueKind.Array) + return Array.Empty(); + + // scriptId → SourceCode (the predicate source the alarm host evaluates). Same join the + // VirtualTag builder uses; an alarm whose PredicateScriptId is absent here is skipped below. + var scriptSourceById = new Dictionary(StringComparer.Ordinal); + if (root.TryGetProperty("Scripts", out var scriptsArr) && scriptsArr.ValueKind == JsonValueKind.Array) + { + foreach (var el in scriptsArr.EnumerateArray()) + { + if (el.ValueKind != JsonValueKind.Object) continue; + var sid = el.TryGetProperty("ScriptId", out var sidEl) ? sidEl.GetString() : null; + if (string.IsNullOrWhiteSpace(sid)) continue; + var src = el.TryGetProperty("SourceCode", out var srcEl) && srcEl.ValueKind == JsonValueKind.String + ? srcEl.GetString() : null; + scriptSourceById[sid!] = src ?? string.Empty; + } + } + + var result = new List(alarmsArr.GetArrayLength()); + foreach (var el in alarmsArr.EnumerateArray()) + { + if (el.ValueKind != JsonValueKind.Object) continue; + var scriptedAlarmId = el.TryGetProperty("ScriptedAlarmId", out var idEl) ? idEl.GetString() : null; + var equipmentId = el.TryGetProperty("EquipmentId", out var eqEl) ? eqEl.GetString() : null; + var name = el.TryGetProperty("Name", out var nmEl) ? nmEl.GetString() : null; + var alarmType = el.TryGetProperty("AlarmType", out var atEl) ? atEl.GetString() : null; + var severity = el.TryGetProperty("Severity", out var svEl) && svEl.TryGetInt32(out var sv) ? sv : 0; + var messageTemplate = el.TryGetProperty("MessageTemplate", out var mtEl) ? mtEl.GetString() : null; + var predicateScriptId = el.TryGetProperty("PredicateScriptId", out var psEl) ? psEl.GetString() : null; + var historize = el.TryGetProperty("HistorizeToAveva", out var hEl) && hEl.ValueKind != JsonValueKind.Null + ? hEl.GetBoolean() : false; + var retain = el.TryGetProperty("Retain", out var rEl) && rEl.ValueKind != JsonValueKind.Null + ? rEl.GetBoolean() : false; + var enabled = el.TryGetProperty("Enabled", out var enEl) && enEl.ValueKind != JsonValueKind.Null + ? enEl.GetBoolean() : false; + + if (string.IsNullOrWhiteSpace(scriptedAlarmId)) continue; + + // Skip alarms whose predicate script is missing — matching Phase7Composer's skip behaviour + // so both sides emit the same set (byte-parity). + if (predicateScriptId is null || !scriptSourceById.TryGetValue(predicateScriptId, out var source)) + continue; + + result.Add(new EquipmentScriptedAlarmPlan( + ScriptedAlarmId: scriptedAlarmId!, + EquipmentId: equipmentId ?? string.Empty, + Name: name ?? string.Empty, + AlarmType: alarmType ?? string.Empty, + Severity: severity, + MessageTemplate: messageTemplate ?? string.Empty, + PredicateScriptId: predicateScriptId, + PredicateSource: source, + DependencyRefs: EquipmentScriptPaths.ExtractAlarmDependencyRefs(source, messageTemplate), + HistorizeToAveva: historize, + Retain: retain, + Enabled: enabled)); + } + + result.Sort((a, b) => + { + var byEquipment = string.CompareOrdinal(a.EquipmentId, b.EquipmentId); + return byEquipment != 0 ? byEquipment : string.CompareOrdinal(a.ScriptedAlarmId, b.ScriptedAlarmId); + }); + return result; + } + /// /// Extract the driver-side full reference from a tag's TagConfig JSON (top-level "FullName" /// field). The artifact-decode mirror of Phase7Composer.ExtractTagFullName / diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactScriptedAlarmParityTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactScriptedAlarmParityTests.cs new file mode 100644 index 00000000..f81080e7 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactScriptedAlarmParityTests.cs @@ -0,0 +1,242 @@ +using System.Linq; +using System.Text.Json; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.OpcUaServer; +using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; + +/// +/// Byte-parity tests for the scripted-alarm deployment-artifact decode path +/// (DeploymentArtifact.BuildEquipmentScriptedAlarmPlans) against the live compose seam +/// (Phase7Composer.Compose). Both sides derive EquipmentScriptedAlarmPlan from the +/// same ScriptedAlarm + Script data via the shared EquipmentScriptPaths.ExtractAlarmDependencyRefs +/// helper, so the decoded plans must equal the composer's element-wise (the record has value +/// equality including DependencyRefs order). Mirrors the existing EquipmentVirtualTags parity +/// style in DeploymentArtifactEquipTokenTests. +/// +public sealed class DeploymentArtifactScriptedAlarmParityTests +{ + private static byte[] BlobOf(object snapshot) => JsonSerializer.SerializeToUtf8Bytes(snapshot); + + /// The live composer's plan for the same scripted alarms + scripts the artifact carries + /// must equal the artifact-decode plan, field-for-field including DependencyRefs order. + [Fact] + public void ParseComposition_scripted_alarms_are_byte_parity_with_composer() + { + // Two equipments, each with a scripted alarm: predicate reads a tag via ctx.GetTag("X.Y"), + // and the message template carries a distinct {A.B} token — so DependencyRefs exercise the + // predicate-reads-then-template-tokens merge order on both sides. + 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 at {Mach1.Pressure}", + 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 at {Mach2.Pressure}", + PredicateScriptId = "s-2", + HistorizeToAveva = false, + Retain = false, + Enabled = false, + }; + + var composed = Phase7Composer.Compose( + Array.Empty(), Array.Empty(), Array.Empty(), + Array.Empty(), new[] { alarm1, alarm2 }, + Array.Empty(), Array.Empty(), + virtualTags: Array.Empty(), + scripts: new[] { script1, script2 }); + + // The artifact blob the write side (ConfigComposer) emits: the FULL ScriptedAlarm + Script + // entities serialised Pascal-case off the EF entity. + var blob = BlobOf(new + { + ScriptedAlarms = new[] + { + ToSnapshot(alarm1), + ToSnapshot(alarm2), + }, + Scripts = new[] + { + new { ScriptId = script1.ScriptId, SourceCode = script1.SourceCode }, + new { ScriptId = script2.ScriptId, SourceCode = script2.SourceCode }, + }, + }); + + var decoded = DeploymentArtifact.ParseComposition(blob); + + // Byte-parity: element-wise value equality (record equality compares DependencyRefs order). + decoded.EquipmentScriptedAlarms.Count.ShouldBe(2); + decoded.EquipmentScriptedAlarms.SequenceEqual(composed.EquipmentScriptedAlarms).ShouldBeTrue(); + // And spot-check the merged DependencyRefs (predicate read first, then template token). + var plan1 = decoded.EquipmentScriptedAlarms.Single(p => p.ScriptedAlarmId == "al-1"); + plan1.DependencyRefs.ShouldBe(new[] { "Mach1.Temp", "Mach1.Pressure" }); + plan1.Enabled.ShouldBeTrue(); + var plan2 = decoded.EquipmentScriptedAlarms.Single(p => p.ScriptedAlarmId == "al-2"); + plan2.DependencyRefs.ShouldBe(new[] { "Mach2.Temp", "Mach2.Pressure" }); + plan2.Enabled.ShouldBeFalse(); + plan2.HistorizeToAveva.ShouldBeFalse(); + plan2.Retain.ShouldBeFalse(); + } + + /// The cluster-scoped overload keeps only the scripted alarms whose EquipmentId belongs to + /// an in-cluster driver (mirroring how EquipmentVirtualTags + EquipmentTags are filtered). + [Fact] + public void ParseComposition_scoped_keeps_only_my_clusters_scripted_alarms() + { + var blob = BlobOf(new + { + Clusters = new[] { new { ClusterId = "MAIN" }, new { ClusterId = "SITE-A" } }, + Nodes = new[] + { + new { NodeId = "central-1:4053", ClusterId = "MAIN" }, + new { NodeId = "site-a-1:4053", ClusterId = "SITE-A" }, + }, + DriverInstances = new[] + { + new { DriverInstanceId = "main-modbus", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "MAIN", NamespaceId = "main-ns" }, + new { DriverInstanceId = "sa-modbus", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "SITE-A", NamespaceId = "sa-ns" }, + }, + Equipment = new[] + { + new { EquipmentId = "eq-main", Name = "eqm", UnsLineId = "l1", DriverInstanceId = "main-modbus" }, + new { EquipmentId = "eq-sa", Name = "eqs", UnsLineId = "l2", DriverInstanceId = "sa-modbus" }, + }, + Scripts = new[] + { + new { ScriptId = "scr", SourceCode = "return ctx.GetTag(\"A.X\").Value;" }, + }, + ScriptedAlarms = new[] + { + NewAlarmSnapshot("al-main", "eq-main", "scr"), + NewAlarmSnapshot("al-sa", "eq-sa", "scr"), + }, + }); + + var main = DeploymentArtifact.ParseComposition(blob, "central-1:4053"); + main.EquipmentScriptedAlarms.Select(a => a.ScriptedAlarmId).ShouldBe(new[] { "al-main" }); + + var siteA = DeploymentArtifact.ParseComposition(blob, "site-a-1:4053"); + siteA.EquipmentScriptedAlarms.Select(a => a.ScriptedAlarmId).ShouldBe(new[] { "al-sa" }); + } + + /// An alarm whose PredicateScriptId matches no Script row is SKIPPED on BOTH the composer + /// and the artifact-decode side, so parity holds for the surviving alarm. + [Fact] + public void ParseComposition_skips_alarm_with_missing_predicate_script_matching_composer() + { + 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", + HistorizeToAveva = true, + Retain = true, + Enabled = true, + }; + var orphanAlarm = new ScriptedAlarm + { + ScriptedAlarmId = "al-orphan", + EquipmentId = "eq-2", + Name = "Orphan", + AlarmType = "AlarmCondition", + Severity = 500, + MessageTemplate = "orphan", + PredicateScriptId = "s-does-not-exist", + HistorizeToAveva = true, + Retain = true, + Enabled = true, + }; + + var composed = Phase7Composer.Compose( + Array.Empty(), Array.Empty(), Array.Empty(), + Array.Empty(), new[] { goodAlarm, orphanAlarm }, + Array.Empty(), Array.Empty(), + scripts: new[] { goodScript }); + + var blob = BlobOf(new + { + ScriptedAlarms = new[] { ToSnapshot(goodAlarm), ToSnapshot(orphanAlarm) }, + Scripts = new[] { new { ScriptId = goodScript.ScriptId, SourceCode = goodScript.SourceCode } }, + }); + + var decoded = DeploymentArtifact.ParseComposition(blob); + + // The orphan is dropped on both sides; the survivor's plan is byte-identical. + var composedPlan = composed.EquipmentScriptedAlarms.ShouldHaveSingleItem(); + composedPlan.ScriptedAlarmId.ShouldBe("al-ok"); + var decodedPlan = decoded.EquipmentScriptedAlarms.ShouldHaveSingleItem(); + decodedPlan.ScriptedAlarmId.ShouldBe("al-ok"); + decodedPlan.ShouldBe(composedPlan); + } + + // The full Pascal-case snapshot a ScriptedAlarm EF entity serialises to (matches ConfigComposer). + private static object ToSnapshot(ScriptedAlarm a) => new + { + a.ScriptedAlarmId, + a.EquipmentId, + a.Name, + a.AlarmType, + a.Severity, + a.MessageTemplate, + a.PredicateScriptId, + a.HistorizeToAveva, + a.Retain, + a.Enabled, + }; + + private static object NewAlarmSnapshot(string id, string equipmentId, string scriptId) => new + { + ScriptedAlarmId = id, + EquipmentId = equipmentId, + Name = id, + AlarmType = "AlarmCondition", + Severity = 500, + MessageTemplate = $"{id} alarm", + PredicateScriptId = scriptId, + HistorizeToAveva = true, + Retain = true, + Enabled = true, + }; +}