feat(opcua): remove SystemPlatform-mirror GalaxyTags contract end-to-end (composer+applier+artifact, byte-parity)

This commit is contained in:
Joseph Doherty
2026-06-12 21:45:19 -04:00
parent 5dfb797817
commit 95be607a07
13 changed files with 167 additions and 431 deletions
@@ -70,22 +70,20 @@ public sealed class Phase7Applier
var changedCount =
plan.ChangedEquipment.Count + plan.ChangedDrivers.Count + plan.ChangedAlarms.Count +
plan.ChangedGalaxyTags.Count + plan.ChangedEquipmentTags.Count +
plan.ChangedEquipmentTags.Count +
plan.ChangedEquipmentVirtualTags.Count;
var addedCount =
plan.AddedEquipment.Count + plan.AddedDrivers.Count + plan.AddedAlarms.Count +
plan.AddedGalaxyTags.Count + plan.AddedEquipmentTags.Count +
plan.AddedEquipmentTags.Count +
plan.AddedEquipmentVirtualTags.Count;
// Any add/remove of Equipment, ScriptedAlarm, Galaxy tag, Equipment tag, or Equipment
// VirtualTag topology requires a real address-space rebuild. Driver-instance changes don't
// touch the address-space topology directly — they go through DriverHostActor's spawn-plan
// in Runtime.
// Any add/remove of Equipment, ScriptedAlarm, Equipment tag, or Equipment VirtualTag topology
// requires a real address-space rebuild. Driver-instance changes don't touch the address-space
// topology directly — they go through DriverHostActor's spawn-plan in Runtime.
// TODO(equipment-virtualtags): when MaterialiseEquipmentVirtualTags drives per-delta sink work, revisit whether ChangedEquipmentVirtualTags should also force needsRebuild.
var needsRebuild =
plan.AddedEquipment.Count > 0 || plan.RemovedEquipment.Count > 0 ||
plan.AddedAlarms.Count > 0 || plan.RemovedAlarms.Count > 0 ||
plan.AddedGalaxyTags.Count > 0 || plan.RemovedGalaxyTags.Count > 0 ||
plan.AddedEquipmentTags.Count > 0 || plan.RemovedEquipmentTags.Count > 0 ||
plan.AddedEquipmentVirtualTags.Count > 0 || plan.RemovedEquipmentVirtualTags.Count > 0;
@@ -141,46 +139,8 @@ public sealed class Phase7Applier
}
/// <summary>
/// Materialise Galaxy / SystemPlatform-namespace tags from a composition snapshot:
/// for each <see cref="GalaxyTagPlan"/>, ensure its FolderPath segment exists (a folder
/// under the namespace root), then ensure a Variable node sits inside that folder for
/// the leaf <see cref="GalaxyTagPlan.DisplayName"/>. Variable starts with BadWaitingForInitialData;
/// the Galaxy driver's <c>OnDataChange</c> path fills the value in once SubscribeBulk lands.
/// Idempotent.
/// </summary>
/// <param name="composition">The composition result containing the Galaxy tags to materialise.</param>
public void MaterialiseGalaxyTags(Phase7CompositionResult composition)
{
ArgumentNullException.ThrowIfNull(composition);
if (composition.GalaxyTags.Count == 0) return;
// Folders first — each distinct FolderPath becomes one folder under the root.
var foldersCreated = new HashSet<string>(StringComparer.Ordinal);
foreach (var tag in composition.GalaxyTags)
{
if (string.IsNullOrWhiteSpace(tag.FolderPath)) continue;
if (!foldersCreated.Add(tag.FolderPath)) continue;
SafeEnsureFolder(tag.FolderPath, parentNodeId: null, displayName: tag.FolderPath);
}
// Variables: NodeId is "<FolderPath>.<DisplayName>" so it matches the MXAccess ref the
// Galaxy driver subscribes to. Browse-path lookup via OPC UA Translate is the canonical
// resolution; flat NodeId keeps the address space lookup cheap.
foreach (var tag in composition.GalaxyTags)
{
var nodeId = string.IsNullOrWhiteSpace(tag.FolderPath) ? tag.DisplayName : tag.MxAccessRef;
var parent = string.IsNullOrWhiteSpace(tag.FolderPath) ? null : tag.FolderPath;
SafeEnsureVariable(nodeId, parent, tag.DisplayName, tag.DataType);
}
_logger.LogInformation(
"Phase7Applier: Galaxy tags materialised (tags={Tags}, folders={Folders})",
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"/>,
/// Materialise Equipment-namespace tags from a composition snapshot.
/// 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.
@@ -222,7 +182,7 @@ public sealed class Phase7Applier
// would collide in the sink (EnsureVariable keys on NodeId) and drop all but one machine's
// signal. The driver-side FullName lives on EquipmentTagPlan for the later values milestone to
// route by. 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.
// Per-variable idempotency relies on the sink's own EnsureVariable.
foreach (var tag in composition.EquipmentTags)
{
var parent = string.IsNullOrWhiteSpace(tag.FolderPath)
@@ -9,18 +9,15 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
/// <summary>Outcome of <see cref="Phase7Composer.Compose"/> — pure value tuple, no side effects.
/// <see cref="UnsAreas"/> + <see cref="UnsLines"/> carry the UNS topology so the applier can
/// materialise the Area/Line/Equipment folder hierarchy in the address space; equipment carries
/// its parent line id so the applier knows where to hang each equipment folder.
/// <see cref="GalaxyTags"/> carries SystemPlatform-namespace tags (Galaxy hierarchy) so the
/// applier can materialise their FolderPath + Variable nodes ahead of any driver subscribe.</summary>
/// its parent line id so the applier knows where to hang each equipment folder.</summary>
public sealed record Phase7CompositionResult(
IReadOnlyList<UnsAreaProjection> UnsAreas,
IReadOnlyList<UnsLineProjection> UnsLines,
IReadOnlyList<EquipmentNode> EquipmentNodes,
IReadOnlyList<DriverInstancePlan> DriverInstancePlans,
IReadOnlyList<ScriptedAlarmPlan> ScriptedAlarmPlans,
IReadOnlyList<GalaxyTagPlan> GalaxyTags)
IReadOnlyList<ScriptedAlarmPlan> ScriptedAlarmPlans)
{
/// <summary>Convenience constructor for tests + earlier callers that don't carry UNS or Galaxy data.</summary>
/// <summary>Convenience constructor for tests + earlier callers that don't carry UNS data.</summary>
/// <param name="equipmentNodes">The equipment nodes.</param>
/// <param name="driverInstancePlans">The driver instance plans.</param>
/// <param name="scriptedAlarmPlans">The scripted alarm plans.</param>
@@ -29,33 +26,17 @@ public sealed record Phase7CompositionResult(
IReadOnlyList<DriverInstancePlan> driverInstancePlans,
IReadOnlyList<ScriptedAlarmPlan> scriptedAlarmPlans)
: this(Array.Empty<UnsAreaProjection>(), Array.Empty<UnsLineProjection>(),
equipmentNodes, driverInstancePlans, scriptedAlarmPlans, Array.Empty<GalaxyTagPlan>())
{
}
/// <summary>Convenience constructor for callers carrying UNS but not Galaxy data.</summary>
/// <param name="unsAreas">The UNS areas.</param>
/// <param name="unsLines">The UNS lines.</param>
/// <param name="equipmentNodes">The equipment nodes.</param>
/// <param name="driverInstancePlans">The driver instance plans.</param>
/// <param name="scriptedAlarmPlans">The scripted alarm plans.</param>
public Phase7CompositionResult(
IReadOnlyList<UnsAreaProjection> unsAreas,
IReadOnlyList<UnsLineProjection> unsLines,
IReadOnlyList<EquipmentNode> equipmentNodes,
IReadOnlyList<DriverInstancePlan> driverInstancePlans,
IReadOnlyList<ScriptedAlarmPlan> scriptedAlarmPlans)
: this(unsAreas, unsLines, equipmentNodes, driverInstancePlans, scriptedAlarmPlans, Array.Empty<GalaxyTagPlan>())
equipmentNodes, driverInstancePlans, scriptedAlarmPlans)
{
}
/// <summary>
/// Equipment-namespace tags — a <see cref="Tag"/> with non-null <see cref="Tag.EquipmentId"/>
/// in an <c>Equipment</c>-kind namespace. Mirror of <see cref="GalaxyTags"/> for the UNS
/// equipment-signal path: <c>Phase7Applier.MaterialiseEquipmentTags</c> materialises each as
/// a Variable under its existing equipment folder. Declared as an init-only member defaulting
/// to empty (rather than a 7th positional parameter) so every existing convenience
/// constructor + call site keeps compiling unchanged; new producers set it via initializer.
/// in an <c>Equipment</c>-kind namespace. <c>Phase7Applier.MaterialiseEquipmentTags</c>
/// materialises each as a Variable under its existing equipment folder. Declared as an
/// init-only member defaulting to empty (rather than a positional parameter) so every existing
/// convenience constructor + call site keeps compiling unchanged; new producers set it via
/// initializer.
/// </summary>
public IReadOnlyList<EquipmentTagPlan> EquipmentTags { get; init; } = Array.Empty<EquipmentTagPlan>();
@@ -81,21 +62,6 @@ public sealed record EquipmentNode(string EquipmentId, string DisplayName, strin
public sealed record DriverInstancePlan(string DriverInstanceId, string DriverType, string ConfigJson);
public sealed record ScriptedAlarmPlan(string ScriptedAlarmId, string EquipmentId, string PredicateScriptId, string MessageTemplate);
/// <summary>
/// One Galaxy / SystemPlatform-namespace tag from a <see cref="Tag"/> row where
/// <see cref="Tag.EquipmentId"/> is null. Carries the FolderPath segment that the applier
/// turns into a folder, the leaf <see cref="DisplayName"/> for the Variable, the OPC UA
/// <see cref="DataType"/>, and the dot-form MXAccess reference (<see cref="MxAccessRef"/>)
/// that the Galaxy driver consumes when subscribing.
/// </summary>
public sealed record GalaxyTagPlan(
string TagId,
string DriverInstanceId,
string FolderPath,
string DisplayName,
string DataType,
string MxAccessRef);
/// <summary>
/// One Equipment-namespace tag from a <see cref="Tag"/> row whose <see cref="Tag.EquipmentId"/>
/// is non-null and whose owning driver's namespace is <c>Equipment</c>-kind. Carries the stable
@@ -105,8 +71,7 @@ public sealed record GalaxyTagPlan(
/// <see cref="DataType"/>, and the driver-side <see cref="FullName"/> reference (extracted from
/// <c>Tag.TagConfig</c>) the later values milestone routes reads/writes by. The variable's NodeId
/// is folder-scoped (<c>parent/Name</c>), NOT <see cref="FullName"/>, because a raw driver ref
/// (e.g. a Modbus register) is not unique across identical machines. The equipment-signal
/// analogue of <see cref="GalaxyTagPlan"/>.
/// (e.g. a Modbus register) is not unique across identical machines.
/// </summary>
public sealed record EquipmentTagPlan(
string TagId,
@@ -253,7 +218,7 @@ public sealed record EquipmentScriptedAlarmPlan(
/// </summary>
public static class Phase7Composer
{
/// <summary>Convenience overload for legacy callers + tests that don't yet supply UNS / Galaxy data.</summary>
/// <summary>Convenience overload for legacy callers + tests that don't supply UNS topology or tags.</summary>
/// <param name="equipment">The equipment.</param>
/// <param name="driverInstances">The driver instances.</param>
/// <param name="scriptedAlarms">The scripted alarms.</param>
@@ -265,7 +230,7 @@ public static class Phase7Composer
Compose(Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), equipment, driverInstances, scriptedAlarms,
Array.Empty<Tag>(), Array.Empty<Namespace>());
/// <summary>UNS-aware overload that doesn't yet supply Galaxy tags.</summary>
/// <summary>UNS-aware overload that doesn't supply tags.</summary>
/// <param name="unsAreas">The UNS areas.</param>
/// <param name="unsLines">The UNS lines.</param>
/// <param name="equipment">The equipment.</param>
@@ -440,7 +405,7 @@ public static class Phase7Composer
Enabled: a.Enabled));
}
return new Phase7CompositionResult(areas, lines, nodes, plans, alarms, Array.Empty<GalaxyTagPlan>())
return new Phase7CompositionResult(areas, lines, nodes, plans, alarms)
{
EquipmentTags = equipmentTags,
EquipmentVirtualTags = equipmentVirtualTags,
@@ -21,10 +21,7 @@ public sealed record Phase7Plan(
IReadOnlyList<Phase7Plan.DriverDelta> ChangedDrivers,
IReadOnlyList<ScriptedAlarmPlan> AddedAlarms,
IReadOnlyList<ScriptedAlarmPlan> RemovedAlarms,
IReadOnlyList<Phase7Plan.AlarmDelta> ChangedAlarms,
IReadOnlyList<GalaxyTagPlan> AddedGalaxyTags,
IReadOnlyList<GalaxyTagPlan> RemovedGalaxyTags,
IReadOnlyList<Phase7Plan.GalaxyTagDelta> ChangedGalaxyTags)
IReadOnlyList<Phase7Plan.AlarmDelta> ChangedAlarms)
{
/// <summary>
/// Equipment-namespace tag diff sets, keyed by <see cref="EquipmentTagPlan.TagId"/>. Added as
@@ -60,14 +57,12 @@ public sealed record Phase7Plan(
AddedEquipment.Count == 0 && RemovedEquipment.Count == 0 && ChangedEquipment.Count == 0 &&
AddedDrivers.Count == 0 && RemovedDrivers.Count == 0 && ChangedDrivers.Count == 0 &&
AddedAlarms.Count == 0 && RemovedAlarms.Count == 0 && ChangedAlarms.Count == 0 &&
AddedGalaxyTags.Count == 0 && RemovedGalaxyTags.Count == 0 && ChangedGalaxyTags.Count == 0 &&
AddedEquipmentTags.Count == 0 && RemovedEquipmentTags.Count == 0 && ChangedEquipmentTags.Count == 0 &&
AddedEquipmentVirtualTags.Count == 0 && RemovedEquipmentVirtualTags.Count == 0 && ChangedEquipmentVirtualTags.Count == 0;
public sealed record EquipmentDelta(EquipmentNode Previous, EquipmentNode Current);
public sealed record DriverDelta(DriverInstancePlan Previous, DriverInstancePlan Current);
public sealed record AlarmDelta(ScriptedAlarmPlan Previous, ScriptedAlarmPlan Current);
public sealed record GalaxyTagDelta(GalaxyTagPlan Previous, GalaxyTagPlan Current);
public sealed record EquipmentTagDelta(EquipmentTagPlan Previous, EquipmentTagPlan Current);
public sealed record EquipmentVirtualTagDelta(EquipmentVirtualTagPlan Previous, EquipmentVirtualTagPlan Current);
}
@@ -102,11 +97,6 @@ public static class Phase7Planner
a => a.ScriptedAlarmId,
(a, b) => new Phase7Plan.AlarmDelta(a, b));
var (addedGalaxy, removedGalaxy, changedGalaxy) = DiffById(
previous.GalaxyTags, next.GalaxyTags,
t => t.TagId,
(a, b) => new Phase7Plan.GalaxyTagDelta(a, b));
var (addedEqTags, removedEqTags, changedEqTags) = DiffById(
previous.EquipmentTags, next.EquipmentTags,
t => t.TagId,
@@ -125,8 +115,7 @@ public static class Phase7Planner
return new Phase7Plan(
addedEq, removedEq, changedEq,
addedDrv, removedDrv, changedDrv,
addedAlarm, removedAlarm, changedAlarm,
addedGalaxy, removedGalaxy, changedGalaxy)
addedAlarm, removedAlarm, changedAlarm)
{
AddedEquipmentTags = addedEqTags,
RemovedEquipmentTags = removedEqTags,