refactor(opcua): repoint Phase7Applier + VirtualTagHostActor to shared EquipmentNodeIds
This commit is contained in:
@@ -172,7 +172,7 @@ public sealed class Phase7Applier
|
|||||||
foreach (var tag in composition.EquipmentTags)
|
foreach (var tag in composition.EquipmentTags)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(tag.FolderPath)) continue;
|
if (string.IsNullOrWhiteSpace(tag.FolderPath)) continue;
|
||||||
var folderNodeId = EquipmentSubFolderNodeId(tag.EquipmentId, tag.FolderPath);
|
var folderNodeId = EquipmentNodeIds.SubFolder(tag.EquipmentId, tag.FolderPath);
|
||||||
if (!foldersCreated.Add(folderNodeId)) continue;
|
if (!foldersCreated.Add(folderNodeId)) continue;
|
||||||
SafeEnsureFolder(folderNodeId, parentNodeId: tag.EquipmentId, displayName: tag.FolderPath);
|
SafeEnsureFolder(folderNodeId, parentNodeId: tag.EquipmentId, displayName: tag.FolderPath);
|
||||||
}
|
}
|
||||||
@@ -187,8 +187,8 @@ public sealed class Phase7Applier
|
|||||||
{
|
{
|
||||||
var parent = string.IsNullOrWhiteSpace(tag.FolderPath)
|
var parent = string.IsNullOrWhiteSpace(tag.FolderPath)
|
||||||
? tag.EquipmentId
|
? tag.EquipmentId
|
||||||
: EquipmentSubFolderNodeId(tag.EquipmentId, tag.FolderPath);
|
: EquipmentNodeIds.SubFolder(tag.EquipmentId, tag.FolderPath);
|
||||||
var nodeId = $"{parent}/{tag.Name}";
|
var nodeId = EquipmentNodeIds.Variable(tag.EquipmentId, tag.FolderPath, tag.Name);
|
||||||
SafeEnsureVariable(nodeId, parent, tag.Name, tag.DataType);
|
SafeEnsureVariable(nodeId, parent, tag.Name, tag.DataType);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,7 +223,7 @@ public sealed class Phase7Applier
|
|||||||
foreach (var v in composition.EquipmentVirtualTags)
|
foreach (var v in composition.EquipmentVirtualTags)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(v.FolderPath)) continue;
|
if (string.IsNullOrWhiteSpace(v.FolderPath)) continue;
|
||||||
var folderNodeId = EquipmentSubFolderNodeId(v.EquipmentId, v.FolderPath);
|
var folderNodeId = EquipmentNodeIds.SubFolder(v.EquipmentId, v.FolderPath);
|
||||||
if (!foldersCreated.Add(folderNodeId)) continue;
|
if (!foldersCreated.Add(folderNodeId)) continue;
|
||||||
SafeEnsureFolder(folderNodeId, parentNodeId: v.EquipmentId, displayName: v.FolderPath);
|
SafeEnsureFolder(folderNodeId, parentNodeId: v.EquipmentId, displayName: v.FolderPath);
|
||||||
}
|
}
|
||||||
@@ -234,8 +234,8 @@ public sealed class Phase7Applier
|
|||||||
{
|
{
|
||||||
var parent = string.IsNullOrWhiteSpace(v.FolderPath)
|
var parent = string.IsNullOrWhiteSpace(v.FolderPath)
|
||||||
? v.EquipmentId
|
? v.EquipmentId
|
||||||
: EquipmentSubFolderNodeId(v.EquipmentId, v.FolderPath);
|
: EquipmentNodeIds.SubFolder(v.EquipmentId, v.FolderPath);
|
||||||
var nodeId = $"{parent}/{v.Name}";
|
var nodeId = EquipmentNodeIds.Variable(v.EquipmentId, v.FolderPath, v.Name);
|
||||||
SafeEnsureVariable(nodeId, parent, v.Name, v.DataType);
|
SafeEnsureVariable(nodeId, parent, v.Name, v.DataType);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,11 +275,6 @@ public sealed class Phase7Applier
|
|||||||
.Select(a => a.EquipmentId).Distinct(StringComparer.Ordinal).Count());
|
.Select(a => a.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) =>
|
|
||||||
$"{equipmentId}/{folderPath}";
|
|
||||||
|
|
||||||
private void SafeEnsureFolder(string nodeId, string? parentNodeId, string displayName)
|
private void SafeEnsureFolder(string nodeId, string? parentNodeId, string displayName)
|
||||||
{
|
{
|
||||||
try { _sink.EnsureFolder(nodeId, parentNodeId, displayName); }
|
try { _sink.EnsureFolder(nodeId, parentNodeId, displayName); }
|
||||||
|
|||||||
@@ -17,10 +17,9 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
|
|||||||
/// already-materialised Variable node (currently BadWaitingForInitialData) reflects the value.
|
/// already-materialised Variable node (currently BadWaitingForInitialData) reflects the value.
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The published NodeId is computed with the <b>identical</b> formula
|
/// The published NodeId is computed by the shared <see cref="EquipmentNodeIds.Variable"/> —
|
||||||
/// <c>Phase7Applier.MaterialiseEquipmentVirtualTags</c> uses to materialise the variable —
|
/// the single source of truth <c>Phase7Applier.MaterialiseEquipmentVirtualTags</c> also
|
||||||
/// <c>{parent}/{Name}</c> where <c>parent = IsNullOrWhiteSpace(FolderPath) ? EquipmentId :
|
/// materialises against — so the value always lands on a NodeId that exists.
|
||||||
/// {EquipmentId}/{FolderPath}</c> — or the value would land on a NodeId that does not exist.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class VirtualTagHostActor : ReceiveActor
|
public sealed class VirtualTagHostActor : ReceiveActor
|
||||||
@@ -143,14 +142,9 @@ public sealed class VirtualTagHostActor : ReceiveActor
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Folder-scoped NodeId for a VirtualTag plan — MUST match
|
/// <summary>Folder-scoped NodeId for a VirtualTag plan. The formula now lives in the shared
|
||||||
/// <c>Phase7Applier.MaterialiseEquipmentVirtualTags</c> exactly, or the published value lands on a
|
/// <see cref="EquipmentNodeIds"/> (the single source of truth that <c>Phase7Applier</c> also
|
||||||
/// NodeId that was never materialised.</summary>
|
/// materialises against), so the published value always lands on the NodeId that was materialised.</summary>
|
||||||
private static string NodeIdFor(EquipmentVirtualTagPlan p)
|
private static string NodeIdFor(EquipmentVirtualTagPlan p) =>
|
||||||
{
|
EquipmentNodeIds.Variable(p.EquipmentId, p.FolderPath, p.Name);
|
||||||
var parent = string.IsNullOrWhiteSpace(p.FolderPath)
|
|
||||||
? p.EquipmentId
|
|
||||||
: $"{p.EquipmentId}/{p.FolderPath}";
|
|
||||||
return $"{parent}/{p.Name}";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,6 +139,8 @@ public sealed class Phase7ApplierTests
|
|||||||
|
|
||||||
sink.FolderCalls.ShouldBeEmpty(); // equipment folder already exists; no sub-folder needed
|
sink.FolderCalls.ShouldBeEmpty(); // equipment folder already exists; no sub-folder needed
|
||||||
sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Speed", "eq-1", "Speed", "Float"));
|
sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Speed", "eq-1", "Speed", "Float"));
|
||||||
|
// Parity: the materialiser's NodeId is the shared EquipmentNodeIds formula (null/empty FolderPath).
|
||||||
|
sink.VariableCalls.Single().NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "Speed"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies a FolderPath on an equipment tag becomes a sub-folder UNDER the equipment
|
/// <summary>Verifies a FolderPath on an equipment tag becomes a sub-folder UNDER the equipment
|
||||||
@@ -163,6 +165,8 @@ public sealed class Phase7ApplierTests
|
|||||||
|
|
||||||
sink.FolderCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics", "eq-1", "Diagnostics"));
|
sink.FolderCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics", "eq-1", "Diagnostics"));
|
||||||
sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics/Temp", "eq-1/Diagnostics", "Temp", "Float"));
|
sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics/Temp", "eq-1/Diagnostics", "Temp", "Float"));
|
||||||
|
// Parity: the materialiser's NodeId is the shared EquipmentNodeIds formula (with FolderPath).
|
||||||
|
sink.VariableCalls.Single().NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "Diagnostics", "Temp"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Regression for the FullName-as-NodeId collision: two identical machines exposing the
|
/// <summary>Regression for the FullName-as-NodeId collision: two identical machines exposing the
|
||||||
@@ -215,6 +219,47 @@ public sealed class Phase7ApplierTests
|
|||||||
|
|
||||||
sink.FolderCalls.ShouldBeEmpty(); // equipment folder already exists; no sub-folder needed
|
sink.FolderCalls.ShouldBeEmpty(); // equipment folder already exists; no sub-folder needed
|
||||||
sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/speed-rpm", "eq-1", "speed-rpm", "Float64"));
|
sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/speed-rpm", "eq-1", "speed-rpm", "Float64"));
|
||||||
|
// Parity: the vtag materialiser's NodeId is the shared EquipmentNodeIds formula.
|
||||||
|
sink.VariableCalls.Single().NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "speed-rpm"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Golden/parity guard: the materialiser's Variable NodeId for BOTH the equipment-tag and
|
||||||
|
/// the equipment-VirtualTag pass is byte-identical to <see cref="EquipmentNodeIds.Variable"/> — the
|
||||||
|
/// single source of truth Phase7Applier + VirtualTagHostActor both point at. Covers null/empty
|
||||||
|
/// FolderPath (directly under equipment) and a non-empty FolderPath (sub-folder scoped). This test
|
||||||
|
/// LOCKS the formula against drift: any change to the materialiser NodeId that diverges from the
|
||||||
|
/// shared helper fails here.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Materialised_variable_node_ids_match_shared_EquipmentNodeIds_formula()
|
||||||
|
{
|
||||||
|
var sink = new RecordingSink();
|
||||||
|
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
|
||||||
|
|
||||||
|
var composition = new Phase7CompositionResult(
|
||||||
|
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||||
|
{
|
||||||
|
EquipmentTags = new[]
|
||||||
|
{
|
||||||
|
new EquipmentTagPlan("tag-flat", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001"),
|
||||||
|
new EquipmentTagPlan("tag-nested", "eq-1", "drv", FolderPath: "Diagnostics", Name: "Temp", DataType: "Float", FullName: "40002"),
|
||||||
|
},
|
||||||
|
EquipmentVirtualTags = new[]
|
||||||
|
{
|
||||||
|
new EquipmentVirtualTagPlan("vt-flat", "eq-2", FolderPath: "", Name: "Efficiency", DataType: "Float64",
|
||||||
|
Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }),
|
||||||
|
new EquipmentVirtualTagPlan("vt-nested", "eq-2", FolderPath: "Calc", Name: "Avg", DataType: "Float64",
|
||||||
|
Expression: "ctx.GetTag(\"b\")", DependencyRefs: new[] { "b" }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
applier.MaterialiseEquipmentTags(composition);
|
||||||
|
applier.MaterialiseEquipmentVirtualTags(composition);
|
||||||
|
|
||||||
|
var nodeIds = sink.VariableCalls.Select(v => v.NodeId).ToList();
|
||||||
|
nodeIds.ShouldContain(EquipmentNodeIds.Variable("eq-1", "", "Speed"));
|
||||||
|
nodeIds.ShouldContain(EquipmentNodeIds.Variable("eq-1", "Diagnostics", "Temp"));
|
||||||
|
nodeIds.ShouldContain(EquipmentNodeIds.Variable("eq-2", "", "Efficiency"));
|
||||||
|
nodeIds.ShouldContain(EquipmentNodeIds.Variable("eq-2", "Calc", "Avg"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Two VirtualTags under the SAME equipment produce two distinct folder-scoped variables
|
/// <summary>Two VirtualTags under the SAME equipment produce two distinct folder-scoped variables
|
||||||
|
|||||||
Reference in New Issue
Block a user