feat(opcua): materialise Equipment VirtualTag variables on rebuild

This commit is contained in:
Joseph Doherty
2026-06-07 05:22:22 -04:00
parent 9818d0cba8
commit 695e61dedf
3 changed files with 106 additions and 0 deletions
@@ -238,6 +238,53 @@ public sealed class Phase7Applier
composition.EquipmentTags.Select(t => t.EquipmentId).Distinct(StringComparer.Ordinal).Count());
}
/// <summary>
/// Materialise Equipment-namespace VirtualTags from a composition snapshot — the VirtualTag
/// analogue of <see cref="MaterialiseEquipmentTags"/>. For each <see cref="EquipmentVirtualTagPlan"/>,
/// ensure its optional <c>FolderPath</c> sub-folder under the existing equipment folder (in
/// practice <c>FolderPath</c> is empty for VirtualTags, so this is usually a no-op), then ensure
/// a Variable inside it. Like the tag pass, the variable's NodeId is FOLDER-SCOPED
/// (<c>parent/Name</c>) — NOT the <see cref="EquipmentVirtualTagPlan.VirtualTagId"/> or
/// <see cref="EquipmentVirtualTagPlan.Expression"/> — so identically-named VirtualTags on
/// different equipments never collide in the sink (which keys on NodeId). Variables start
/// BadWaitingForInitialData; <c>VirtualTagActor</c> fills live values in a later milestone.
/// Idempotent (per-variable idempotency relies on the sink's own <c>EnsureVariable</c>).
/// </summary>
/// <param name="composition">The composition result containing the equipment VirtualTags to materialise.</param>
public void MaterialiseEquipmentVirtualTags(Phase7CompositionResult composition)
{
ArgumentNullException.ThrowIfNull(composition);
if (composition.EquipmentVirtualTags.Count == 0) return;
// Sub-folders first — a VirtualTag's FolderPath becomes one folder UNDER its equipment folder
// (deduped per distinct equipment+path). VirtualTags with no FolderPath hang directly under the
// equipment folder, which MaterialiseHierarchy already created (never re-create it here).
var foldersCreated = new HashSet<string>(StringComparer.Ordinal);
foreach (var v in composition.EquipmentVirtualTags)
{
if (string.IsNullOrWhiteSpace(v.FolderPath)) continue;
var folderNodeId = EquipmentSubFolderNodeId(v.EquipmentId, v.FolderPath);
if (!foldersCreated.Add(folderNodeId)) continue;
SafeEnsureFolder(folderNodeId, parentNodeId: v.EquipmentId, displayName: v.FolderPath);
}
// Variables: NodeId is FOLDER-SCOPED ("<parent>/<Name>"), mirroring the equipment-tag pass.
// Parent is the FolderPath sub-folder when set, else the equipment folder directly.
foreach (var v in composition.EquipmentVirtualTags)
{
var parent = string.IsNullOrWhiteSpace(v.FolderPath)
? v.EquipmentId
: EquipmentSubFolderNodeId(v.EquipmentId, v.FolderPath);
var nodeId = $"{parent}/{v.Name}";
SafeEnsureVariable(nodeId, parent, v.Name, v.DataType);
}
_logger.LogInformation(
"Phase7Applier: equipment virtualtags materialised (vtags={Vtags}, equipment={Equipment})",
composition.EquipmentVirtualTags.Count,
composition.EquipmentVirtualTags.Select(v => v.EquipmentId).Distinct(StringComparer.Ordinal).Count());
}
/// <summary>Deterministic NodeId for a tag's FolderPath sub-folder, scoped under its equipment
/// folder so two equipments' identically-named sub-folders never collide.</summary>
private static string EquipmentSubFolderNodeId(string equipmentId, string folderPath) =>
@@ -237,6 +237,11 @@ public sealed class OpcUaPublishActor : ReceiveActor
// clients can browse them. Live values arrive in a later milestone; until then the
// variables show BadWaitingForInitialData.
_applier.MaterialiseEquipmentTags(composition);
// Equipment-namespace VirtualTags get their own pass right after the equipment tags:
// ensures each computed signal's Variable (and any FolderPath sub-folder) exists under its
// equipment folder with a folder-scoped NodeId. The VirtualTagActor fills live values in a
// later milestone; until then the variables show BadWaitingForInitialData (same as tags).
_applier.MaterialiseEquipmentVirtualTags(composition);
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "rebuild"));
_log.Info("OpcUaPublish: applied rebuild (correlation={Correlation}, added={Added}, removed={Removed}, changed={Changed}, rebuild={Rebuild})",