feat(opcua): materialise Equipment VirtualTag variables on rebuild
This commit is contained in:
@@ -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})",
|
||||
|
||||
@@ -257,6 +257,60 @@ public sealed class Phase7ApplierTests
|
||||
sink.VariableCalls.ShouldContain(("eq-2/Speed", "eq-2", "Speed", "Float"));
|
||||
}
|
||||
|
||||
/// <summary>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).</summary>
|
||||
[Fact]
|
||||
public void MaterialiseEquipmentVirtualTags_creates_variable_under_equipment_folder()
|
||||
{
|
||||
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>())
|
||||
{
|
||||
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"));
|
||||
}
|
||||
|
||||
/// <summary>Two VirtualTags under the SAME equipment produce two distinct folder-scoped variables
|
||||
/// (one EnsureVariable each, no NodeId collision), parented to the equipment folder.</summary>
|
||||
[Fact]
|
||||
public void MaterialiseEquipmentVirtualTags_two_under_same_equipment_do_not_collide()
|
||||
{
|
||||
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>())
|
||||
{
|
||||
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"));
|
||||
}
|
||||
|
||||
/// <summary>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).</summary>
|
||||
|
||||
Reference in New Issue
Block a user