feat(scripted-alarms): DeploymentArtifact byte-parity for the alarm plan (T6)

This commit is contained in:
Joseph Doherty
2026-06-10 14:41:46 -04:00
parent 55101baaa4
commit 8e8ca9efe8
4 changed files with 376 additions and 45 deletions
@@ -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(@"(?<!\{)\{([^{}]+)\}(?!\})", RegexOptions.Compiled);
// ctx.GetTag("…") OR ctx.SetVirtualTag("…", …) — first string-literal arg captured in
// three parts (prefix, content, closing quote) so token substitution touches ONLY the
// literal content (never a comment, Logger string, or other code).
@@ -92,4 +98,42 @@ public static class EquipmentScriptPaths
}
return result;
}
/// <summary>
/// Merge a scripted alarm's dependency graph: the predicate script's distinct
/// <c>ctx.GetTag("…")</c> read refs (via <see cref="ExtractDependencyRefs"/>, first-seen order)
/// UNION the distinct <c>{TagPath}</c> token paths referenced in the message template (first-seen
/// order, appended after the predicate reads, trimmed + non-empty). The reserved
/// <c>{{equip}}</c> double-brace form is excluded by the token regex. Deterministic so the live
/// composer (<c>Phase7Composer</c>) and the artifact-decode mirror (<c>DeploymentArtifact</c>)
/// produce the exact same ordered list — the byte-parity contract <c>EquipmentScriptedAlarmPlan</c>
/// equality depends on. Scripted alarms do NOT use <c>{{equip}}</c> substitution (only virtual
/// tags do) — pass the predicate source as-is.
/// </summary>
/// <param name="predicateSource">The resolved predicate script source.</param>
/// <param name="messageTemplate">The alarm message template carrying <c>{TagPath}</c> tokens.</param>
/// <returns>The merged, distinct, deterministically-ordered dependency refs.</returns>
public static IReadOnlyList<string> ExtractAlarmDependencyRefs(string? predicateSource, string? messageTemplate)
{
var seen = new HashSet<string>(StringComparer.Ordinal);
var result = new List<string>();
// 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;
}
}
@@ -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 <see cref="EquipmentVirtualTagPlan"/>).</summary>
/// <remarks>
/// <b>DependencyRefs equality is order-sensitive</b> (SequenceEqual).
/// <see cref="Phase7Composer.MergeAlarmDependencyRefs"/> is the canonical, deterministic
/// <see cref="EquipmentScriptPaths.ExtractAlarmDependencyRefs"/> is the canonical, deterministic
/// producer of that order (predicate <c>ctx.GetTag</c> 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 <c>MergeAlarmDependencyRefs</c> with identical inputs.
/// mirror depends on both sides calling <c>ExtractAlarmDependencyRefs</c> with identical inputs.
/// </remarks>
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(@"(?<!\{)\{([^{}]+)\}(?!\})", RegexOptions.Compiled);
/// <summary>
/// Merge the alarm's dependency graph: the predicate script's distinct <c>ctx.GetTag("…")</c>
/// read refs (first-seen order) UNION the distinct <c>{TagPath}</c> token paths referenced in
/// the message template (first-seen order, appended after the predicate reads). The reserved
/// <c>{{equip}}</c> double-brace form is excluded by the token regex. Deterministic so the
/// artifact-decode mirror (sibling task) can reproduce the exact same ordered list.
/// </summary>
/// <param name="predicateSource">The resolved predicate script source.</param>
/// <param name="messageTemplate">The alarm message template carrying <c>{TagPath}</c> tokens.</param>
/// <returns>The merged, distinct, deterministically-ordered dependency refs.</returns>
private static IReadOnlyList<string> MergeAlarmDependencyRefs(string predicateSource, string? messageTemplate)
{
var seen = new HashSet<string>(StringComparer.Ordinal);
var result = new List<string>();
// 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;
}
/// <summary>
/// Extract the driver-side full reference from a <see cref="Tag.TagConfig"/> JSON blob: the
/// <c>CK_Tag_TagConfig_IsJson</c> constraint guarantees a JSON object, and every shipped
@@ -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;
}
/// <summary>
/// Join the artifact's ScriptedAlarms array to its Scripts array (by PredicateScriptId) to emit
/// one <see cref="EquipmentScriptedAlarmPlan"/> per alarm. The artifact-decode mirror of
/// <c>Phase7Composer.Compose</c>'s scripted-alarm producer — so the compose-side + artifact-decode
/// plans agree byte-for-byte. An alarm whose <c>PredicateScriptId</c> has no matching Script row is
/// SKIPPED (matching the composer's skip behaviour) to preserve parity. <c>PredicateSource</c> = the
/// joined script source ("" when missing — but such alarms are skipped above); <c>DependencyRefs</c>
/// = the shared <see cref="EquipmentScriptPaths.ExtractAlarmDependencyRefs"/> merge of the predicate's
/// distinct <c>ctx.GetTag("…")</c> reads UNION the message template's <c>{TagPath}</c> tokens. Scripted
/// alarms do NOT use <c>{{equip}}</c> substitution (only virtual tags do) — the predicate source is
/// used as-is. Ordered by EquipmentId then ScriptedAlarmId to match the composer's deterministic order.
/// </summary>
private static IReadOnlyList<EquipmentScriptedAlarmPlan> BuildEquipmentScriptedAlarmPlans(JsonElement root)
{
if (!root.TryGetProperty("ScriptedAlarms", out var alarmsArr) || alarmsArr.ValueKind != JsonValueKind.Array)
return Array.Empty<EquipmentScriptedAlarmPlan>();
// 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<string, string>(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<EquipmentScriptedAlarmPlan>(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;
}
/// <summary>
/// Extract the driver-side full reference from a tag's TagConfig JSON (top-level "FullName"
/// field). The artifact-decode mirror of <c>Phase7Composer.ExtractTagFullName</c> /
@@ -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;
/// <summary>
/// Byte-parity tests for the scripted-alarm deployment-artifact decode path
/// (<c>DeploymentArtifact.BuildEquipmentScriptedAlarmPlans</c>) against the live compose seam
/// (<c>Phase7Composer.Compose</c>). Both sides derive <c>EquipmentScriptedAlarmPlan</c> from the
/// same ScriptedAlarm + Script data via the shared <c>EquipmentScriptPaths.ExtractAlarmDependencyRefs</c>
/// 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 <c>DeploymentArtifactEquipTokenTests</c>.
/// </summary>
public sealed class DeploymentArtifactScriptedAlarmParityTests
{
private static byte[] BlobOf(object snapshot) => JsonSerializer.SerializeToUtf8Bytes(snapshot);
/// <summary>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.</summary>
[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<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
Array.Empty<DriverInstance>(), new[] { alarm1, alarm2 },
Array.Empty<Tag>(), Array.Empty<Namespace>(),
virtualTags: Array.Empty<VirtualTag>(),
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();
}
/// <summary>The cluster-scoped overload keeps only the scripted alarms whose EquipmentId belongs to
/// an in-cluster driver (mirroring how EquipmentVirtualTags + EquipmentTags are filtered).</summary>
[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" });
}
/// <summary>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.</summary>
[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<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
Array.Empty<DriverInstance>(), new[] { goodAlarm, orphanAlarm },
Array.Empty<Tag>(), Array.Empty<Namespace>(),
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,
};
}