feat(v3-batch4-wp1): composer/planner emit both Raw + UNS subtrees

Un-darken the address-space composition for the v3 dual namespace.

Composer (AddressSpaceComposer):
- Raw subtree: RawContainerNode (Folder/Driver/Device/TagGroup as
  Object/Folder nodes, keyed s=<RawPath>, parent-before-child) + RawTagPlan
  (raw tag Variables keyed (realm=Raw, s=<RawPath>) carrying DataType /
  writable / historize+historian tagname / array shape). Native-alarm intent
  attaches at the RAW tag (ConditionId = RawPath) with the list of referencing
  equipment UNS paths (one alarm at the raw tag, not one per equipment).
- UNS subtree: UnsReferenceVariable per UnsTagReference, keyed
  (realm=Uns, s=<Area>/<Line>/<Equipment>/<EffectiveName>), carrying its
  backing RawPath (Organizes UNS->Raw target + fan-out) and inheriting
  DataType/AccessLevel from the backing raw tag. Effective name =
  DisplayNameOverride else backing raw Tag.Name.
- All RawPaths flow through one shared RawTopology (RawPathResolver +
  memoised container paths), byte-parity with EquipmentReferenceMap.
- Every new node carries an AddressSpaceRealm (so WP3's applier picks the
  namespace at the call site). Additive/defaulted model changes only — the
  un-migrated AddressSpaceApplier still compiles.

Planner (AddressSpacePlanner.Compute) + AddressSpacePlan:
- Per-realm diff sets: RawContainers + RawTags keyed by RawPath (NodeId) so a
  rename = remove+add; UnsReferenceVariables keyed by the stable
  UnsTagReferenceId so a backing-tag rename re-points (BackingRawPath moves,
  NodeId stable) and a display-name-override edit is UNS-only.
- PINNED: raw-tag rename = remove+add in Raw AND re-point in UNS.

Classifier folds the new sets in (adds->PureAdd, removes->PureRemove,
changed->Rebuild safe default).

