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).