feat(opcua): materialise Equipment-namespace tags in the live rebuild

Add Phase7Applier.MaterialiseEquipmentTags — a sink-based pass (Task-0 decision A) that
ensures each EquipmentTagPlan's Variable (NodeId = FullName) under its existing equipment
folder, nesting any FolderPath as a sub-folder. Wire it into OpcUaPublishActor.HandleRebuild
after the Galaxy pass. Variables start BadWaitingForInitialData; never re-creates equipment
folders (decision #4).
This commit is contained in:
Joseph Doherty
2026-06-06 14:46:38 -04:00
parent febe462750
commit df0dc516c3
4 changed files with 119 additions and 1 deletions
@@ -5,7 +5,7 @@
"tasks": [
{"id": 0, "nativeTaskId": 86, "subject": "Task 0: Confirm signatures + record architecture decisions", "status": "completed", "blockedBy": []},
{"id": 1, "nativeTaskId": 87, "subject": "Task 1: Carry equipment signals in the composition + artifact", "status": "completed", "blockedBy": [86]},
{"id": 2, "nativeTaskId": 88, "subject": "Task 2: Materialise equipment signals in the live rebuild", "status": "pending", "blockedBy": [87]},
{"id": 2, "nativeTaskId": 88, "subject": "Task 2: Materialise equipment signals in the live rebuild", "status": "completed", "blockedBy": [87]},
{"id": 3, "nativeTaskId": 89, "subject": "Task 3: Friendly browse names for UNS folders", "status": "pending", "blockedBy": [87]},
{"id": 4, "nativeTaskId": 90, "subject": "Task 4: Idempotency + restart-safety", "status": "pending", "blockedBy": [88]},
{"id": 5, "nativeTaskId": 91, "subject": "Task 5: docker-dev integration verification + tool support", "status": "pending", "blockedBy": [88, 89]}
@@ -172,6 +172,67 @@ public sealed class Phase7Applier
composition.GalaxyTags.Count, foldersCreated.Count);
}
/// <summary>
/// Materialise Equipment-namespace tags from a composition snapshot — the equipment-signal
/// analogue of <see cref="MaterialiseGalaxyTags"/>. For each <see cref="EquipmentTagPlan"/>,
/// ensure its optional <c>FolderPath</c> sub-folder under the existing equipment folder, then
/// ensure a Variable (NodeId = <c>FullName</c>, the driver-side ref) inside it. Variables
/// start BadWaitingForInitialData; the driver fills live values in a later milestone.
/// Idempotent.
/// <para>
/// <b>Task 0 architecture decisions (recorded per the equipment-namespace-structure
/// plan).</b> Decision #1 = <b>A</b> — a sink-based pass, NOT a reuse of
/// <c>EquipmentNodeWalker</c>: no sink-backed <c>IAddressSpaceBuilder</c> adapter exists
/// (<c>GenericDriverNodeManager.CapturingBuilder</c> decorates another builder, not the
/// sink), and the walker re-creates the whole Area/Line/Equipment tree with browse-path
/// NodeIds — incompatible with this path's logical-Id NodeIds (decision #3) and the
/// already-materialised equipment folders (decision #4). Decision #4 = this pass adds
/// ONLY variables (and any per-tag sub-folder); <see cref="MaterialiseHierarchy"/> owns
/// the equipment folders and this pass never re-creates them. The sink's
/// <c>EnsureVariable</c> takes a plain <c>string dataType</c> (not a DriverAttributeInfo).
/// </para>
/// </summary>
/// <param name="composition">The composition result containing the equipment tags to materialise.</param>
public void MaterialiseEquipmentTags(Phase7CompositionResult composition)
{
ArgumentNullException.ThrowIfNull(composition);
if (composition.EquipmentTags.Count == 0) return;
// Sub-folders first — a tag's FolderPath becomes one folder UNDER its equipment folder
// (deduped per distinct equipment+path). Tags with no FolderPath hang directly under the
// equipment folder, which MaterialiseHierarchy already created (decision #4: never re-create
// the equipment folder here).
var foldersCreated = new HashSet<string>(StringComparer.Ordinal);
foreach (var tag in composition.EquipmentTags)
{
if (string.IsNullOrWhiteSpace(tag.FolderPath)) continue;
var folderNodeId = EquipmentSubFolderNodeId(tag.EquipmentId, tag.FolderPath);
if (!foldersCreated.Add(folderNodeId)) continue;
SafeEnsureFolder(folderNodeId, parentNodeId: tag.EquipmentId, displayName: tag.FolderPath);
}
// Variables: NodeId = FullName (the driver-side reference → read/write routing key). Parent
// is the FolderPath sub-folder when set, else the equipment folder directly. Like the Galaxy
// pass, per-variable idempotency relies on the sink's own EnsureVariable idempotency.
foreach (var tag in composition.EquipmentTags)
{
var parent = string.IsNullOrWhiteSpace(tag.FolderPath)
? tag.EquipmentId
: EquipmentSubFolderNodeId(tag.EquipmentId, tag.FolderPath);
SafeEnsureVariable(tag.FullName, parent, tag.Name, tag.DataType);
}
_logger.LogInformation(
"Phase7Applier: equipment tags materialised (tags={Tags}, equipment={Equipment})",
composition.EquipmentTags.Count,
composition.EquipmentTags.Select(t => t.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)
{
try { _sink.EnsureFolder(nodeId, parentNodeId, displayName); }
@@ -230,6 +230,11 @@ public sealed class OpcUaPublishActor : ReceiveActor
// + Variable node exist so clients can browse them. The Galaxy driver fills values
// on a future SubscribeBulk pass; until then variables show BadWaitingForInitialData.
_applier.MaterialiseGalaxyTags(composition);
// Equipment-namespace tags get their own pass: ensures each signal's Variable (and any
// FolderPath sub-folder) exists under its already-materialised equipment folder so
// clients can browse them. Live values arrive in a later milestone; until then the
// variables show BadWaitingForInitialData.
_applier.MaterialiseEquipmentTags(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})",
@@ -177,6 +177,58 @@ public sealed class Phase7ApplierTests
sink.VariableCalls.ShouldContain(("line.cell.Torque", "line.cell", "Torque", "Float"));
}
/// <summary>Verifies MaterialiseEquipmentTags creates one Variable per equipment tag directly
/// under its existing equipment folder (NodeId == FullName, parent == EquipmentId,
/// displayName == Name) and does NOT re-create the equipment folder (decision #4).</summary>
[Fact]
public void MaterialiseEquipmentTags_creates_variable_under_equipment_folder()
{
var sink = new RecordingSink();
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
var composition = new Phase7CompositionResult(
UnsAreas: Array.Empty<UnsAreaProjection>(),
UnsLines: Array.Empty<UnsLineProjection>(),
EquipmentNodes: Array.Empty<EquipmentNode>(),
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>(),
GalaxyTags: Array.Empty<GalaxyTagPlan>())
{
EquipmentTags = new[]
{
new EquipmentTagPlan("eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001"),
},
};
applier.MaterialiseEquipmentTags(composition);
sink.FolderCalls.ShouldBeEmpty(); // equipment folder already exists; no sub-folder needed
sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("40001", "eq-1", "Speed", "Float"));
}
/// <summary>Verifies a FolderPath on an equipment tag becomes a sub-folder UNDER the equipment
/// folder (not the namespace root), with the variable parented to that sub-folder.</summary>
[Fact]
public void MaterialiseEquipmentTags_nests_FolderPath_subfolder_under_equipment()
{
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("eq-1", "drv", FolderPath: "Diagnostics", Name: "Temp", DataType: "Float", FullName: "40002"),
},
};
applier.MaterialiseEquipmentTags(composition);
sink.FolderCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics", "eq-1", "Diagnostics"));
sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("40002", "eq-1/Diagnostics", "Temp", "Float"));
}
/// <summary>Verifies that added Galaxy tags in an otherwise-empty plan trigger an address-space rebuild.</summary>
[Fact]
public void Added_galaxy_tags_trigger_rebuild()