Tests: AddressSpaceComposerDualNamespaceTests (7),
AddressSpacePlannerDualNamespaceTests (8, incl. the rename pin),
AddressSpaceChangeClassifierDualNamespaceTests (8); reactivated
EquipmentNamespaceMaterializationTests' Batch-4-pending case, re-authored to
drive the composer over the seeded config (the artifact-decode seam is WP3).

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
This commit is contained in:
Joseph Doherty
2026-07-16 09:30:12 -04:00
parent a1a56e22bb
commit f4f3e17e3e
7 changed files with 1253 additions and 84 deletions
@@ -1,4 +1,5 @@
using System.Diagnostics;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
@@ -53,8 +54,163 @@ public sealed record AddressSpaceComposition(
/// constructor + call site keeps compiling unchanged.
/// </summary>
public IReadOnlyList<EquipmentScriptedAlarmPlan> EquipmentScriptedAlarms { get; init; } = Array.Empty<EquipmentScriptedAlarmPlan>();
// ----- v3 Batch 4 dual-namespace address space (un-darkened) -----
/// <summary>
/// The Raw subtree's <b>container</b> nodes (Folder → Driver → Device → TagGroup) as
/// <c>Object</c>/<c>Folder</c> nodes in the <see cref="AddressSpaceRealm.Raw"/> namespace. Each
/// carries its <c>s=&lt;RawPath&gt;</c> NodeId and its parent NodeId (<see langword="null"/> = cluster
/// root) so <c>AddressSpaceApplier</c> can materialise parents-before-children. Sorted by NodeId
/// (ordinal, so a parent precedes each child). Init-only, defaults empty so every existing constructor
/// + call site keeps compiling unchanged.
/// </summary>
public IReadOnlyList<RawContainerNode> RawContainers { get; init; } = Array.Empty<RawContainerNode>();
/// <summary>
/// The Raw subtree's <b>tag</b> Variable nodes — one per raw <see cref="Tag"/> — keyed
/// <c>(realm=Raw, s=&lt;RawPath&gt;)</c>. The RawPath IS the node identity + the single value source
/// the driver binds; every referencing UNS variable fans out from it. Carries DataType / writable /
/// historize + array shape, the optional native-alarm intent (attached at the RAW tag —
/// ConditionId = RawPath), and the list of referencing equipment UNS folder paths (so WP4 wires the
/// multi-notifier alarm). Init-only, defaults empty.
/// </summary>
public IReadOnlyList<RawTagPlan> RawTags { get; init; } = Array.Empty<RawTagPlan>();
/// <summary>
/// The UNS subtree's reference Variable nodes — one per <see cref="UnsTagReference"/> — keyed
/// <c>(realm=Uns, s=&lt;Area&gt;/&lt;Line&gt;/&lt;Equipment&gt;/&lt;EffectiveName&gt;)</c>. Each carries
/// its backing raw tag's RawPath (the <c>Organizes</c> UNS→Raw target + the fan-out source) plus the
/// inherited DataType / writable from that raw tag. UNS nodes never bind a driver directly — they fan
/// out from the raw node. Init-only, defaults empty.
/// </summary>
public IReadOnlyList<UnsReferenceVariable> UnsReferenceVariables { get; init; } = Array.Empty<UnsReferenceVariable>();
}
/// <summary>Which Raw-tree container a <see cref="RawContainerNode"/> is (all are <c>Object</c>/<c>Folder</c>
/// nodes; the kind lets the applier pick the OPC UA type + reference).</summary>
public enum RawNodeKind
{
/// <summary>A driver-organizing <see cref="RawFolder"/>.</summary>
Folder,
/// <summary>A <see cref="DriverInstance"/>.</summary>
Driver,
/// <summary>A <see cref="Device"/>.</summary>
Device,
/// <summary>A tag-organizing <see cref="TagGroup"/>.</summary>
TagGroup,
}
/// <summary>
/// One Raw-subtree container node (Folder / Driver / Device / TagGroup). <see cref="NodeId"/> is its
/// <c>RawPath</c> (== the <c>ns=Raw</c> <c>s=</c> id); <see cref="ParentNodeId"/> is the parent container's
/// RawPath (<see langword="null"/> = directly under the cluster root). <see cref="Realm"/> is always
/// <see cref="AddressSpaceRealm.Raw"/> — carried explicitly so the applier chooses the namespace at the
/// call site rather than inferring it.
/// </summary>
/// <param name="NodeId">The container's RawPath (its <c>ns=Raw</c> <c>s=</c> identifier).</param>
/// <param name="ParentNodeId">The parent container's RawPath, or <see langword="null"/> for a cluster-root node.</param>
/// <param name="DisplayName">The friendly browse name (the container's leaf <c>Name</c>).</param>
/// <param name="Kind">Which container this is.</param>
/// <param name="Realm">The address-space realm (always <see cref="AddressSpaceRealm.Raw"/>).</param>
public sealed record RawContainerNode(
string NodeId,
string? ParentNodeId,
string DisplayName,
RawNodeKind Kind,
AddressSpaceRealm Realm = AddressSpaceRealm.Raw);
/// <summary>
/// One Raw-subtree tag Variable node. <see cref="NodeId"/> is the tag's <c>RawPath</c> — the single
/// identity string at every seam (the <c>ns=Raw</c> <c>s=</c> id, the driver fan-out/write key, the
/// historian default tagname, the native-alarm <c>ConditionId</c>). <see cref="ParentNodeId"/> is the
/// owning TagGroup's RawPath (or the Device's when the tag sits directly under the device).
/// <see cref="Writable"/> mirrors <c>Tag.AccessLevel == ReadWrite</c>. <see cref="Alarm"/> /
/// <see cref="IsHistorized"/> / <see cref="HistorianTagname"/> / <see cref="IsArray"/> /
/// <see cref="ArrayLength"/> are parsed from <c>Tag.TagConfig</c> via
/// <see cref="TagConfigIntent"/> (the single byte-parity authority). <see cref="ReferencingEquipmentPaths"/>
/// is the (possibly empty) set of UNS equipment-folder paths (<c>Area/Line/Equipment</c>) that reference
/// this tag — WP4 wires one notifier per path so the raw tag's single alarm condition fans to every
/// referencing equipment. <see cref="Realm"/> is always <see cref="AddressSpaceRealm.Raw"/>.
/// A null <see cref="HistorianTagname"/> means the historian tagname defaults to the RawPath (resolved
/// later, not here).
/// </summary>
public sealed record RawTagPlan(
string TagId,
string NodeId,
string? ParentNodeId,
string DriverInstanceId,
string Name,
string DataType,
bool Writable,
EquipmentTagAlarmInfo? Alarm,
IReadOnlyList<string> ReferencingEquipmentPaths,
bool IsHistorized = false,
string? HistorianTagname = null,
bool IsArray = false,
uint? ArrayLength = null,
AddressSpaceRealm Realm = AddressSpaceRealm.Raw)
{
/// <inheritdoc />
// Structural equality: the auto-generated record equality compares ReferencingEquipmentPaths (an
// interface-typed list) BY REFERENCE, which would flag every raw tag as "changed" on every parse
// (fresh list instances). Compare it element-wise so a no-op redeploy diffs empty (mirrors
// EquipmentVirtualTagPlan).
public bool Equals(RawTagPlan? other) =>
other is not null &&
TagId == other.TagId &&
NodeId == other.NodeId &&
ParentNodeId == other.ParentNodeId &&
DriverInstanceId == other.DriverInstanceId &&
Name == other.Name &&
DataType == other.DataType &&
Writable == other.Writable &&
EqualityComparer<EquipmentTagAlarmInfo?>.Default.Equals(Alarm, other.Alarm) &&
IsHistorized == other.IsHistorized &&
HistorianTagname == other.HistorianTagname &&
IsArray == other.IsArray &&
ArrayLength == other.ArrayLength &&
Realm == other.Realm &&
ReferencingEquipmentPaths.SequenceEqual(other.ReferencingEquipmentPaths, StringComparer.Ordinal);
/// <inheritdoc />
public override int GetHashCode()
{
var hash = new HashCode();
hash.Add(TagId); hash.Add(NodeId); hash.Add(ParentNodeId); hash.Add(DriverInstanceId);
hash.Add(Name); hash.Add(DataType); hash.Add(Writable); hash.Add(Alarm);
hash.Add(IsHistorized); hash.Add(HistorianTagname); hash.Add(IsArray); hash.Add(ArrayLength);
hash.Add(Realm);
foreach (var p in ReferencingEquipmentPaths) hash.Add(p, StringComparer.Ordinal);
return hash.ToHashCode();
}
}
/// <summary>
/// One UNS-subtree reference Variable node projected from a <see cref="UnsTagReference"/>.
/// <see cref="NodeId"/> is the slash-joined UNS path <c>Area/Line/Equipment/EffectiveName</c> (the
/// <c>ns=UNS</c> <c>s=</c> id); <see cref="EffectiveName"/> is <c>DisplayNameOverride</c> else the backing
/// raw tag's <c>Name</c>. <see cref="BackingRawPath"/> is the backing raw tag's RawPath — the
/// <c>Organizes</c> UNS→Raw reference target AND the fan-out value source (the UNS node binds no driver;
/// it mirrors the raw node). <see cref="DataType"/> / <see cref="Writable"/> are inherited from the
/// backing raw tag. <see cref="UnsTagReferenceId"/> is the stable diff identity (a display-name-override
/// edit keeps the same id while the NodeId/effective-name changes; a backing-tag rename keeps the same id
/// + NodeId while <see cref="BackingRawPath"/> re-points). <see cref="Realm"/> is always
/// <see cref="AddressSpaceRealm.Uns"/>.
/// </summary>
public sealed record UnsReferenceVariable(
string UnsTagReferenceId,
string EquipmentId,
string NodeId,
string EffectiveName,
string BackingRawPath,
string DataType,
bool Writable,
AddressSpaceRealm Realm = AddressSpaceRealm.Uns);
public sealed record UnsAreaProjection(string UnsAreaId, string DisplayName);
public sealed record UnsLineProjection(string UnsLineId, string UnsAreaId, string DisplayName);
@@ -301,12 +457,17 @@ public static class AddressSpaceComposer
/// <summary>
/// Composes the address space build plan from the v3 configuration entities.
/// <para><b>v3 DARK address space (Batch 1):</b> equipment-tag variable nodes do NOT materialize —
/// <c>EquipmentTags</c> is deliberately empty (the raw + UNS variable nodes are lit up in Batch 4's
/// dual namespace). The composer carries the UNS folder hierarchy (Area/Line/Equipment) +
/// per-equipment VirtualTags + ScriptedAlarms only. Equipment no longer binds a driver/device, so
/// <see cref="EquipmentNode"/>'s driver/device hooks are always null. This is the pure reference
/// mirror of <c>DeploymentArtifact.ParseComposition</c> (byte-parity target for the corpus tests).</para>
/// <para><b>v3 dual namespace (Batch 4):</b> the composer emits BOTH subtrees. The
/// <see cref="AddressSpaceComposition.RawContainers"/> + <see cref="AddressSpaceComposition.RawTags"/>
/// light up the device-oriented <c>ns=Raw</c> tree (Folder→Driver→Device→TagGroup→Tag, each tag keyed
/// <c>s=&lt;RawPath&gt;</c>); the <see cref="AddressSpaceComposition.UnsReferenceVariables"/> light up
/// the equipment-oriented <c>ns=UNS</c> tree (one Variable per <see cref="UnsTagReference"/>, keyed
/// <c>s=&lt;Area&gt;/&lt;Line&gt;/&lt;Equipment&gt;/&lt;EffectiveName&gt;</c>, carrying its backing RawPath
/// for the <c>Organizes</c> reference + fan-out). Native-alarm intents attach at the RAW tag
/// (ConditionId = RawPath) with the list of referencing equipment paths. The legacy
/// <see cref="AddressSpaceComposition.EquipmentTags"/> set stays EMPTY (the retired
/// equipment-namespace tag path); UNS folders + per-equipment VirtualTags + ScriptedAlarms are
/// unchanged.</para>
/// </summary>
/// <param name="unsAreas">The UNS areas.</param>
/// <param name="unsLines">The UNS lines.</param>
@@ -366,15 +527,19 @@ public static class AddressSpaceComposer
.Select(a => new ScriptedAlarmPlan(a.ScriptedAlarmId, a.EquipmentId, a.PredicateScriptId, a.MessageTemplate))
.ToList();
// v3 DARK: no equipment-tag variable plans this batch (Batch 4 owns UNS↔Raw fan-out).
// v3: the retired equipment-namespace tag path stays empty — raw + UNS variable nodes now carry
// every value (see RawTags / UnsReferenceVariables below).
var equipmentTags = Array.Empty<EquipmentTagPlan>();
// Per-equipment {{equip}}/<RefName> reference map (effectiveName → backing-tag RawPath), built
// from the equipment's UnsTagReferences + the raw tags + the raw topology via the shared
// RawPathResolver — the same identity authority the artifact-decode mirror + the draft validator
// use, so the three agree byte-for-byte on the resolved RawPath.
var referenceMapByEquip = BuildEquipmentReferenceMap(
unsTagReferences, tags, rawFolders, driverInstances, devices, tagGroups);
// The shared raw topology (RawPathResolver over the folder/driver/device/group ancestry + the
// container RawPath maps). One authority for the Raw subtree emit, the UNS backing RawPaths, and the
// {{equip}} reference map — so the three agree byte-for-byte on every RawPath.
var topology = RawTopology.Build(rawFolders, driverInstances, devices, tagGroups);
// Per-equipment {{equip}}/<RefName> reference map (effectiveName → backing-tag RawPath), built via
// the shared EquipmentReferenceMap over the SAME resolver — the same identity authority the
// artifact-decode mirror + the draft validator use.
var referenceMapByEquip = BuildEquipmentReferenceMap(unsTagReferences, tags, topology.Resolver);
var emptyRefMap = (IReadOnlyDictionary<string, string>)
new Dictionary<string, string>(StringComparer.Ordinal);
@@ -451,45 +616,38 @@ public static class AddressSpaceComposer
Enabled: a.Enabled));
}
// Raw subtree (device-oriented) + UNS subtree (equipment-oriented) — the Batch-4 dual namespace.
var (rawContainers, rawTags) = BuildRawSubtree(
rawFolders, driverInstances, devices, tagGroups, tags, unsTagReferences,
unsAreas, unsLines, equipment, topology);
var unsReferenceVariables = BuildUnsReferenceVariables(
unsTagReferences, tags, unsAreas, unsLines, equipment, topology);
return new AddressSpaceComposition(areas, lines, nodes, plans, alarms)
{
EquipmentTags = equipmentTags,
EquipmentVirtualTags = equipmentVirtualTags,
EquipmentScriptedAlarms = equipmentScriptedAlarms,
RawContainers = rawContainers,
RawTags = rawTags,
UnsReferenceVariables = unsReferenceVariables,
};
}
/// <summary>Build the per-equipment <c>{{equip}}/&lt;RefName&gt;</c> reference map from the v3 entities
/// via the shared <see cref="EquipmentReferenceMap"/> + <see cref="RawPathResolver"/> — the entity-side
/// mirror of <c>DeploymentArtifact.BuildEquipmentReferenceMap</c> (JSON side). Empty when no references
/// are supplied (every test / earlier caller that omits the reference data).</summary>
/// via the shared <see cref="EquipmentReferenceMap"/> over the SHARED <paramref name="resolver"/> — the
/// entity-side mirror of <c>DeploymentArtifact.BuildEquipmentReferenceMap</c> (JSON side). Empty when no
/// references are supplied (every test / earlier caller that omits the reference data).</summary>
private static IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>> BuildEquipmentReferenceMap(
IReadOnlyList<UnsTagReference>? unsTagReferences,
IReadOnlyList<Tag>? tags,
IReadOnlyList<RawFolder>? rawFolders,
IReadOnlyList<DriverInstance> driverInstances,
IReadOnlyList<Device>? devices,
IReadOnlyList<TagGroup>? tagGroups)
RawPathResolver resolver)
{
var refs = unsTagReferences ?? Array.Empty<UnsTagReference>();
var tagRows = tags ?? Array.Empty<Tag>();
if (refs.Count == 0 || tagRows.Count == 0)
return new Dictionary<string, IReadOnlyDictionary<string, string>>(StringComparer.Ordinal);
var folders = (rawFolders ?? Array.Empty<RawFolder>())
.GroupBy(f => f.RawFolderId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => ((string?)g.First().ParentRawFolderId, g.First().Name), StringComparer.Ordinal);
var drivers = driverInstances
.GroupBy(d => d.DriverInstanceId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => ((string?)g.First().RawFolderId, g.First().Name), StringComparer.Ordinal);
var deviceMap = (devices ?? Array.Empty<Device>())
.GroupBy(d => d.DeviceId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => (g.First().DriverInstanceId, g.First().Name), StringComparer.Ordinal);
var groups = (tagGroups ?? Array.Empty<TagGroup>())
.GroupBy(t => t.TagGroupId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => ((string?)g.First().ParentTagGroupId, g.First().Name), StringComparer.Ordinal);
var resolver = new RawPathResolver(folders, drivers, deviceMap, groups);
var tagsById = tagRows
.GroupBy(t => t.TagId, StringComparer.Ordinal)
.ToDictionary(
@@ -503,4 +661,363 @@ public static class AddressSpaceComposer
tagsById,
resolver);
}
/// <summary>
/// Emit the Raw subtree: the container nodes (Folder→Driver→Device→TagGroup) as
/// <c>Object</c>/<c>Folder</c> nodes, and the tag Variable nodes keyed <c>(realm=Raw, s=RawPath)</c>.
/// A node whose RawPath cannot be resolved (broken/unknown ancestry, or an invalid segment) is
/// SKIPPED rather than faulting the whole compose (mirrors <see cref="RawPathResolver"/>'s
/// null-not-throw policy). Both lists are sorted by NodeId ordinal so a parent precedes each child.
/// </summary>
private static (IReadOnlyList<RawContainerNode> Containers, IReadOnlyList<RawTagPlan> Tags) BuildRawSubtree(
IReadOnlyList<RawFolder>? rawFolders,
IReadOnlyList<DriverInstance> driverInstances,
IReadOnlyList<Device>? devices,
IReadOnlyList<TagGroup>? tagGroups,
IReadOnlyList<Tag>? tags,
IReadOnlyList<UnsTagReference>? unsTagReferences,
IReadOnlyList<UnsArea> unsAreas,
IReadOnlyList<UnsLine> unsLines,
IReadOnlyList<Equipment> equipment,
RawTopology topology)
{
var containers = new List<RawContainerNode>();
foreach (var f in rawFolders ?? Array.Empty<RawFolder>())
{
if (!topology.FolderPaths.TryGetValue(f.RawFolderId, out var path)) continue;
var parent = f.ParentRawFolderId is not null
? topology.FolderPaths.GetValueOrDefault(f.ParentRawFolderId)
: null;
containers.Add(new RawContainerNode(path, parent, f.Name, RawNodeKind.Folder));
}
foreach (var d in driverInstances)
{
if (!topology.DriverPaths.TryGetValue(d.DriverInstanceId, out var path)) continue;
var parent = d.RawFolderId is not null
? topology.FolderPaths.GetValueOrDefault(d.RawFolderId)
: null;
containers.Add(new RawContainerNode(path, parent, d.Name, RawNodeKind.Driver));
}
foreach (var dev in devices ?? Array.Empty<Device>())
{
if (!topology.DevicePaths.TryGetValue(dev.DeviceId, out var path)) continue;
var parent = topology.DriverPaths.GetValueOrDefault(dev.DriverInstanceId);
containers.Add(new RawContainerNode(path, parent, dev.Name, RawNodeKind.Device));
}
foreach (var g in tagGroups ?? Array.Empty<TagGroup>())
{
if (!topology.GroupPaths.TryGetValue(g.TagGroupId, out var path)) continue;
var parent = g.ParentTagGroupId is not null
? topology.GroupPaths.GetValueOrDefault(g.ParentTagGroupId)
: topology.DevicePaths.GetValueOrDefault(g.DeviceId);
containers.Add(new RawContainerNode(path, parent, g.Name, RawNodeKind.TagGroup));
}
// TagId → the set of referencing equipment UNS folder paths (Area/Line/Equipment), for the
// native-alarm multi-notifier. Distinct + ordinal-sorted for determinism.
var equipPathByTag = BuildReferencingEquipmentPathsByTag(unsTagReferences, unsAreas, unsLines, equipment);
var rawTags = new List<RawTagPlan>();
foreach (var t in tags ?? Array.Empty<Tag>())
{
var nodeId = topology.Resolver.TryBuildTagPath(t.DeviceId, t.TagGroupId, t.Name);
if (nodeId is null) continue;
var parent = t.TagGroupId is not null
? topology.GroupPaths.GetValueOrDefault(t.TagGroupId)
: topology.DevicePaths.GetValueOrDefault(t.DeviceId);
var driverInstanceId = topology.DriverIdByDevice.GetValueOrDefault(t.DeviceId, string.Empty);
var intent = TagConfigIntent.Parse(t.TagConfig);
var alarm = intent.Alarm is { } a
? new EquipmentTagAlarmInfo(a.AlarmType, a.Severity, a.HistorizeToAveva)
: null;
var refEquipPaths = equipPathByTag.GetValueOrDefault(t.TagId, Array.Empty<string>());
rawTags.Add(new RawTagPlan(
TagId: t.TagId,
NodeId: nodeId,
ParentNodeId: parent,
DriverInstanceId: driverInstanceId,
Name: t.Name,
DataType: t.DataType,
Writable: t.AccessLevel == TagAccessLevel.ReadWrite,
Alarm: alarm,
ReferencingEquipmentPaths: refEquipPaths,
IsHistorized: intent.IsHistorized,
HistorianTagname: intent.HistorianTagname,
IsArray: intent.IsArray,
ArrayLength: intent.ArrayLength));
}
containers.Sort((x, y) => string.CompareOrdinal(x.NodeId, y.NodeId));
rawTags.Sort((x, y) => string.CompareOrdinal(x.NodeId, y.NodeId));
return (containers, rawTags);
}
/// <summary>
/// Emit the UNS reference Variables: one per <see cref="UnsTagReference"/> whose backing raw tag +
/// equipment path resolve, keyed <c>(realm=Uns, s=Area/Line/Equipment/EffectiveName)</c> and carrying
/// the backing RawPath (Organizes target + fan-out) plus the inherited DataType / writable. A
/// reference whose backing tag is unknown, whose RawPath is unresolvable, or whose UNS path segments
/// are invalid is SKIPPED. Sorted by NodeId ordinal.
/// </summary>
private static IReadOnlyList<UnsReferenceVariable> BuildUnsReferenceVariables(
IReadOnlyList<UnsTagReference>? unsTagReferences,
IReadOnlyList<Tag>? tags,
IReadOnlyList<UnsArea> unsAreas,
IReadOnlyList<UnsLine> unsLines,
IReadOnlyList<Equipment> equipment,
RawTopology topology)
{
var refs = unsTagReferences ?? Array.Empty<UnsTagReference>();
if (refs.Count == 0) return Array.Empty<UnsReferenceVariable>();
var tagsById = (tags ?? Array.Empty<Tag>())
.GroupBy(t => t.TagId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
var equipFolderPath = BuildEquipmentFolderPaths(unsAreas, unsLines, equipment);
var vars = new List<UnsReferenceVariable>();
foreach (var r in refs.OrderBy(x => x.UnsTagReferenceId, StringComparer.Ordinal))
{
if (!tagsById.TryGetValue(r.TagId, out var tag)) continue;
if (!equipFolderPath.TryGetValue(r.EquipmentId, out var equipPath)) continue;
var rawPath = topology.Resolver.TryBuildTagPath(tag.DeviceId, tag.TagGroupId, tag.Name);
if (rawPath is null) continue;
var effectiveName = string.IsNullOrEmpty(r.DisplayNameOverride) ? tag.Name : r.DisplayNameOverride!;
string nodeId;
try
{
nodeId = V3NodeIds.UnsChild(equipPath, effectiveName);
}
catch (ArgumentException)
{
continue; // invalid effective-name segment — dropped (deploy gate rejects invalid names)
}
vars.Add(new UnsReferenceVariable(
UnsTagReferenceId: r.UnsTagReferenceId,
EquipmentId: r.EquipmentId,
NodeId: nodeId,
EffectiveName: effectiveName,
BackingRawPath: rawPath,
DataType: tag.DataType,
Writable: tag.AccessLevel == TagAccessLevel.ReadWrite));
}
vars.Sort((x, y) => string.CompareOrdinal(x.NodeId, y.NodeId));
return vars;
}
/// <summary>
/// <c>EquipmentId → UNS folder path</c> (<c>Area/Line/Equipment</c>, slash-joined via
/// <see cref="V3NodeIds"/>) for every equipment whose Area/Line ancestry resolves + whose three
/// segments are valid. Equipment with a broken ancestry or an invalid segment is absent.
/// </summary>
private static IReadOnlyDictionary<string, string> BuildEquipmentFolderPaths(
IReadOnlyList<UnsArea> unsAreas,
IReadOnlyList<UnsLine> unsLines,
IReadOnlyList<Equipment> equipment)
{
var areaName = unsAreas
.GroupBy(a => a.UnsAreaId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First().Name, StringComparer.Ordinal);
var line = unsLines
.GroupBy(l => l.UnsLineId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => (g.First().UnsAreaId, g.First().Name), StringComparer.Ordinal);
var byEquip = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var e in equipment)
{
if (!line.TryGetValue(e.UnsLineId, out var ln)) continue;
if (!areaName.TryGetValue(ln.UnsAreaId, out var aName)) continue;
try
{
byEquip[e.EquipmentId] = V3NodeIds.Uns(aName, ln.Name, e.Name);
}
catch (ArgumentException)
{
// invalid Area/Line/Equipment segment — dropped (deploy gate rejects invalid names).
}
}
return byEquip;
}
/// <summary>
/// <c>TagId → distinct, ordinal-sorted list of referencing equipment UNS folder paths</c>
/// (<c>Area/Line/Equipment</c>). Drives the native-alarm multi-notifier (WP4): the raw tag's single
/// alarm condition fans one event to each referencing equipment's UNS folder. A tag with no
/// resolvable reference is absent (⇒ an empty list at the call site).
/// </summary>
private static IReadOnlyDictionary<string, IReadOnlyList<string>> BuildReferencingEquipmentPathsByTag(
IReadOnlyList<UnsTagReference>? unsTagReferences,
IReadOnlyList<UnsArea> unsAreas,
IReadOnlyList<UnsLine> unsLines,
IReadOnlyList<Equipment> equipment)
{
var refs = unsTagReferences ?? Array.Empty<UnsTagReference>();
if (refs.Count == 0) return new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal);
var equipFolderPath = BuildEquipmentFolderPaths(unsAreas, unsLines, equipment);
var byTag = new Dictionary<string, SortedSet<string>>(StringComparer.Ordinal);
foreach (var r in refs)
{
if (!equipFolderPath.TryGetValue(r.EquipmentId, out var equipPath)) continue;
if (!byTag.TryGetValue(r.TagId, out var set))
byTag[r.TagId] = set = new SortedSet<string>(StringComparer.Ordinal);
set.Add(equipPath);
}
return byTag.ToDictionary(
kv => kv.Key,
kv => (IReadOnlyList<string>)kv.Value.ToList(),
StringComparer.Ordinal);
}
/// <summary>
/// The shared Raw-tree topology: a <see cref="RawPathResolver"/> over the folder/driver/device/group
/// ancestry plus the resolved container RawPath maps (memoised) + the device→driver id map. Built
/// once per compose and reused for the Raw subtree, the UNS backing RawPaths, and the <c>{{equip}}</c>
/// reference map so every RawPath is produced by the single authority.
/// </summary>
private sealed class RawTopology
{
public required RawPathResolver Resolver { get; init; }
/// <summary><c>RawFolderId → RawPath</c> (absent when the ancestry is broken).</summary>
public required IReadOnlyDictionary<string, string> FolderPaths { get; init; }
/// <summary><c>DriverInstanceId → RawPath</c> (absent when the ancestry is broken).</summary>
public required IReadOnlyDictionary<string, string> DriverPaths { get; init; }
/// <summary><c>DeviceId → RawPath</c> (absent when the ancestry is broken).</summary>
public required IReadOnlyDictionary<string, string> DevicePaths { get; init; }
/// <summary><c>TagGroupId → RawPath</c> (absent when the ancestry is broken).</summary>
public required IReadOnlyDictionary<string, string> GroupPaths { get; init; }
/// <summary><c>DeviceId → DriverInstanceId</c> — the raw tag's owning driver (for RawTagPlan).</summary>
public required IReadOnlyDictionary<string, string> DriverIdByDevice { get; init; }
public static RawTopology Build(
IReadOnlyList<RawFolder>? rawFolders,
IReadOnlyList<DriverInstance> driverInstances,
IReadOnlyList<Device>? devices,
IReadOnlyList<TagGroup>? tagGroups)
{
var folderRows = (rawFolders ?? Array.Empty<RawFolder>())
.GroupBy(f => f.RawFolderId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => ((string?)g.First().ParentRawFolderId, g.First().Name), StringComparer.Ordinal);
var driverRows = driverInstances
.GroupBy(d => d.DriverInstanceId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => ((string?)g.First().RawFolderId, g.First().Name), StringComparer.Ordinal);
var deviceRows = (devices ?? Array.Empty<Device>())
.GroupBy(d => d.DeviceId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => (g.First().DriverInstanceId, g.First().Name), StringComparer.Ordinal);
var groupRows = (tagGroups ?? Array.Empty<TagGroup>())
.GroupBy(t => t.TagGroupId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => ((string?)g.First().ParentTagGroupId, g.First().Name), StringComparer.Ordinal);
var resolver = new RawPathResolver(folderRows, driverRows, deviceRows, groupRows);
// Container RawPaths — reuse the resolver for driver prefix + memoise folder/device/group chains.
var folderPaths = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var id in folderRows.Keys)
ResolveFolder(id, folderRows, folderPaths, 0);
var driverPaths = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var id in driverRows.Keys)
{
var p = resolver.TryBuildDriverPrefix(id);
if (p is not null) driverPaths[id] = p;
}
var devicePaths = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var (id, dev) in deviceRows)
{
if (!driverPaths.TryGetValue(dev.DriverInstanceId, out var driverPrefix)) continue;
try { devicePaths[id] = RawPaths.Combine(driverPrefix, dev.Name); }
catch (ArgumentException) { /* invalid device name — dropped */ }
}
var groupPaths = new Dictionary<string, string>(StringComparer.Ordinal);
var groupDeviceById = (tagGroups ?? Array.Empty<TagGroup>())
.GroupBy(t => t.TagGroupId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First().DeviceId, StringComparer.Ordinal);
foreach (var id in groupRows.Keys)
ResolveGroup(id, groupRows, groupDeviceById, devicePaths, groupPaths, 0);
var driverIdByDevice = deviceRows.ToDictionary(kv => kv.Key, kv => kv.Value.DriverInstanceId, StringComparer.Ordinal);
return new RawTopology
{
Resolver = resolver,
FolderPaths = folderPaths,
DriverPaths = driverPaths,
DevicePaths = devicePaths,
GroupPaths = groupPaths,
DriverIdByDevice = driverIdByDevice,
};
}
private const int MaxDepth = 256;
private static string? ResolveFolder(
string id,
IReadOnlyDictionary<string, (string? ParentId, string Name)> rows,
Dictionary<string, string> memo,
int depth)
{
if (depth > MaxDepth) return null;
if (memo.TryGetValue(id, out var cached)) return cached;
if (!rows.TryGetValue(id, out var row)) return null;
var parentPath = row.ParentId is null ? null : ResolveFolder(row.ParentId, rows, memo, depth + 1);
if (row.ParentId is not null && parentPath is null) return null; // broken parent chain
try
{
var path = RawPaths.Combine(parentPath, row.Name);
memo[id] = path;
return path;
}
catch (ArgumentException) { return null; }
}
private static string? ResolveGroup(
string id,
IReadOnlyDictionary<string, (string? ParentId, string Name)> rows,
IReadOnlyDictionary<string, string> groupDeviceById,
IReadOnlyDictionary<string, string> devicePaths,
Dictionary<string, string> memo,
int depth)
{
if (depth > MaxDepth) return null;
if (memo.TryGetValue(id, out var cached)) return cached;
if (!rows.TryGetValue(id, out var row)) return null;
string? parentPath;
if (row.ParentId is not null)
{
parentPath = ResolveGroup(row.ParentId, rows, groupDeviceById, devicePaths, memo, depth + 1);
if (parentPath is null) return null; // broken parent chain
}
else
{
// Root group: parent is the owning device's RawPath.
if (!groupDeviceById.TryGetValue(id, out var deviceId)
|| !devicePaths.TryGetValue(deviceId, out parentPath))
return null;
}
try
{
var path = RawPaths.Combine(parentPath, row.Name);
memo[id] = path;
return path;
}
catch (ArgumentException) { return null; }
}
}
}