feat(opcua): add AddressSpaceChangeClassifier — typed delta classification for the applier (R2-07 T1)
This commit is contained in:
@@ -138,11 +138,11 @@ public sealed class AddressSpaceApplier
|
||||
plan.AddedEquipment.Count > 0 || plan.RemovedEquipment.Count > 0 || plan.ChangedEquipment.Count > 0 ||
|
||||
plan.AddedAlarms.Count > 0 || plan.RemovedAlarms.Count > 0 || plan.ChangedAlarms.Count > 0 ||
|
||||
plan.AddedEquipmentTags.Count > 0 || plan.RemovedEquipmentTags.Count > 0 ||
|
||||
plan.ChangedEquipmentTags.Any(d => !TagDeltaIsSurgicalEligible(d)) ||
|
||||
plan.ChangedEquipmentTags.Any(d => !AddressSpaceChangeClassifier.TagDeltaIsSurgicalEligible(d)) ||
|
||||
plan.AddedEquipmentVirtualTags.Count > 0 || plan.RemovedEquipmentVirtualTags.Count > 0 ||
|
||||
plan.ChangedEquipmentVirtualTags.Any(d => !VtagDeltaIsNodeIrrelevant(d));
|
||||
plan.ChangedEquipmentVirtualTags.Any(d => !AddressSpaceChangeClassifier.VtagDeltaIsNodeIrrelevant(d));
|
||||
|
||||
var surgicalTagDeltas = plan.ChangedEquipmentTags.Where(TagDeltaIsSurgicalEligible).ToList();
|
||||
var surgicalTagDeltas = plan.ChangedEquipmentTags.Where(AddressSpaceChangeClassifier.TagDeltaIsSurgicalEligible).ToList();
|
||||
// UNS Area / Line renames are surgically applicable (in-place DisplayName swap)
|
||||
// when no structural rebuild fires. When a rebuild DOES fire (any add/remove/structural change),
|
||||
// MaterialiseHierarchy re-creates every folder with the new display names, so the renames are
|
||||
@@ -663,46 +663,10 @@ public sealed class AddressSpaceApplier
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: EnsureVariable threw for {Node}", nodeId); return false; }
|
||||
}
|
||||
|
||||
// A VirtualTag's materialised OPC UA node (MaterialiseEquipmentVirtualTags) is derived ONLY from
|
||||
// {EquipmentId, FolderPath, Name, DataType}. Expression/DependencyRefs/Historize are engine/write-side
|
||||
// only and are adopted by VirtualTagHostActor's INDEPENDENT respawn (DriverHostActor → ApplyVirtualTags),
|
||||
// so a delta changing ONLY those three leaves a byte-identical node and needs no address-space rebuild.
|
||||
// Whitelist-of-may-differ via `with` + the record's custom Equals: any OTHER field difference (current
|
||||
// or future) makes the override unequal → falls back to a full rebuild (safe default).
|
||||
private static bool VtagDeltaIsNodeIrrelevant(AddressSpacePlan.EquipmentVirtualTagDelta d) =>
|
||||
(d.Previous with
|
||||
{
|
||||
Expression = d.Current.Expression,
|
||||
DependencyRefs = d.Current.DependencyRefs,
|
||||
Historize = d.Current.Historize,
|
||||
}).Equals(d.Current);
|
||||
|
||||
// F10b: a CHANGED equipment tag whose ONLY differences are Writable / IsHistorized /
|
||||
// HistorianTagname / DataType / IsArray / ArrayLength (a plain value variable — no alarm condition node)
|
||||
// can be updated IN PLACE on the existing node via ISurgicalAddressSpaceSink.UpdateTagAttributes,
|
||||
// avoiding a full rebuild (preserving subscriptions). The presentation-shape fields (DataType / IsArray /
|
||||
// ArrayLength) join the whitelist now that the surgical sink swaps DataType + ValueRank + ArrayDimensions
|
||||
// in place (and raises a GeneralModelChangeEvent). FullName / DriverInstanceId / Name / identity / alarm
|
||||
// differences still fall through to a rebuild — FullName/DriverInstanceId re-route the node to a different
|
||||
// driver point, Name re-derives the NodeId, and an alarm flip turns the node into a Part 9 condition. The
|
||||
// override-unequal default also covers any future field.
|
||||
// REACH (live-verified): the shape path only fires for drivers whose TagConfig carries a stable
|
||||
// top-level "FullName" (Galaxy = tag_name.AttributeName; OpcUaClient = the node id) — there a DataType/array
|
||||
// edit leaves FullName untouched ⇒ surgical. For structured-TagConfig protocol drivers (Modbus/S7/AbCip/…)
|
||||
// TagConfigIntent.Parse falls back to the RAW TagConfig blob as FullName, so a DataType/
|
||||
// array edit also mutates that blob ⇒ FullName differs ⇒ this returns false ⇒ full rebuild. That is the
|
||||
// correct safe default (a protocol driver's subscription needs re-spawning for a new shape anyway).
|
||||
private static bool TagDeltaIsSurgicalEligible(AddressSpacePlan.EquipmentTagDelta d) =>
|
||||
d.Previous.Alarm is null && d.Current.Alarm is null &&
|
||||
(d.Previous with
|
||||
{
|
||||
Writable = d.Current.Writable,
|
||||
IsHistorized = d.Current.IsHistorized,
|
||||
HistorianTagname = d.Current.HistorianTagname,
|
||||
DataType = d.Current.DataType,
|
||||
IsArray = d.Current.IsArray,
|
||||
ArrayLength = d.Current.ArrayLength,
|
||||
}).Equals(d.Current);
|
||||
// The tag/vtag surgical-eligibility predicates moved to AddressSpaceChangeClassifier (R2-07 T1) so the
|
||||
// whole delta classification is one pure, table-testable unit; the applier references them there (and the
|
||||
// classifier's Classify names the routing). The `with`-expression whitelist technique is preserved there
|
||||
// byte-for-byte.
|
||||
|
||||
/// <summary>The "no-event" condition state written to a removed equipment / alarm node before the
|
||||
/// rebuild tears it down: inactive, acked, confirmed, enabled, unshelved, severity 0, empty message.
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
|
||||
/// <summary>How <see cref="AddressSpaceApplier"/> must mutate the address space for a given
|
||||
/// <see cref="AddressSpacePlan"/>. A pure policy classification OVER the planner's diff (the planner stays
|
||||
/// a pure diff; the kind is the applier's routing policy over that diff — see R2-07 03/P1).</summary>
|
||||
public enum AddressSpaceChangeKind
|
||||
{
|
||||
/// <summary><see cref="AddressSpacePlan.IsEmpty"/> — nothing to do.</summary>
|
||||
Empty,
|
||||
|
||||
/// <summary>Only surgical-eligible changed tags, node-irrelevant vtag changes, folder renames, and/or
|
||||
/// node-inert driver deltas — the existing F10b in-place path. No adds, no removes.</summary>
|
||||
AttributeOnly,
|
||||
|
||||
/// <summary>Only additions (any mix of Equipment/Alarms/Tags/VirtualTags) on top of at-most-AttributeOnly
|
||||
/// changes — no removals, no node-affecting changes. Phase 1: the idempotent Materialise passes create
|
||||
/// exactly the added nodes with no teardown, so every client subscription survives.</summary>
|
||||
PureAdd,
|
||||
|
||||
/// <summary>Only removals on top of at-most-AttributeOnly changes — no additions, no node-affecting
|
||||
/// changes. Phase 2: scoped in-place teardown of only the removed subtree.</summary>
|
||||
PureRemove,
|
||||
|
||||
/// <summary>Additions AND removals, still no node-affecting changes. Phase 3 applies removes-then-adds
|
||||
/// surgically; until Phase 3 it maps to a full rebuild.</summary>
|
||||
AddRemoveMix,
|
||||
|
||||
/// <summary>Anything with a node-affecting change (ChangedEquipment, ChangedAlarms, non-surgical
|
||||
/// ChangedEquipmentTags, node-relevant ChangedEquipmentVirtualTags) — the unclassifiable-safe default.
|
||||
/// Full rebuild.</summary>
|
||||
Rebuild,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure static classifier over an <see cref="AddressSpacePlan"/> — names the delta class the applier
|
||||
/// routes each plan through, so a pure-add deploy no longer tears down and recreates every node on the
|
||||
/// server (03/P1). First-match-wins per the plan's classification table; the default-closed
|
||||
/// <see cref="AddressSpaceChangeKind.Rebuild"/> catches every node-affecting change (including any future
|
||||
/// plan field that makes a changed record unequal), so the worst outcome of any misclassification is
|
||||
/// today's full-rebuild behaviour.
|
||||
/// </summary>
|
||||
public static class AddressSpaceChangeClassifier
|
||||
{
|
||||
/// <summary>Classify <paramref name="plan"/> into the delta class the applier routes it through.</summary>
|
||||
/// <param name="plan">The plan to classify.</param>
|
||||
/// <returns>The <see cref="AddressSpaceChangeKind"/> for the plan.</returns>
|
||||
public static AddressSpaceChangeKind Classify(AddressSpacePlan plan)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plan);
|
||||
|
||||
// 1 — nothing to do.
|
||||
if (plan.IsEmpty) return AddressSpaceChangeKind.Empty;
|
||||
|
||||
// 2 — any node-affecting CHANGE forces a full rebuild (default-closed). ChangedEquipment /
|
||||
// ChangedAlarms are always node-affecting; a changed tag is node-affecting UNLESS it is
|
||||
// surgical-eligible, and a changed vtag is node-affecting UNLESS it is node-irrelevant. Evaluated
|
||||
// BEFORE the add/remove split so a non-surgical change alongside pure adds still rebuilds.
|
||||
if (plan.ChangedEquipment.Count > 0 ||
|
||||
plan.ChangedAlarms.Count > 0 ||
|
||||
plan.ChangedEquipmentTags.Any(d => !TagDeltaIsSurgicalEligible(d)) ||
|
||||
plan.ChangedEquipmentVirtualTags.Any(d => !VtagDeltaIsNodeIrrelevant(d)))
|
||||
{
|
||||
return AddressSpaceChangeKind.Rebuild;
|
||||
}
|
||||
|
||||
// Driver deltas (Added/Removed/ChangedDrivers) are node-INERT — they never touch the address-space
|
||||
// topology (they route to DriverHostActor's spawn plan in Runtime), so they are excluded from the
|
||||
// add/remove split below and leave a driver-only plan classified AttributeOnly.
|
||||
var hasAdds =
|
||||
plan.AddedEquipment.Count + plan.AddedAlarms.Count +
|
||||
plan.AddedEquipmentTags.Count + plan.AddedEquipmentVirtualTags.Count > 0;
|
||||
var hasRemoves =
|
||||
plan.RemovedEquipment.Count + plan.RemovedAlarms.Count +
|
||||
plan.RemovedEquipmentTags.Count + plan.RemovedEquipmentVirtualTags.Count > 0;
|
||||
|
||||
// 3 — additions AND removals.
|
||||
if (hasAdds && hasRemoves) return AddressSpaceChangeKind.AddRemoveMix;
|
||||
// 4 — additions only.
|
||||
if (hasAdds) return AddressSpaceChangeKind.PureAdd;
|
||||
// 5 — removals only.
|
||||
if (hasRemoves) return AddressSpaceChangeKind.PureRemove;
|
||||
// 6 — only surgical tag deltas / node-irrelevant vtag deltas / folder renames / driver deltas.
|
||||
return AddressSpaceChangeKind.AttributeOnly;
|
||||
}
|
||||
|
||||
// A VirtualTag's materialised OPC UA node (MaterialiseEquipmentVirtualTags) is derived ONLY from
|
||||
// {EquipmentId, FolderPath, Name, DataType}. Expression/DependencyRefs/Historize are engine/write-side
|
||||
// only and are adopted by VirtualTagHostActor's INDEPENDENT respawn (DriverHostActor → ApplyVirtualTags),
|
||||
// so a delta changing ONLY those three leaves a byte-identical node and needs no address-space rebuild.
|
||||
// Whitelist-of-may-differ via `with` + the record's custom Equals: any OTHER field difference (current
|
||||
// or future) makes the override unequal → falls back to a full rebuild (safe default).
|
||||
internal static bool VtagDeltaIsNodeIrrelevant(AddressSpacePlan.EquipmentVirtualTagDelta d) =>
|
||||
(d.Previous with
|
||||
{
|
||||
Expression = d.Current.Expression,
|
||||
DependencyRefs = d.Current.DependencyRefs,
|
||||
Historize = d.Current.Historize,
|
||||
}).Equals(d.Current);
|
||||
|
||||
// F10b: a CHANGED equipment tag whose ONLY differences are Writable / IsHistorized /
|
||||
// HistorianTagname / DataType / IsArray / ArrayLength (a plain value variable — no alarm condition node)
|
||||
// can be updated IN PLACE on the existing node via ISurgicalAddressSpaceSink.UpdateTagAttributes,
|
||||
// avoiding a full rebuild (preserving subscriptions). The presentation-shape fields (DataType / IsArray /
|
||||
// ArrayLength) join the whitelist now that the surgical sink swaps DataType + ValueRank + ArrayDimensions
|
||||
// in place (and raises a GeneralModelChangeEvent). FullName / DriverInstanceId / Name / identity / alarm
|
||||
// differences still fall through to a rebuild — FullName/DriverInstanceId re-route the node to a different
|
||||
// driver point, Name re-derives the NodeId, and an alarm flip turns the node into a Part 9 condition. The
|
||||
// override-unequal default also covers any future field.
|
||||
// REACH (live-verified): the shape path only fires for drivers whose TagConfig carries a stable
|
||||
// top-level "FullName" (Galaxy = tag_name.AttributeName; OpcUaClient = the node id) — there a DataType/array
|
||||
// edit leaves FullName untouched ⇒ surgical. For structured-TagConfig protocol drivers (Modbus/S7/AbCip/…)
|
||||
// TagConfigIntent.Parse falls back to the RAW TagConfig blob as FullName, so a DataType/
|
||||
// array edit also mutates that blob ⇒ FullName differs ⇒ this returns false ⇒ full rebuild. That is the
|
||||
// correct safe default (a protocol driver's subscription needs re-spawning for a new shape anyway).
|
||||
internal static bool TagDeltaIsSurgicalEligible(AddressSpacePlan.EquipmentTagDelta d) =>
|
||||
d.Previous.Alarm is null && d.Current.Alarm is null &&
|
||||
(d.Previous with
|
||||
{
|
||||
Writable = d.Current.Writable,
|
||||
IsHistorized = d.Current.IsHistorized,
|
||||
HistorianTagname = d.Current.HistorianTagname,
|
||||
DataType = d.Current.DataType,
|
||||
IsArray = d.Current.IsArray,
|
||||
ArrayLength = d.Current.ArrayLength,
|
||||
}).Equals(d.Current);
|
||||
}
|
||||
Reference in New Issue
Block a user