feat(scripted-alarms): EquipmentScriptedAlarmPlan + Phase7Composer enrichment (T5)

This commit is contained in:
Joseph Doherty
2026-06-10 14:21:28 -04:00
parent 1c96fe0be0
commit b28c6bdb62
2 changed files with 468 additions and 0 deletions
@@ -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(
/// <summary>Equipment-namespace VirtualTags. See <see cref="EquipmentVirtualTagPlan"/>. Init-only,
/// defaults empty so every existing constructor + call site keeps compiling.</summary>
public IReadOnlyList<EquipmentVirtualTagPlan> EquipmentVirtualTags { get; init; } = Array.Empty<EquipmentVirtualTagPlan>();
/// <summary>
/// Per-equipment scripted-alarm host plans — the richer analogue of the thin
/// <see cref="ScriptedAlarmPlans"/> projection. Each carries the fully-resolved predicate
/// source (joined from its <see cref="Script"/>) and the merged dependency graph (predicate
/// <c>ctx.GetTag("…")</c> reads UNION <c>{TagPath}</c> tokens in the message template) so the
/// runtime alarm host can subscribe to every signal and evaluate the predicate. See
/// <see cref="EquipmentScriptedAlarmPlan"/>. Init-only, defaults empty so every existing
/// constructor + call site keeps compiling unchanged.
/// </summary>
public IReadOnlyList<EquipmentScriptedAlarmPlan> EquipmentScriptedAlarms { get; init; } = Array.Empty<EquipmentScriptedAlarmPlan>();
}
public sealed record UnsAreaProjection(string UnsAreaId, string DisplayName);
@@ -149,6 +162,78 @@ public sealed record EquipmentVirtualTagPlan(
}
}
/// <summary>
/// One Equipment-owned scripted alarm from a <see cref="ScriptedAlarm"/> row, joined to its
/// predicate <see cref="Script"/> (by <see cref="PredicateScriptId"/>) for the source. The
/// richer host analogue of the thin <see cref="ScriptedAlarmPlan"/>: the runtime alarm host
/// spawns one Part 9 condition per plan, evaluates <see cref="PredicateSource"/> over
/// <see cref="DependencyRefs"/>, and resolves the <see cref="MessageTemplate"/>'s
/// <c>{TagPath}</c> tokens at emission time. <see cref="DependencyRefs"/> = the distinct
/// <c>ctx.GetTag("…")</c> read literals in the predicate source UNION the distinct
/// <c>{TagPath}</c> token paths referenced in the message template (the reserved
/// <c>{{equip}}</c> double-brace form is excluded). <see cref="Enabled"/> 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 <see cref="EquipmentVirtualTagPlan"/>.
/// </summary>
/// <param name="ScriptedAlarmId">Stable logical id — drives the condition name + diff identity.</param>
/// <param name="EquipmentId">Owning equipment folder the alarm hangs under.</param>
/// <param name="Name">Operator-facing alarm name.</param>
/// <param name="AlarmType">Concrete Part 9 type ("AlarmCondition"/"LimitAlarm"/"OffNormalAlarm"/"DiscreteAlarm").</param>
/// <param name="Severity">Numeric severity 1..1000 per OPC UA Part 9.</param>
/// <param name="MessageTemplate">Template with <c>{TagPath}</c> tokens resolved at emission time.</param>
/// <param name="PredicateScriptId">Logical FK to the predicate script.</param>
/// <param name="PredicateSource">The resolved predicate script source (joined by <paramref name="PredicateScriptId"/>).</param>
/// <param name="DependencyRefs">Distinct predicate read refs UNION message-template token paths.</param>
/// <param name="HistorizeToAveva">When true, transitions route to the Aveva Historian sink.</param>
/// <param name="Retain">OPC UA Part 9 <c>Retain</c> flag.</param>
/// <param name="Enabled">Whether the alarm is enabled — carried for the host to decide on.</param>
public sealed record EquipmentScriptedAlarmPlan(
string ScriptedAlarmId,
string EquipmentId,
string Name,
string AlarmType,
int Severity,
string MessageTemplate,
string PredicateScriptId,
string PredicateSource,
IReadOnlyList<string> DependencyRefs,
bool HistorizeToAveva,
bool Retain,
bool Enabled)
{
/// <summary>Structural equality: the auto-generated record equality would compare
/// <see cref="DependencyRefs"/> (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 <see cref="EquipmentVirtualTagPlan"/>).</summary>
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);
/// <inheritdoc />
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();
}
}
/// <summary>
/// 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(@"(?<!\{)\{([^{}]+)\}(?!\})", 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