From 695e61dedf4c8e02a7fea2ae5e4836b33661ee30 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 7 Jun 2026 05:22:22 -0400 Subject: [PATCH] feat(opcua): materialise Equipment VirtualTag variables on rebuild --- .../Phase7Applier.cs | 47 ++++++++++++++++ .../OpcUa/OpcUaPublishActor.cs | 5 ++ .../Phase7ApplierTests.cs | 54 +++++++++++++++++++ 3 files changed, 106 insertions(+) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs index f66b90f3..fa36c740 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs @@ -238,6 +238,53 @@ public sealed class Phase7Applier composition.EquipmentTags.Select(t => t.EquipmentId).Distinct(StringComparer.Ordinal).Count()); } + /// + /// Materialise Equipment-namespace VirtualTags from a composition snapshot — the VirtualTag + /// analogue of . For each , + /// ensure its optional FolderPath sub-folder under the existing equipment folder (in + /// practice FolderPath 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 + /// (parent/Name) — NOT the or + /// — so identically-named VirtualTags on + /// different equipments never collide in the sink (which keys on NodeId). Variables start + /// BadWaitingForInitialData; VirtualTagActor fills live values in a later milestone. + /// Idempotent (per-variable idempotency relies on the sink's own EnsureVariable). + /// + /// The composition result containing the equipment VirtualTags to materialise. + 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(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 ("/"), 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()); + } + /// Deterministic NodeId for a tag's FolderPath sub-folder, scoped under its equipment /// folder so two equipments' identically-named sub-folders never collide. private static string EquipmentSubFolderNodeId(string equipmentId, string folderPath) => diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs index 3d8a735c..eac0cbe5 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs @@ -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("kind", "rebuild")); _log.Info("OpcUaPublish: applied rebuild (correlation={Correlation}, added={Added}, removed={Removed}, changed={Changed}, rebuild={Rebuild})", diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs index 1136fdba..7e09f541 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs @@ -257,6 +257,60 @@ public sealed class Phase7ApplierTests sink.VariableCalls.ShouldContain(("eq-2/Speed", "eq-2", "Speed", "Float")); } + /// Verifies MaterialiseEquipmentVirtualTags creates one Variable per VirtualTag directly + /// under its existing equipment folder, with a folder-scoped NodeId (EquipmentId/Name — NOT the + /// VirtualTagId or Expression), parent == EquipmentId, displayName == Name, and does NOT re-create + /// the equipment folder (no sub-folder when FolderPath is empty). + [Fact] + public void MaterialiseEquipmentVirtualTags_creates_variable_under_equipment_folder() + { + var sink = new RecordingSink(); + var applier = new Phase7Applier(sink, NullLogger.Instance); + + var composition = new Phase7CompositionResult( + Array.Empty(), Array.Empty(), Array.Empty()) + { + EquipmentVirtualTags = new[] + { + new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "speed-rpm", DataType: "Float64", + Expression: "ctx.GetTag(\"x\") * 60", DependencyRefs: new[] { "x" }), + }, + }; + + applier.MaterialiseEquipmentVirtualTags(composition); + + sink.FolderCalls.ShouldBeEmpty(); // equipment folder already exists; no sub-folder needed + sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/speed-rpm", "eq-1", "speed-rpm", "Float64")); + } + + /// Two VirtualTags under the SAME equipment produce two distinct folder-scoped variables + /// (one EnsureVariable each, no NodeId collision), parented to the equipment folder. + [Fact] + public void MaterialiseEquipmentVirtualTags_two_under_same_equipment_do_not_collide() + { + var sink = new RecordingSink(); + var applier = new Phase7Applier(sink, NullLogger.Instance); + + var composition = new Phase7CompositionResult( + Array.Empty(), Array.Empty(), Array.Empty()) + { + EquipmentVirtualTags = new[] + { + new EquipmentVirtualTagPlan("vt-a", "eq-1", FolderPath: "", Name: "speed-rpm", DataType: "Float64", + Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }), + new EquipmentVirtualTagPlan("vt-b", "eq-1", FolderPath: "", Name: "load-pct", DataType: "Float64", + Expression: "ctx.GetTag(\"b\")", DependencyRefs: new[] { "b" }), + }, + }; + + applier.MaterialiseEquipmentVirtualTags(composition); + + sink.FolderCalls.ShouldBeEmpty(); + sink.VariableCalls.Count.ShouldBe(2); + sink.VariableCalls.ShouldContain(("eq-1/speed-rpm", "eq-1", "speed-rpm", "Float64")); + sink.VariableCalls.ShouldContain(("eq-1/load-pct", "eq-1", "load-pct", "Float64")); + } + /// Verifies that added equipment tags in an otherwise-empty plan trigger an /// address-space rebuild (parity with the Galaxy-tag path — the planner now diffs equipment /// tags, so a tags-only deploy is no longer a silent no-op).