feat(v3-batch4-wp3): raw-only binding + UNS fan-out + write routing
Wave B of Batch 4 — the runtime binding seam for the dual namespace. Applier (both realms, explicit realm at every sink call site): - MaterialiseRawSubtree: Raw containers as folders + Raw tags as variables keyed by RawPath (native-alarm tag → single Part 9 condition at the RawPath), all in AddressSpaceRealm.Raw; historian tagname = override else RawPath. - MaterialiseUnsReferences: each UNS reference Variable under its equipment folder (Uns realm) + an Organizes UNS->Raw edge; inherits writable/array/ historian tagname from the backing raw tag (both NodeIds -> one tagname). - FeedHistorizedRefs / ProvisionHistorizedTags now source RAW tags (mux ref stays single, keyed by RawPath); ApplyPureRemove tears down raw tags + UNS refs in place (raw-container removal falls back to rebuild). DriverHostActor (dual-NodeId, single-source fan-out): - _nodeIdByDriverRef value gains a realm (NodeRealmRef); rebuilt from RawTags UNION UnsReferenceVariables so one (DriverInstanceId, RawPath) fans to the raw NodeId AND every referencing UNS NodeId with identical value/quality/timestamp. Write inverse map keyed by the bare id; the ns-qualified NodeId the write hook passes is normalised (BareNodeId) so a write to either NodeId resolves the same driver ref (-> RawPath write). - Native raw alarm condition routing is realm-tagged (Raw); AttributeValueUpdate + AlarmStateUpdate carry the realm through to the sink. Retire EquipmentNodeIds -> V3NodeIds.Uns (applier/VirtualTagHostActor) and RawPaths.Combine (DiscoveredNodeMapper, discovered nodes are Raw now). DeploymentArtifact.ParseComposition emits the Raw + UNS subtrees byte-parity with the composer (reconstruct entities -> AddressSpaceComposer.Compose). Sink surface: removed the transitional `= AddressSpaceRealm.Uns` defaults from IOpcUaAddressSpaceSink / ISurgicalAddressSpaceSink / SdkAddressSpaceSink / DeferredAddressSpaceSink / NullOpcUaAddressSpaceSink — every call site is now explicit (realm reordered before the trailing optionals on EnsureVariable + MaterialiseAlarmCondition). Node-manager convenience methods keep their defaults (they are not the interface impl; Sdk delegates explicitly). Tests: rewrote DriverHostActorLiveValueTests (fan-out drift, 1:N) + DriverHostActorWriteRoutingTests (dual-NodeId raw/uns write routing) to the v3 raw+uns model; new AddressSpaceApplierRawUnsTests; migrated the EquipmentTags provisioning/feed tests to RawTags; swept EquipmentNodeIds test callers. Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
This commit is contained in:
@@ -27,9 +27,9 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
var (host, server) = await BootAsync();
|
||||
var sink = new SdkAddressSpaceSink(server.NodeManager!);
|
||||
|
||||
sink.WriteValue("eq-1/temp", 22.5, OpcUaQuality.Good, DateTime.UtcNow);
|
||||
sink.WriteValue("eq-1/temp", 23.1, OpcUaQuality.Good, DateTime.UtcNow);
|
||||
sink.WriteValue("eq-1/pressure", 100, OpcUaQuality.Uncertain, DateTime.UtcNow);
|
||||
sink.WriteValue("eq-1/temp", 22.5, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
sink.WriteValue("eq-1/temp", 23.1, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
sink.WriteValue("eq-1/pressure", 100, OpcUaQuality.Uncertain, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
|
||||
server.NodeManager!.VariableCount.ShouldBe(2);
|
||||
|
||||
@@ -44,8 +44,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
var (host, server) = await BootAsync();
|
||||
var sink = new SdkAddressSpaceSink(server.NodeManager!);
|
||||
|
||||
sink.WriteAlarmCondition("alarm-7", Snapshot(active: true, acknowledged: false), DateTime.UtcNow);
|
||||
sink.WriteValue("eq-1/temp", 22.5, OpcUaQuality.Good, DateTime.UtcNow);
|
||||
sink.WriteAlarmCondition("alarm-7", Snapshot(active: true, acknowledged: false), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
sink.WriteValue("eq-1/temp", 22.5, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
|
||||
server.NodeManager!.VariableCount.ShouldBe(2);
|
||||
|
||||
@@ -120,16 +120,16 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
var (host, server) = await BootAsync();
|
||||
var sink = new SdkAddressSpaceSink(server.NodeManager!);
|
||||
|
||||
sink.WriteValue("a", 1, OpcUaQuality.Good, DateTime.UtcNow);
|
||||
sink.WriteValue("b", 2, OpcUaQuality.Good, DateTime.UtcNow);
|
||||
sink.WriteAlarmCondition("alarm-c", Snapshot(active: true), DateTime.UtcNow);
|
||||
sink.WriteValue("a", 1, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
sink.WriteValue("b", 2, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
sink.WriteAlarmCondition("alarm-c", Snapshot(active: true), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
server.NodeManager!.VariableCount.ShouldBe(3);
|
||||
|
||||
sink.RebuildAddressSpace();
|
||||
server.NodeManager.VariableCount.ShouldBe(0);
|
||||
|
||||
// After rebuild, subsequent writes start fresh.
|
||||
sink.WriteValue("a", 99, OpcUaQuality.Good, DateTime.UtcNow);
|
||||
sink.WriteValue("a", 99, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
server.NodeManager.VariableCount.ShouldBe(1);
|
||||
|
||||
await host.DisposeAsync();
|
||||
@@ -142,8 +142,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
// Sanity check that the F10 fallback still works — production callers default to
|
||||
// NullOpcUaAddressSpaceSink when no SDK NodeManager is wired.
|
||||
var sink = NullOpcUaAddressSpaceSink.Instance;
|
||||
sink.WriteValue("x", 1, OpcUaQuality.Good, DateTime.UtcNow);
|
||||
sink.WriteAlarmCondition("a", Snapshot(active: true), DateTime.UtcNow);
|
||||
sink.WriteValue("x", 1, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
sink.WriteAlarmCondition("a", Snapshot(active: true), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
sink.RebuildAddressSpace();
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
@@ -161,10 +161,10 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
var sink = new SdkAddressSpaceSink(nm);
|
||||
|
||||
// Equipment folder must exist first (MaterialiseHierarchy owns this in production).
|
||||
sink.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1");
|
||||
sink.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", AddressSpaceRealm.Uns);
|
||||
|
||||
// Materialise the condition. NodeId == alarm node id (the ScriptedAlarmId) so WriteAlarmCondition targets it.
|
||||
sink.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", severity: 700);
|
||||
sink.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
|
||||
|
||||
nm.AlarmConditionCount.ShouldBe(1);
|
||||
|
||||
@@ -201,7 +201,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
|
||||
// WriteAlarmCondition now targets the real condition (not the bool[2] placeholder): no extra
|
||||
// BaseDataVariable is minted for the alarm id.
|
||||
sink.WriteAlarmCondition("alm-1", Snapshot(active: true, acknowledged: false), DateTime.UtcNow);
|
||||
sink.WriteAlarmCondition("alm-1", Snapshot(active: true, acknowledged: false), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
nm.VariableCount.ShouldBe(0); // fallback bool[2] path NOT taken
|
||||
|
||||
condition.ActiveState.Id.Value.ShouldBeTrue();
|
||||
@@ -209,7 +209,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
condition.Retain.Value.ShouldBeTrue(); // active || !acked ⇒ retain
|
||||
|
||||
// Idempotent re-materialise (e.g. redeploy): still exactly one condition node for the id.
|
||||
sink.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", severity: 700);
|
||||
sink.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
|
||||
nm.AlarmConditionCount.ShouldBe(1);
|
||||
|
||||
// RebuildAddressSpace clears the alarm dict too.
|
||||
@@ -228,8 +228,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
var nm = server.NodeManager!;
|
||||
var sink = new SdkAddressSpaceSink(nm);
|
||||
|
||||
sink.EnsureFolder("eq-9", parentNodeId: null, displayName: "Equipment 9");
|
||||
sink.MaterialiseAlarmCondition("alm-x", "eq-9", "GenericAlarm", "LimitAlarm", severity: 500);
|
||||
sink.EnsureFolder("eq-9", parentNodeId: null, displayName: "Equipment 9", AddressSpaceRealm.Uns);
|
||||
sink.MaterialiseAlarmCondition("alm-x", "eq-9", "GenericAlarm", "LimitAlarm", severity: 500, realm: AddressSpaceRealm.Uns);
|
||||
|
||||
var condition = nm.TryGetAlarmCondition("alm-x");
|
||||
condition.ShouldNotBeNull();
|
||||
@@ -250,8 +250,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
var nm = server.NodeManager!;
|
||||
var sink = new SdkAddressSpaceSink(nm);
|
||||
|
||||
sink.EnsureFolder("eq-2", parentNodeId: null, displayName: "Equipment 2");
|
||||
sink.MaterialiseAlarmCondition("alm-rich", "eq-2", "HighPressure", "OffNormalAlarm", severity: 300);
|
||||
sink.EnsureFolder("eq-2", parentNodeId: null, displayName: "Equipment 2", AddressSpaceRealm.Uns);
|
||||
sink.MaterialiseAlarmCondition("alm-rich", "eq-2", "HighPressure", "OffNormalAlarm", severity: 300, realm: AddressSpaceRealm.Uns);
|
||||
|
||||
var condition = nm.TryGetAlarmCondition("alm-rich");
|
||||
condition.ShouldNotBeNull();
|
||||
@@ -267,7 +267,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
Shelving: AlarmShelvingKind.Timed,
|
||||
Severity: 850,
|
||||
Message: "Pressure above limit"),
|
||||
DateTime.UtcNow);
|
||||
DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
|
||||
condition.ActiveState.Id.Value.ShouldBeTrue();
|
||||
condition.AckedState.Id.Value.ShouldBeFalse();
|
||||
@@ -296,8 +296,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
var nm = server.NodeManager!;
|
||||
var sink = new SdkAddressSpaceSink(nm);
|
||||
|
||||
sink.EnsureFolder("eq-3", parentNodeId: null, displayName: "Equipment 3");
|
||||
sink.MaterialiseAlarmCondition("alm-base", "eq-3", "Generic", "AlarmCondition", severity: 200);
|
||||
sink.EnsureFolder("eq-3", parentNodeId: null, displayName: "Equipment 3", AddressSpaceRealm.Uns);
|
||||
sink.MaterialiseAlarmCondition("alm-base", "eq-3", "Generic", "AlarmCondition", severity: 200, realm: AddressSpaceRealm.Uns);
|
||||
|
||||
var condition = nm.TryGetAlarmCondition("alm-base");
|
||||
condition.ShouldNotBeNull();
|
||||
@@ -318,7 +318,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
Shelving: AlarmShelvingKind.OneShot,
|
||||
Severity: 500,
|
||||
Message: "still works"),
|
||||
DateTime.UtcNow));
|
||||
DateTime.UtcNow, AddressSpaceRealm.Uns));
|
||||
|
||||
// Mandatory state still projected despite the missing optional child.
|
||||
condition.ActiveState.Id.Value.ShouldBeTrue();
|
||||
@@ -346,8 +346,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
var nm = server.NodeManager!;
|
||||
var sink = new SdkAddressSpaceSink(nm);
|
||||
|
||||
sink.EnsureFolder("eq-evt", parentNodeId: null, displayName: "Equipment Evt");
|
||||
sink.MaterialiseAlarmCondition("alm-evt", "eq-evt", "HighTemp", "OffNormalAlarm", severity: 700);
|
||||
sink.EnsureFolder("eq-evt", parentNodeId: null, displayName: "Equipment Evt", AddressSpaceRealm.Uns);
|
||||
sink.MaterialiseAlarmCondition("alm-evt", "eq-evt", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
|
||||
|
||||
var condition = nm.TryGetAlarmCondition("alm-evt");
|
||||
condition.ShouldNotBeNull();
|
||||
@@ -357,7 +357,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
var materialiseEventId = (byte[]?)condition!.EventId.Value?.Clone();
|
||||
|
||||
// First engine-driven transition → fires an event with a fresh EventId.
|
||||
sink.WriteAlarmCondition("alm-evt", Snapshot(active: true, acknowledged: false), DateTime.UtcNow);
|
||||
sink.WriteAlarmCondition("alm-evt", Snapshot(active: true, acknowledged: false), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
|
||||
var firstEventId = (byte[]?)condition.EventId.Value?.Clone();
|
||||
firstEventId.ShouldNotBeNull();
|
||||
@@ -369,7 +369,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
}
|
||||
|
||||
// Second transition → DIFFERENT EventId (fresh per event, so T17 ack-correlation is unambiguous).
|
||||
sink.WriteAlarmCondition("alm-evt", Snapshot(active: true, acknowledged: true), DateTime.UtcNow);
|
||||
sink.WriteAlarmCondition("alm-evt", Snapshot(active: true, acknowledged: true), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
|
||||
var secondEventId = (byte[]?)condition.EventId.Value?.Clone();
|
||||
secondEventId.ShouldNotBeNull();
|
||||
@@ -395,8 +395,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
var nm = server.NodeManager!;
|
||||
var sink = new SdkAddressSpaceSink(nm);
|
||||
|
||||
sink.EnsureFolder("eq-evt2", parentNodeId: null, displayName: "Equipment Evt2");
|
||||
sink.MaterialiseAlarmCondition("alm-evt2", "eq-evt2", "HighTemp", "OffNormalAlarm", severity: 700);
|
||||
sink.EnsureFolder("eq-evt2", parentNodeId: null, displayName: "Equipment Evt2", AddressSpaceRealm.Uns);
|
||||
sink.MaterialiseAlarmCondition("alm-evt2", "eq-evt2", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
|
||||
|
||||
var condition = nm.TryGetAlarmCondition("alm-evt2");
|
||||
condition.ShouldNotBeNull();
|
||||
@@ -404,9 +404,9 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
// Drive several transitions; each fires an event AND projects state. State must survive firing.
|
||||
Should.NotThrow(() =>
|
||||
{
|
||||
sink.WriteAlarmCondition("alm-evt2", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow);
|
||||
sink.WriteAlarmCondition("alm-evt2", Snapshot(active: true, acknowledged: true, message: "acked"), DateTime.UtcNow);
|
||||
sink.WriteAlarmCondition("alm-evt2", Snapshot(active: false, acknowledged: true, message: "cleared"), DateTime.UtcNow);
|
||||
sink.WriteAlarmCondition("alm-evt2", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
sink.WriteAlarmCondition("alm-evt2", Snapshot(active: true, acknowledged: true, message: "acked"), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
sink.WriteAlarmCondition("alm-evt2", Snapshot(active: false, acknowledged: true, message: "cleared"), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
});
|
||||
|
||||
// Final projected state is intact after the last firing.
|
||||
@@ -434,14 +434,14 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
var nm = server.NodeManager!;
|
||||
var sink = new SdkAddressSpaceSink(nm);
|
||||
|
||||
sink.EnsureFolder("eq-ack", parentNodeId: null, displayName: "Equipment Ack");
|
||||
sink.MaterialiseAlarmCondition("alm-ack", "eq-ack", "HighTemp", "OffNormalAlarm", severity: 700);
|
||||
sink.EnsureFolder("eq-ack", parentNodeId: null, displayName: "Equipment Ack", AddressSpaceRealm.Uns);
|
||||
sink.MaterialiseAlarmCondition("alm-ack", "eq-ack", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
|
||||
|
||||
var condition = nm.TryGetAlarmCondition("alm-ack");
|
||||
condition.ShouldNotBeNull();
|
||||
|
||||
// Drive the alarm active+unacked through the engine path (a genuine transition → fires).
|
||||
sink.WriteAlarmCondition("alm-ack", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow);
|
||||
sink.WriteAlarmCondition("alm-ack", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
condition!.ActiveState.Id.Value.ShouldBeTrue();
|
||||
condition.AckedState.Id.Value.ShouldBeFalse();
|
||||
|
||||
@@ -462,7 +462,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
// === Engine re-projects the SAME acked transition through WriteAlarmCondition (would-be E3) ===
|
||||
// Snapshot equals the node's current state (active, acked, message "active") ⇒ delta-gate sees
|
||||
// no change ⇒ NO event fires.
|
||||
sink.WriteAlarmCondition("alm-ack", Snapshot(active: true, acknowledged: true, message: "active"), DateTime.UtcNow);
|
||||
sink.WriteAlarmCondition("alm-ack", Snapshot(active: true, acknowledged: true, message: "active"), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
|
||||
var afterReProject = (byte[]?)condition.EventId.Value?.Clone();
|
||||
// EventId is UNCHANGED ⇒ ReportConditionEvent did NOT run ⇒ E3 suppressed.
|
||||
@@ -486,8 +486,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
var nm = server.NodeManager!;
|
||||
var sink = new SdkAddressSpaceSink(nm);
|
||||
|
||||
sink.EnsureFolder("eq-delta", parentNodeId: null, displayName: "Equipment Delta");
|
||||
sink.MaterialiseAlarmCondition("alm-delta", "eq-delta", "HighTemp", "OffNormalAlarm", severity: 700);
|
||||
sink.EnsureFolder("eq-delta", parentNodeId: null, displayName: "Equipment Delta", AddressSpaceRealm.Uns);
|
||||
sink.MaterialiseAlarmCondition("alm-delta", "eq-delta", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
|
||||
|
||||
var condition = nm.TryGetAlarmCondition("alm-delta");
|
||||
condition.ShouldNotBeNull();
|
||||
@@ -496,19 +496,19 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
|
||||
// Genuine transition: snapshot (active, unacked) differs from the materialise state
|
||||
// (inactive, acked) ⇒ delta ⇒ fires exactly one event (EventId changes).
|
||||
sink.WriteAlarmCondition("alm-delta", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow);
|
||||
sink.WriteAlarmCondition("alm-delta", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
var afterFirst = (byte[]?)condition.EventId.Value?.Clone();
|
||||
afterFirst.ShouldNotBeNull();
|
||||
afterFirst!.ShouldNotBe(beforeFirst); // fired
|
||||
|
||||
// Identical re-projection: snapshot now EQUALS the node's current state ⇒ no delta ⇒ 0 more
|
||||
// events (EventId unchanged from the first fire).
|
||||
sink.WriteAlarmCondition("alm-delta", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow);
|
||||
sink.WriteAlarmCondition("alm-delta", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
var afterSecond = (byte[]?)condition.EventId.Value?.Clone();
|
||||
afterSecond.ShouldBe(afterFirst); // suppressed
|
||||
|
||||
// A FURTHER genuine transition (clear) differs again ⇒ fires once more.
|
||||
sink.WriteAlarmCondition("alm-delta", Snapshot(active: false, acknowledged: true, message: "cleared"), DateTime.UtcNow);
|
||||
sink.WriteAlarmCondition("alm-delta", Snapshot(active: false, acknowledged: true, message: "cleared"), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
var afterThird = (byte[]?)condition.EventId.Value?.Clone();
|
||||
afterThird.ShouldNotBe(afterSecond); // fired
|
||||
|
||||
|
||||
Reference in New Issue
Block a user