merge: equipment-namespace live values (VirtualTag route)
v2-ci / build (push) Failing after 36s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped

This commit is contained in:
Joseph Doherty
2026-06-07 09:33:21 -04:00
17 changed files with 1671 additions and 13 deletions
@@ -1,6 +1,16 @@
# Scope: Equipment-Namespace Materialization in the Live Deploy Path
**Status:** Scoping (not yet a task plan)
> **STATUS UPDATE 2026-06-07 — WS-1/2/4 (structure) and WS-3a (VirtualTag live values) are DONE.**
> The structure milestone shipped on `master` (`febe462..9a67ebc`). **WS-3a — live values via VirtualTags**
> shipped on branch `feat/equipment-namespace-live-values` (plan:
> [`2026-06-07-equipment-namespace-live-values.md`](2026-06-07-equipment-namespace-live-values.md)).
> Verified live in docker-dev: galaxy mirror **396/396 Good**, and the company-shape Equipment namespace
> carries **live Good values** via VirtualTags — every company signal backed by a real galaxy source
> (396 of 1036; the other 640 cite synthetic refs the company UNS model invented beyond the 396 real
> galaxy attributes). Restart-safe (bootstrap restore re-serves Good, no re-deploy). WS-3b (OpcUaClient
> driver route) was **not** taken (the VirtualTag route was chosen). WS-5 (tests) done throughout.
**Status:** WS-1/2/4 + WS-3a DONE (2026-06-07); WS-3b not pursued
**Date:** 2026-06-06
**Author:** investigation while building the Northwind UNS overlay (see `scadaproj/otopcua-uns-loader/`)
**Depends on:** the driver value-streaming fixes already on `master` (`c1ce583`, `b1b3f3f`)
@@ -0,0 +1,403 @@
# Equipment-Namespace Live Values (VirtualTag route) Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development) to implement this plan task-by-task.
**Goal:** Make Equipment-kind namespace signals carry live `Good` OPC UA values by representing each as a `VirtualTag` whose script mirrors a live SystemPlatform (Galaxy-mirror) tag in-process — completing WS-3a of the equipment-namespace materialization scope.
**Architecture:** The structure milestone (`febe462..9a67ebc`) already materialises the Area/Line/Equipment/Signal *folders+variables* for Equipment **Tag** rows (folder-scoped NodeId, `BadWaitingForInitialData`). This milestone adds the **VirtualTag** path: carry `VirtualTag`(+`Script`) rows through the composition/artifact, materialise their variables, and stand up the production VirtualTag actor wiring — a per-deployment `VirtualTagHostActor` that spawns one already-built `VirtualTagActor` per VirtualTag, subscribes each to the `DependencyMuxActor` for its dependency ref(s), and bridges each `EvaluationResult` into `OpcUaPublishActor.AttributeValueUpdate` under the VirtualTag variable's **folder-scoped NodeId**. Cross-namespace reads work because the mux keys subscriptions on a flat `FullReference` string with no namespace scoping. Live Galaxy values already reach the mux via `DriverHostActor.ForwardToMux` (commit `b1b3f3f`).
**Tech Stack:** .NET 10, Akka.NET actors, Roslyn-scripted VirtualTags, OPC UA (sink-based address space), MSSQL config DB, Python loader (pymssql + asyncua).
**Repos touched:** `~/Desktop/OtOpcUa` (the engine work, Tasks 06, 8) and `~/Desktop/scadaproj/otopcua-uns-loader` (the loader + verify, Task 7). Do the OtOpcUa work on a feature branch `feat/equipment-namespace-live-values` off `master` (9a67ebc) — **do not commit on `master`**.
---
## Background: the load-bearing facts (verified in code 2026-06-07)
These were confirmed by reading the actually-wired code, and some **contradict** the convenience summaries that cite `EquipmentNodeWalker` (which is built but **unwired** — ignore it; the live path is the sink-based `Phase7Applier`):
1. **Materialised Equipment variable NodeId is FOLDER-SCOPED, not `FullName` and not `VirtualTagId`.**
`Phase7Applier.MaterialiseEquipmentTags` (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs:215-228`) sets `nodeId = $"{parent}/{tag.Name}"` where `parent` is the equipment folder (or a per-tag sub-folder). The published value must therefore carry **that** NodeId.
2. **`VirtualTagActor` is fully built** (`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs`): `Props(virtualTagId, expression, evaluator, scriptId, publisherFactory, dependencyRefs, mux)`. It self-registers with the mux in `PreStart` (`RegisterInterest(_dependencyRefs, Self)`), evaluates on `DependencyValueChanged`, dedups, and `Context.Parent.Tell(new EvaluationResult(VirtualTagId, value, ts, corr))` (line 147). It is **never spawned in production**.
3. **`DependencyMuxActor`** (`.../VirtualTags/DependencyMuxActor.cs`): `RegisterInterest(IReadOnlyList<string> TagRefs, IActorRef Subscriber)`; `_byRef` is `Dictionary<string, HashSet<IActorRef>>` keyed by the flat `FullReference`**no namespace scoping** (this is what makes cross-namespace mirroring work). On `AttributeValuePublished(FullReference,…)` it fans out `DependencyValueChanged(FullReference, value, ts)` to subscribers.
4. **`RoslynVirtualTagEvaluator`** (`src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynVirtualTagEvaluator.cs`): `Evaluate(virtualTagId, expression, IReadOnlyDictionary<string,object?> dependencies)`. Scripts read deps via `ctx.GetTag("ref").Value`; the dictionary key is the **ref string** (e.g. `TestMachine_001.TestChangingInt`). It is already registered in the Host (`Host/Program.cs`).
5. **VirtualTag declares no explicit dependency list** — deps are the set of `ctx.GetTag("literal")` string literals in the script source.
6. **The artifact snapshot already includes `Scripts` and `VirtualTags`** (`ConfigComposer.SnapshotAndFlattenAsync`, `src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/ConfigComposer.cs:44-45`). **No seal-side change needed.** The gap is only on the *derive* side:
- `Phase7CompositionResult` has no VirtualTag list (`Phase7Composer.cs`).
- `DeploymentArtifact.ParseComposition` builds Galaxy + Equipment tag plans only (`BuildGalaxyTagPlans`, `BuildEquipmentTagPlans`) — no VirtualTag builder.
7. **Spawn/restore hook points** (`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs`): the fresh-apply path calls `PushDesiredSubscriptions(deploymentId)`; the restart path `RestoreApplied()` calls `ReconcileDrivers``RebuildAddressSpace``PushDesiredSubscriptions`. VirtualTag spawning must run on **both** (immediately after `PushDesiredSubscriptions` in each).
8. **Sink resolves the published string NodeId** to the variable created with that same NodeId (`OpcUaPublishActor.HandleAttributeUpdate``_sink.WriteValue(msg.NodeId, …)`). So if the VirtualTag variable is materialised with NodeId `eq/Name` and we publish `AttributeValueUpdate(NodeId="eq/Name")`, it lands.
**The crux:** `VirtualTagActor` emits `EvaluationResult` keyed by `VirtualTagId`, but the variable's NodeId is folder-scoped (`eq/Name`). The new `VirtualTagHostActor` (Task 5) holds the `VirtualTagId → folder-scoped-NodeId` map and translates at the bridge.
---
## Task graph / parallelism
```
T0 (record + composition member)
├─ T1 (composer populate) ┐ parallelizable with each other
├─ T2 (artifact parse) │ (disjoint files)
├─ T3 (Phase7Plan diff) ┘
└─ T5 (VirtualTagHostActor, new file) (parallelizable with T1/T2/T3)
T4 (applier + HandleRebuild) ← T1, T2, T3
T6 (DriverHostActor wiring + evaluator inject) ← T4, T5
T7 (loader VirtualTag rows + verify Good) — scadaproj repo, parallelizable with T1T6
T8 (docker-dev integration verify) ← T6, T7
T9 (docs + memory) ← T8
```
---
### Task 0: `EquipmentVirtualTagPlan` record + carry on `Phase7CompositionResult`
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** none (T1, T2, T3, T5 all depend on this)
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs` (add record + init-only member, mirroring `EquipmentTagPlan`/`EquipmentTags` at lines 58, 94-101)
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ComposerTests.cs` (or the existing composer test file — find it)
**Step 1 — Write the failing test.** Assert the composition default-constructs `EquipmentVirtualTags` to empty and that the record carries the expected fields:
```csharp
[Fact]
public void Composition_carries_empty_equipment_virtualtags_by_default()
{
var r = new Phase7CompositionResult(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>());
Assert.Empty(r.EquipmentVirtualTags);
}
[Fact]
public void EquipmentVirtualTagPlan_holds_id_equipment_name_datatype_expression_and_deps()
{
var p = new EquipmentVirtualTagPlan("vt-1", "eq-1", "", "speed-rpm", "Float64",
"return ctx.GetTag(\"TestMachine_001.TestDouble\").Value;",
new[] { "TestMachine_001.TestDouble" });
Assert.Equal("vt-1", p.VirtualTagId);
Assert.Equal("eq-1", p.EquipmentId);
Assert.Equal("speed-rpm", p.Name);
Assert.Single(p.DependencyRefs);
}
```
**Step 2 — Run, verify it fails to compile** (`EquipmentVirtualTagPlan` undefined).
Run: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ --filter "FullyQualifiedName~Phase7Composer"`
**Step 3 — Add the record + member.** In `Phase7Composer.cs`, after `EquipmentTagPlan` (line ~101) add:
```csharp
/// <summary>
/// One Equipment-namespace VirtualTag from a <see cref="VirtualTag"/> row (joined to its
/// <see cref="Script"/> for the expression). The VirtualTag value analogue of
/// <see cref="EquipmentTagPlan"/>: <c>Phase7Applier.MaterialiseEquipmentVirtualTags</c>
/// materialises each as a Variable under its equipment folder with a folder-scoped NodeId
/// (<c>EquipmentId/Name</c>, or <c>EquipmentId/FolderPath/Name</c> when a sub-folder is set),
/// and <c>VirtualTagHostActor</c> spawns a <c>VirtualTagActor</c> per plan that evaluates
/// <see cref="Expression"/> over <see cref="DependencyRefs"/> and publishes the value back to
/// that NodeId. <see cref="DependencyRefs"/> = the distinct <c>ctx.GetTag("…")</c> literals in
/// the script source.
/// </summary>
public sealed record EquipmentVirtualTagPlan(
string VirtualTagId,
string EquipmentId,
string FolderPath,
string Name,
string DataType,
string Expression,
IReadOnlyList<string> DependencyRefs);
```
And on `Phase7CompositionResult` (after the `EquipmentTags` member, line 58):
```csharp
/// <summary>Equipment-namespace VirtualTags. See <see cref="EquipmentVirtualTagPlan"/>. Init-only,
/// defaults empty so every existing constructor + call site keeps compiling.</summary>
public IReadOnlyList<EquipmentVirtualTagPlan> EquipmentVirtualTags { get; init; } = Array.Empty<EquipmentVirtualTagPlan>();
```
**Step 4 — Run the test, verify PASS.**
**Step 5 — Commit.** `git commit -m "feat(opcua): add EquipmentVirtualTagPlan to Phase7 composition"`
---
### Task 1: Populate `EquipmentVirtualTags` in `Phase7Composer.Compose`
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 2, Task 3, Task 5
**Files:**
- Read first: `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/VirtualTag.cs` and `.../Entities/Script.cs` (confirm exact field names: `VirtualTagId`, `EquipmentId`, `Name`, `DataType`, `ScriptId`; `Script.ScriptId`, `Script.Source`; note whether `VirtualTag` has a `FolderPath` — if not, pass `""`).
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs` (the 7-arg `Compose` overload at lines 155-241; it already receives `scriptedAlarms`**add a `virtualTags` + `scripts` parameter** to that overload and the convenience overloads, defaulting to empty so existing callers compile; thread it from the snapshot. Check the call site in `DeploymentArtifact`/driver-host build path consumes the composition from the artifact, not this overload — so the production producer is Task 2; this overload is used by tests + any direct composer caller).
- Test: the composer test file.
**Design note — dependency extraction.** Replicate the local-helper pattern already used for `ExtractTagFullName` (lines 253-268): add a private `static IReadOnlyList<string> ExtractDependencyRefs(string scriptSource)` that regex-matches `ctx.GetTag("<literal>")` and returns the distinct literals in source order. Do **not** add a project reference to `Core.VirtualTags` (OpcUaServer deliberately doesn't reference the driver/engine assemblies — see the `ExtractTagFullName` comment). Regex: `ctx\s*\.\s*GetTag\s*\(\s*"([^"]+)"\s*\)`.
**Step 1 — Write failing tests.** Given one `VirtualTag{VirtualTagId="vt-1", EquipmentId="eq-1", Name="speed-rpm", DataType="Float64", ScriptId="s-1"}` + `Script{ScriptId="s-1", Source="return ctx.GetTag(\"TestMachine_001.TestDouble\").Value;"}`, `Compose(...)` emits one `EquipmentVirtualTagPlan` with `Expression` = the source and `DependencyRefs` = `["TestMachine_001.TestDouble"]`. Add a second test: a script with two distinct `ctx.GetTag` calls yields two deps, de-duplicated.
**Step 2 — Run, verify fail.**
**Step 3 — Implement.** After the `equipmentTags` block (line ~235) add:
```csharp
var scriptsById = scripts.ToDictionary(s => s.ScriptId, StringComparer.Ordinal);
var equipmentVirtualTags = virtualTags
.OrderBy(v => v.EquipmentId, StringComparer.Ordinal)
.ThenBy(v => v.Name, StringComparer.Ordinal)
.Select(v =>
{
var src = scriptsById.TryGetValue(v.ScriptId, out var s) ? s.Source : string.Empty;
return new EquipmentVirtualTagPlan(
VirtualTagId: v.VirtualTagId,
EquipmentId: v.EquipmentId,
FolderPath: string.Empty, // VirtualTags hang directly under the equipment folder
Name: v.Name,
DataType: v.DataType,
Expression: src,
DependencyRefs: ExtractDependencyRefs(src));
})
.ToList();
```
Add `equipmentVirtualTags` to the returned object initializer (alongside `EquipmentTags`). Add the `ExtractDependencyRefs` helper.
**Step 4 — Run tests, verify PASS.**
**Step 5 — Commit.** `git commit -m "feat(opcua): compose Equipment VirtualTag plans from VirtualTag+Script rows"`
---
### Task 2: Parse `EquipmentVirtualTags` in `DeploymentArtifact`
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 1, Task 3, Task 5
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs` — add `BuildEquipmentVirtualTagPlans(JsonElement root)` mirroring `BuildEquipmentTagPlans` (lines 383-450); call it in **both** `ParseComposition` overloads (set `EquipmentVirtualTags = …` on the result at line ~199, and add the cluster-filter projection at line ~229). The snapshot arrays are `root.GetProperty("VirtualTags")` and `root.GetProperty("Scripts")` (PascalCase — `ConfigComposer` serialises entity property names; verify against the existing `"ScriptedAlarms"`/`"Tags"` reads). Re-derive `DependencyRefs` from `Script.Source` with the **same** regex as Task 1 (keep a single source of truth — put the extractor in a shared internal static helper if both assemblies can see it; otherwise replicate, matching the existing `ExtractFullName` replication pattern, and note the duplication in a comment).
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/` artifact round-trip test file (find the existing `DeploymentArtifact`/`ParseComposition` tests).
**Cluster-filter note:** VirtualTags have no `DriverInstanceId`. Filter the cluster-scoped overload by `EquipmentId` against `sets.EquipmentIds` (mirroring how `ScriptedAlarmPlans` is filtered at line 226: `.Where(a => sets.EquipmentIds.Contains(a.EquipmentId))`).
**Step 1 — Write a failing round-trip test.** Build a snapshot JSON (or use the existing test's snapshot builder) containing a `VirtualTags` array + a matching `Scripts` array; assert `ParseComposition(blob).EquipmentVirtualTags` has the expected single plan with the right `VirtualTagId`, `EquipmentId`, `Name`, `Expression`, and `DependencyRefs`.
**Step 2 — Run, verify fail.**
**Step 3 — Implement** `BuildEquipmentVirtualTagPlans` + wire into both overloads.
**Step 4 — Run tests, verify PASS.** Also run the full `Runtime.Tests` artifact suite to confirm no regression in existing parse tests.
**Step 5 — Commit.** `git commit -m "feat(opcua): parse Equipment VirtualTag plans from the deployment artifact"`
---
### Task 3: `Phase7Plan` diff dimension for Equipment VirtualTags
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 1, Task 2, Task 5
**Files:**
- Read first: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Plan.cs` (the diff record + the differ that produces `Added*/Removed*/Changed*` lists — see how `AddedEquipmentTags`/`RemovedEquipmentTags`/`ChangedEquipmentTags` are computed; `Phase7Applier.Apply` reads them at lines 73-85).
- Modify: `Phase7Plan.cs` — add `AddedEquipmentVirtualTags`/`RemovedEquipmentVirtualTags`/`ChangedEquipmentVirtualTags` (keyed by `VirtualTagId`), populated by the same diff routine that handles `EquipmentTags`.
- Modify: `Phase7Applier.cs` `Apply` — include the new lists in `addedCount`/`changedCount`/`removedCount` and in the `needsRebuild` predicate (lines 71-85), exactly like `EquipmentTags`.
- Test: `Phase7Plan` differ test file — add cases for added/removed/changed VirtualTags driving `needsRebuild = true`.
**Step 1 — Failing test:** a plan with one `AddedEquipmentVirtualTags` entry → `Phase7Applier.Apply` returns `RebuildCalled == true` and `AddedNodes >= 1`.
**Step 2 — Run, verify fail.**
**Step 3 — Implement** the diff dimension + applier accounting.
**Step 4 — Run tests, verify PASS.**
**Step 5 — Commit.** `git commit -m "feat(opcua): diff Equipment VirtualTags in Phase7Plan + rebuild trigger"`
---
### Task 4: Materialise Equipment VirtualTag variables + call in `HandleRebuild`
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (depends on T1, T2, T3)
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs` — add `MaterialiseEquipmentVirtualTags(Phase7CompositionResult composition)` mirroring `MaterialiseEquipmentTags` (lines 197-234) but reading `composition.EquipmentVirtualTags`. **NodeId must be folder-scoped exactly like the tag pass:** `parent = string.IsNullOrWhiteSpace(v.FolderPath) ? v.EquipmentId : EquipmentSubFolderNodeId(v.EquipmentId, v.FolderPath); nodeId = $"{parent}/{v.Name}"`; `SafeEnsureVariable(nodeId, parent, v.Name, v.DataType)`. Log `equipment virtualtags materialised (vtags=…, equipment=…)`.
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs` — in `HandleRebuild`, after the `MaterialiseEquipmentTags(composition)` call, add `applier.MaterialiseEquipmentVirtualTags(composition);` (same applier instance/order).
- Test: `Phase7ApplierTests` — a composition with one `EquipmentVirtualTagPlan` ensures exactly one Variable at the folder-scoped NodeId with `BadWaitingForInitialData` (use the capturing/fake `IOpcUaAddressSpaceSink`).
**Step 1 — Failing test** asserting the captured sink got `EnsureVariable("eq-1/speed-rpm", "eq-1", "speed-rpm", "Float64")`.
**Step 2 — Run, verify fail.**
**Step 3 — Implement** `MaterialiseEquipmentVirtualTags` + the `HandleRebuild` call.
**Step 4 — Run tests, verify PASS.** Run the full `OpcUaServer.Tests` to confirm no materialiser regression.
**Step 5 — Commit.** `git commit -m "feat(opcua): materialise Equipment VirtualTag variables on rebuild"`
---
### Task 5: `VirtualTagHostActor` — spawn, subscribe, bridge results
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 1, Task 2, Task 3
**Files:**
- Read first: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs` (Props + `EvaluationResult` shape) and `.../OpcUa/OpcUaPublishActor.cs` (the `AttributeValueUpdate` record shape + its fully-qualified name, used in `DriverHostActor.ForwardToMux`).
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs`
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs` (Akka.TestKit — find the existing TestKit base used by the runtime tests).
**Behaviour.** A supervisor that owns the live set of VirtualTag child actors and bridges their results to the publish actor:
```csharp
public sealed class VirtualTagHostActor : ReceiveActor
{
public sealed record ApplyVirtualTags(IReadOnlyList<EquipmentVirtualTagPlan> Plans);
private readonly IActorRef _publishActor;
private readonly IActorRef? _mux;
private readonly IVirtualTagEvaluator _evaluator;
private readonly Dictionary<string, IActorRef> _children = new(StringComparer.Ordinal); // vtagId -> child
private readonly Dictionary<string, string> _nodeIdByVtag = new(StringComparer.Ordinal); // vtagId -> folder-scoped NodeId
public static Props Props(IActorRef publishActor, IActorRef? mux, IVirtualTagEvaluator evaluator) =>
Akka.Actor.Props.Create(() => new VirtualTagHostActor(publishActor, mux, evaluator));
public VirtualTagHostActor(IActorRef publishActor, IActorRef? mux, IVirtualTagEvaluator evaluator)
{
_publishActor = publishActor; _mux = mux; _evaluator = evaluator;
Receive<ApplyVirtualTags>(OnApply);
Receive<VirtualTagActor.EvaluationResult>(OnResult);
}
private void OnApply(ApplyVirtualTags msg)
{
var desired = msg.Plans.ToDictionary(p => p.VirtualTagId, StringComparer.Ordinal);
// Stop children no longer present.
foreach (var id in _children.Keys.Where(k => !desired.ContainsKey(k)).ToList())
{
Context.Stop(_children[id]);
_children.Remove(id); _nodeIdByVtag.Remove(id);
}
// Spawn newly-added children; rebuild the NodeId map for all.
foreach (var p in msg.Plans)
{
var parent = string.IsNullOrWhiteSpace(p.FolderPath) ? p.EquipmentId : $"{p.EquipmentId}/{p.FolderPath}";
_nodeIdByVtag[p.VirtualTagId] = $"{parent}/{p.Name}";
if (_children.ContainsKey(p.VirtualTagId)) continue;
var child = Context.ActorOf(
VirtualTagActor.Props(p.VirtualTagId, p.Expression, _evaluator,
scriptId: p.VirtualTagId, publisherFactory: null,
dependencyRefs: p.DependencyRefs, mux: _mux),
name: Akka.Util.Internal.ActorNameUtils... /* sanitise vtagId to a legal actor name; see note */);
_children[p.VirtualTagId] = child;
}
}
private void OnResult(VirtualTagActor.EvaluationResult r)
{
if (!_nodeIdByVtag.TryGetValue(r.VirtualTagId, out var nodeId)) return;
_publishActor.Tell(new OpcUaPublishActor.AttributeValueUpdate(
nodeId, r.Value, OpcUaQuality.Good, r.TimestampUtc));
}
}
```
**Notes for the implementer:**
- Child actor **name** must be a legal Akka name — sanitise the VirtualTagId (replace `/`,`.`,`:` etc.) or use `Context.ActorOf(props)` (auto-named) and key `_children` by vtagId only. Auto-naming is simplest and avoids collisions; prefer it.
- `OpcUaQuality.Good` — confirm the enum/namespace (`Commons.OpcUa`?) used by `AttributeValueUpdate`. Match what `DriverHostActor.ForwardToMux` passes.
- `AttributeValueUpdate` is a nested record on `OpcUaPublishActor` — use its real fully-qualified name as in `DriverHostActor`.
- Because the child self-registers with the mux in `PreStart`, **no explicit `RegisterInterest` send is needed here** — just spawn with the right `mux` + `dependencyRefs`.
**Step 1 — Failing tests (TestKit):**
1. `ApplyVirtualTags` with one plan spawns one child (assert `Context.Child` count / a probe).
2. When the host receives a `VirtualTagActor.EvaluationResult("vt-1", 42.0, ts, corr)` and it has a plan mapping `vt-1 → eq-1/speed-rpm`, the **publishActor probe** receives `AttributeValueUpdate("eq-1/speed-rpm", 42.0, Good, ts)`.
3. A second `ApplyVirtualTags` without `vt-1` stops the child (watch + `ExpectTerminated`).
**Step 2 — Run, verify fail.**
**Step 3 — Implement** `VirtualTagHostActor`.
**Step 4 — Run tests, verify PASS.**
**Step 5 — Commit.** `git commit -m "feat(runtime): VirtualTagHostActor spawns VTag actors + bridges results to OPC UA"`
---
### Task 6: Wire `VirtualTagHostActor` into `DriverHostActor` (apply + restore) + inject the evaluator
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** none (depends on T4, T5)
**Files:**
- Read first: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs` (confirm: `_dependencyMux` is non-null in production — i.e. the mux **is** spawned; `_opcUaPublishActor` ref; the `PushDesiredSubscriptions(deploymentId)` call sites in the apply path and in `RestoreApplied`; how the composition is loaded there — reuse the same `DeploymentArtifact.ParseComposition` result already loaded for `PushDesiredSubscriptions` so we don't parse twice). **If `_dependencyMux` is null in prod, that's a blocker — stop and report** (the value-streaming fix `b1b3f3f` added `ForwardToMux`, so it should be live; verify).
- Read first: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs` and wherever `DriverHostActor.Props` is constructed (the Host driver-role startup) to thread an `IVirtualTagEvaluator` into `DriverHostActor` (the Roslyn evaluator is already DI-registered in the Host).
- Modify: `DriverHostActor.cs` — accept `IVirtualTagEvaluator` in `Props`/ctor; spawn one `VirtualTagHostActor` child in `PreStart` (or lazily on first apply) with `(_opcUaPublishActor, _dependencyMux, evaluator)`; after **each** `PushDesiredSubscriptions(deploymentId)` (apply path **and** `RestoreApplied`), load the composition and `_virtualTagHost.Tell(new VirtualTagHostActor.ApplyVirtualTags(composition.EquipmentVirtualTags))`.
- Modify: the Host startup that builds `DriverHostActor.Props` to pass the resolved `IVirtualTagEvaluator`.
- Test: extend the `DriverHostActor` apply/restore tests (the ones that assert `SetDesiredSubscriptions` is pushed) to also assert an `ApplyVirtualTags` is sent to the spawned host with the composition's VirtualTags on **both** apply and restore. Use the existing TestKit harness + a probe evaluator/publish.
**Step 1 — Failing test:** on apply of a deployment whose artifact has one Equipment VirtualTag, the driver host sends `ApplyVirtualTags([that plan])` to its VirtualTag host child; repeat for the restore path.
**Step 2 — Run, verify fail.**
**Step 3 — Implement** the wiring (ctor param, child spawn, two `ApplyVirtualTags` sends, Host Props threading).
**Step 4 — Run tests, verify PASS.** Run the full `Runtime.Tests` driver-host suite — this path is load-bearing for the live galaxy mirror; **no regression** in `SetDesiredSubscriptions`/restore behaviour is acceptable.
**Step 5 — Commit.** `git commit -m "feat(runtime): spawn+apply VirtualTagHostActor on deploy apply and restore"`
---
### Task 7: Loader emits VirtualTag+Script rows; `verify-equipment` asserts live values
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 16 (different repo)
**Repo:** `~/Desktop/scadaproj/otopcua-uns-loader` (commit on `scadaproj` `main` per existing convention; this dir is not a nested repo).
**Files:**
- Modify: `otopcua_uns.py` — change `cmd_populate_equipment` so each company signal is loaded as a **VirtualTag** (a `VirtualTag` row + a `Script` row), **not** a driver `Tag`. The script source mirrors the live SystemPlatform mirror tag for that signal: `return ctx.GetTag("<machine>.<signal>").Value;` where `<machine>.<signal>` is the galaxy-mirror MXAccess ref (the same `source.fullTagReference` already in `company-uns.json`). Reuse the existing `nweq-…` id scheme for `VirtualTagId`/`ScriptId`. Keep the namespace `nw-uns` (Equipment-kind), the UnsArea/UnsLine/Equipment rows, and the friendly DisplayNames unchanged.
- Modify: `cmd_verify_equipment` — after browsing the company tree (still `nw-area-*` scoped, `--expect 1036`), additionally read each leaf's value and assert a `--require-good N` count of `Good` values (default 0 to stay back-compat; pass `--require-good 1036` post-deploy once values settle). Galaxy mirror tags change over time, so allow a `--wait` poll (reuse the `verify --wait` polling helper) before asserting Good.
- Read first: confirm the config-DB schema for `VirtualTag` + `Script` (table/column names, NOT-NULL columns, `SET QUOTED_IDENTIFIER ON` need like `Tag`) by inspecting the live DB (`otopcua-dev-sql-1`, port 14330, `OtOpcUa!Dev123`) — `SELECT TOP 0 * FROM VirtualTag; SELECT TOP 0 * FROM Script;`. Confirm an Equipment namespace with **only** VirtualTags (no driver Tags) passes the deploy `DraftValidator` (the namespace-kind↔driver rule). If a driver is still required for the namespace, keep the existing `nw-uns-modbus` stub driver but bind **no** Tags to it.
**Step 1 — Dry-run the SQL shape** against the live DB (read-only `SELECT TOP 0`), confirm columns. **Step 2 — Implement** the VirtualTag/Script upsert (mirror the existing idempotent upsert-by-natural-key + `nweq-` prefix so `clean` still removes exactly what it created). **Step 3 — `populate-equipment`** then headless deploy then `verify-equipment --expect 1036` (structure) — should still pass (variables now come from VirtualTags). **Step 4** — extend `verify-equipment` to optionally assert Good. **Step 5 — Commit** on `scadaproj` `main`: `git commit -m "feat(loader): company overlay as VirtualTags mirroring the galaxy mirror + verify live values"` (do NOT commit the `.venv`).
---
### Task 8: docker-dev end-to-end — deploy and verify live `Good` values
**Classification:** standard
**Estimated implement time:** ~5 min (plus deploy/settle wait)
**Parallelizable with:** none (depends on T6, T7)
**Steps (no new code — this is the integration gate):**
1. Build the docker-dev image with the Task 06 changes: `cd ~/Desktop/OtOpcUa/docker-dev && docker compose build admin-a && docker compose up -d` (all 4 host nodes share `otopcua-host:dev`). Confirm the galaxy mirror restores live (`b1b3f3f` RestoreApplied): 396 `Good` on `:4840`.
2. From the loader: `populate-equipment``curl -s -X POST http://localhost:9200/api/deployments -H 'X-Api-Key: docker-dev-deploy-key'` → wait for the `equipment virtualtags materialised (vtags=1036…)` + a driver-host `ApplyVirtualTags` log line.
3. `verify-equipment --expect 1036 --require-good 1036 --wait` — assert the company tree browses **and** every leaf reaches `Good` (allow the poll for the first change-triggered evaluation per VirtualTag; note in output any that stay `BadWaitingForInitialData` because their upstream galaxy tag hasn't changed yet — those are expected for genuinely-static signals, not a failure of the wiring).
4. **Restart safety:** `docker restart otopcua-dev-driver-a-1 otopcua-dev-driver-b-1`; without re-deploying, confirm the company VirtualTags return to `Good` (the `RestoreApplied``ApplyVirtualTags` path).
**Acceptance:** company-shape leaves carry live `Good` values, and survive a driver-node restart with no re-deploy. Record the deploy id + any static-signal exceptions.
**If integration fails:** prefer an inline fix in the most-likely file (the bridge NodeId in Task 5, or the apply/restore wiring in Task 6); only dispatch the debugger subagent (timeboxed ~10 min) if the cause isn't obvious from logs.
---
### Task 9: Docs + memory update
**Classification:** trivial
**Estimated implement time:** ~3 min
**Parallelizable with:** none (depends on T8)
**Files:**
- Modify: `~/Desktop/scadaproj/otopcua-uns-loader/README.md` — flip the "Company-shape overlay" section from "structure-only / BadWaitingForInitialData" to "live values via VirtualTags"; document `--require-good`.
- Modify: `OtOpcUa/docs/plans/2026-06-06-equipment-namespace-materialization-scope.md` — mark WS-3a **done**.
- Update memory: `galaxy-uns-project-state.md` + `otopcua-uns-deploy-and-value-streaming.md` (company shape now carries live values; route taken = VirtualTag; commits + deploy id). Refresh `MEMORY.md` hooks.
- **Do not** auto-merge to `master`/push — the finishing-a-development-branch step presents merge/PR options to the user.
**Commit:** `git commit -m "docs: company-shape UNS now carries live values (WS-3a done)"`
---
## After all tasks
Use **superpowers-extended-cc:finishing-a-development-branch**: verify the OtOpcUa test suite is green (note known pre-existing reds — live-infra integration tests), then present merge/PR/keep/discard options for `feat/equipment-namespace-live-values` (OtOpcUa) and the `scadaproj` loader commit. Merge/push only on the user's explicit go (per the project's standing rule).
@@ -0,0 +1,16 @@
{
"planPath": "docs/plans/2026-06-07-equipment-namespace-live-values.md",
"tasks": [
{"id": 0, "subject": "Task 0: EquipmentVirtualTagPlan record + composition member", "status": "completed", "classification": "small"},
{"id": 1, "subject": "Task 1: Populate EquipmentVirtualTags in Phase7Composer.Compose", "status": "completed", "classification": "standard", "blockedBy": [0], "parallelizableWith": [2, 3, 5]},
{"id": 2, "subject": "Task 2: Parse EquipmentVirtualTags in DeploymentArtifact", "status": "completed", "classification": "standard", "blockedBy": [0], "parallelizableWith": [1, 3, 5]},
{"id": 3, "subject": "Task 3: Phase7Plan diff dimension for Equipment VirtualTags", "status": "completed", "classification": "standard", "blockedBy": [0], "parallelizableWith": [1, 2, 5]},
{"id": 4, "subject": "Task 4: Materialise Equipment VirtualTag variables + HandleRebuild call", "status": "completed", "classification": "standard", "blockedBy": [1, 2, 3]},
{"id": 5, "subject": "Task 5: VirtualTagHostActor (spawn, subscribe, bridge results)", "status": "completed", "classification": "high-risk", "blockedBy": [0], "parallelizableWith": [1, 2, 3]},
{"id": 6, "subject": "Task 6: Wire VirtualTagHostActor into DriverHostActor (apply+restore) + evaluator inject", "status": "completed", "classification": "high-risk", "blockedBy": [4, 5]},
{"id": 7, "subject": "Task 7: Loader emits VirtualTag+Script rows; verify-equipment asserts live values", "status": "completed", "classification": "standard", "parallelizableWith": [1, 2, 3, 4, 5, 6]},
{"id": 8, "subject": "Task 8: docker-dev end-to-end deploy + verify live Good values", "status": "completed", "classification": "standard", "blockedBy": [6, 7]},
{"id": 9, "subject": "Task 9: Docs + memory update", "status": "completed", "classification": "trivial", "blockedBy": [8]}
],
"lastUpdated": "2026-06-07"
}
@@ -70,19 +70,24 @@ public sealed class Phase7Applier
var changedCount =
plan.ChangedEquipment.Count + plan.ChangedDrivers.Count + plan.ChangedAlarms.Count +
plan.ChangedGalaxyTags.Count + plan.ChangedEquipmentTags.Count;
plan.ChangedGalaxyTags.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.AddedGalaxyTags.Count + plan.AddedEquipmentTags.Count +
plan.AddedEquipmentVirtualTags.Count;
// Any add/remove of Equipment, ScriptedAlarm, Galaxy tag, or Equipment tag 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, 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.
// 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.AddedEquipmentTags.Count > 0 || plan.RemovedEquipmentTags.Count > 0 ||
plan.AddedEquipmentVirtualTags.Count > 0 || plan.RemovedEquipmentVirtualTags.Count > 0;
if (needsRebuild)
{
@@ -233,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) =>
@@ -56,6 +56,10 @@ public sealed record Phase7CompositionResult(
/// constructor + call site keeps compiling unchanged; new producers set it via initializer.
/// </summary>
public IReadOnlyList<EquipmentTagPlan> EquipmentTags { get; init; } = Array.Empty<EquipmentTagPlan>();
/// <summary>Equipment-namespace VirtualTags. See <see cref="EquipmentVirtualTagPlan"/>. Init-only,
/// defaults empty so every existing constructor + call site keeps compiling.</summary>
public IReadOnlyList<EquipmentVirtualTagPlan> EquipmentVirtualTags { get; init; } = Array.Empty<EquipmentVirtualTagPlan>();
}
public sealed record UnsAreaProjection(string UnsAreaId, string DisplayName);
@@ -100,6 +104,50 @@ public sealed record EquipmentTagPlan(
string DataType,
string FullName);
/// <summary>
/// One Equipment-namespace VirtualTag from a <see cref="VirtualTag"/> row (joined to its
/// <see cref="Script"/> for the expression). The VirtualTag value analogue of
/// <see cref="EquipmentTagPlan"/>: <c>Phase7Applier.MaterialiseEquipmentVirtualTags</c>
/// materialises each as a Variable under its equipment folder with a folder-scoped NodeId
/// (<c>EquipmentId/Name</c>, or <c>EquipmentId/FolderPath/Name</c> when a sub-folder is set),
/// and <c>VirtualTagHostActor</c> spawns a <c>VirtualTagActor</c> per plan that evaluates
/// <see cref="Expression"/> over <see cref="DependencyRefs"/> and publishes the value back to
/// that NodeId. <see cref="DependencyRefs"/> = the distinct <c>ctx.GetTag("…")</c> literals in
/// the script source.
/// </summary>
public sealed record EquipmentVirtualTagPlan(
string VirtualTagId,
string EquipmentId,
string FolderPath,
string Name,
string DataType,
string Expression,
IReadOnlyList<string> DependencyRefs)
{
/// <summary>Structural equality: the auto-generated record equality would compare
/// <see cref="DependencyRefs"/> (an interface-typed list) BY REFERENCE, flagging every
/// VirtualTag as "changed" on every parse (fresh list instances). Compare it element-wise
/// so a no-op redeploy diffs empty.</summary>
public bool Equals(EquipmentVirtualTagPlan? other) =>
other is not null &&
VirtualTagId == other.VirtualTagId &&
EquipmentId == other.EquipmentId &&
FolderPath == other.FolderPath &&
Name == other.Name &&
DataType == other.DataType &&
Expression == other.Expression &&
DependencyRefs.SequenceEqual(other.DependencyRefs, StringComparer.Ordinal);
public override int GetHashCode()
{
var hash = new HashCode();
hash.Add(VirtualTagId); hash.Add(EquipmentId); hash.Add(FolderPath);
hash.Add(Name); hash.Add(DataType); hash.Add(Expression);
foreach (var r in DependencyRefs) hash.Add(r, StringComparer.Ordinal);
return hash.ToHashCode();
}
}
/// <summary>
/// Pure composer that flattens the live-edit DB tables into the address-space build plan a
/// driver-role host needs. Same inputs → same outputs, no logging, no DB writes. The driver-role
@@ -151,6 +199,8 @@ public static class Phase7Composer
/// <param name="scriptedAlarms">The scripted alarms.</param>
/// <param name="tags">The tags.</param>
/// <param name="namespaces">The namespaces.</param>
/// <param name="virtualTags">The Equipment-namespace virtual (calculated) tags. <c>null</c> = none.</param>
/// <param name="scripts">The scripts joined to <paramref name="virtualTags"/> by ScriptId for the expression. <c>null</c> = none.</param>
/// <returns>The composition result.</returns>
public static Phase7CompositionResult Compose(
IReadOnlyList<UnsArea> unsAreas,
@@ -159,8 +209,12 @@ public static class Phase7Composer
IReadOnlyList<DriverInstance> driverInstances,
IReadOnlyList<ScriptedAlarm> scriptedAlarms,
IReadOnlyList<Tag> tags,
IReadOnlyList<Namespace> namespaces)
IReadOnlyList<Namespace> namespaces,
IReadOnlyList<VirtualTag>? virtualTags = null,
IReadOnlyList<Script>? scripts = null)
{
var vtags = virtualTags ?? Array.Empty<VirtualTag>();
var resolvedScripts = scripts ?? Array.Empty<Script>();
var areas = unsAreas
.OrderBy(a => a.UnsAreaId, StringComparer.Ordinal)
.Select(a => new UnsAreaProjection(a.UnsAreaId, a.Name))
@@ -234,9 +288,31 @@ public static class Phase7Composer
FullName: ExtractTagFullName(t.TagConfig)))
.ToList();
// Equipment VirtualTags = each VirtualTag joined to its Script (by ScriptId) for the
// expression source. DependencyRefs = the distinct ctx.GetTag("…") literals the
// VirtualTagActor subscribes to. VirtualTag has no FolderPath today → "".
var scriptsById = resolvedScripts.ToDictionary(s => s.ScriptId, StringComparer.Ordinal);
var equipmentVirtualTags = vtags
.OrderBy(v => v.EquipmentId, StringComparer.Ordinal)
.ThenBy(v => v.Name, StringComparer.Ordinal)
.Select(v =>
{
var src = scriptsById.TryGetValue(v.ScriptId, out var s) ? s.SourceCode : string.Empty;
return new EquipmentVirtualTagPlan(
VirtualTagId: v.VirtualTagId,
EquipmentId: v.EquipmentId,
FolderPath: string.Empty,
Name: v.Name,
DataType: v.DataType,
Expression: src,
DependencyRefs: ExtractDependencyRefs(src));
})
.ToList();
return new Phase7CompositionResult(areas, lines, nodes, plans, alarms, galaxyTags)
{
EquipmentTags = equipmentTags,
EquipmentVirtualTags = equipmentVirtualTags,
};
}
@@ -266,4 +342,24 @@ public static class Phase7Composer
catch (JsonException) { /* fall through to raw blob */ }
return tagConfig;
}
private static readonly System.Text.RegularExpressions.Regex GetTagRefRegex =
new(@"ctx\s*\.\s*GetTag\s*\(\s*""([^""]+)""\s*\)", System.Text.RegularExpressions.RegexOptions.Compiled);
/// <summary>Distinct <c>ctx.GetTag("ref")</c> string literals in a VirtualTag script source,
/// in first-seen order — the dependency refs the VirtualTagActor subscribes to.</summary>
/// <param name="scriptSource">The VirtualTag's script source.</param>
/// <returns>The distinct dependency refs in first-seen order.</returns>
private static IReadOnlyList<string> ExtractDependencyRefs(string scriptSource)
{
if (string.IsNullOrWhiteSpace(scriptSource)) return Array.Empty<string>();
var seen = new HashSet<string>(StringComparer.Ordinal);
var result = new List<string>();
foreach (System.Text.RegularExpressions.Match m in GetTagRefRegex.Matches(scriptSource))
{
var r = m.Groups[1].Value;
if (seen.Add(r)) result.Add(r);
}
return result;
}
}
@@ -40,19 +40,36 @@ public sealed record Phase7Plan(
/// <inheritdoc cref="AddedEquipmentTags"/>
public IReadOnlyList<EquipmentTagDelta> ChangedEquipmentTags { get; init; } = Array.Empty<EquipmentTagDelta>();
/// <summary>
/// Equipment-namespace VirtualTag diff sets, keyed by <see cref="EquipmentVirtualTagPlan.VirtualTagId"/>.
/// The value-side analogue of <see cref="AddedEquipmentTags"/>: a VirtualTag carries an
/// <c>Expression</c> evaluated over <c>DependencyRefs</c>, so a deploy that changes ONLY
/// VirtualTags (e.g. a new computed signal or an edited formula) must still produce a
/// non-empty plan and drive a rebuild — without these the diff was blind to VirtualTags and
/// such a deploy silently no-op'd. Added as init-only members (defaulting empty) for the same
/// compile-compatibility reason as <see cref="AddedEquipmentTags"/>.
/// </summary>
public IReadOnlyList<EquipmentVirtualTagPlan> AddedEquipmentVirtualTags { get; init; } = Array.Empty<EquipmentVirtualTagPlan>();
/// <inheritdoc cref="AddedEquipmentVirtualTags"/>
public IReadOnlyList<EquipmentVirtualTagPlan> RemovedEquipmentVirtualTags { get; init; } = Array.Empty<EquipmentVirtualTagPlan>();
/// <inheritdoc cref="AddedEquipmentVirtualTags"/>
public IReadOnlyList<EquipmentVirtualTagDelta> ChangedEquipmentVirtualTags { get; init; } = Array.Empty<EquipmentVirtualTagDelta>();
/// <summary>Gets a value indicating whether the composition plan contains no changes.</summary>
public bool IsEmpty =>
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;
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);
}
public static class Phase7Planner
@@ -95,6 +112,16 @@ public static class Phase7Planner
t => t.TagId,
(a, b) => new Phase7Plan.EquipmentTagDelta(a, b));
// VirtualTags diff by VirtualTagId, mirroring the EquipmentTags pass. EquipmentVirtualTagPlan
// overrides record equality to compare ALL fields by value — scalars (Expression/DataType/
// Name/FolderPath) plus DependencyRefs element-wise (SequenceEqual). So a no-op redeploy (fresh
// list instances, identical contents) correctly diffs to empty; only a real content change is
// flagged as changed.
var (addedVTags, removedVTags, changedVTags) = DiffById(
previous.EquipmentVirtualTags, next.EquipmentVirtualTags,
t => t.VirtualTagId,
(a, b) => new Phase7Plan.EquipmentVirtualTagDelta(a, b));
return new Phase7Plan(
addedEq, removedEq, changedEq,
addedDrv, removedDrv, changedDrv,
@@ -104,6 +131,9 @@ public static class Phase7Planner
AddedEquipmentTags = addedEqTags,
RemovedEquipmentTags = removedEqTags,
ChangedEquipmentTags = changedEqTags,
AddedEquipmentVirtualTags = addedVTags,
RemovedEquipmentVirtualTags = removedVTags,
ChangedEquipmentVirtualTags = changedVTags,
};
}
@@ -193,10 +193,12 @@ public static class DeploymentArtifact
var alarms = ReadArray(root, "ScriptedAlarms", ReadAlarmPlan);
var galaxyTags = BuildGalaxyTagPlans(root, drivers);
var equipmentTags = BuildEquipmentTagPlans(root);
var equipmentVirtualTags = BuildEquipmentVirtualTagPlans(root);
return new Phase7CompositionResult(areas, lines, equipment, drivers, alarms, galaxyTags)
{
EquipmentTags = equipmentTags,
EquipmentVirtualTags = equipmentVirtualTags,
};
}
catch (JsonException)
@@ -251,6 +253,7 @@ public static class DeploymentArtifact
full.GalaxyTags.Where(t => sets.DriverIds.Contains(t.DriverInstanceId)).ToArray())
{
EquipmentTags = full.EquipmentTags.Where(t => sets.DriverIds.Contains(t.DriverInstanceId)).ToArray(),
EquipmentVirtualTags = full.EquipmentVirtualTags.Where(v => sets.EquipmentIds.Contains(v.EquipmentId)).ToArray(),
};
}
@@ -490,6 +493,91 @@ public static class DeploymentArtifact
return result;
}
/// <summary>
/// Join the artifact's VirtualTags array to its Scripts array (by ScriptId) to emit one
/// <see cref="EquipmentVirtualTagPlan"/> per VirtualTag. The artifact-decode mirror of
/// <c>Phase7Composer.Compose</c>'s VirtualTag producer — so the compose-side + artifact-decode
/// plans agree. <c>Expression</c> = the joined Script's <c>SourceCode</c> (empty when the
/// ScriptId is absent); <c>DependencyRefs</c> = the distinct <c>ctx.GetTag("…")</c> literals in
/// that source; <c>FolderPath</c> is always "" (VirtualTag has no FolderPath today). Ordered by
/// EquipmentId then Name to match the composer's deterministic ordering.
/// </summary>
private static IReadOnlyList<EquipmentVirtualTagPlan> BuildEquipmentVirtualTagPlans(JsonElement root)
{
if (!root.TryGetProperty("VirtualTags", out var vtArr) || vtArr.ValueKind != JsonValueKind.Array)
return Array.Empty<EquipmentVirtualTagPlan>();
// scriptId → SourceCode (the expression source the VirtualTagActor evaluates).
var scriptSourceById = new Dictionary<string, string>(StringComparer.Ordinal);
if (root.TryGetProperty("Scripts", out var scriptsArr) && scriptsArr.ValueKind == JsonValueKind.Array)
{
foreach (var el in scriptsArr.EnumerateArray())
{
if (el.ValueKind != JsonValueKind.Object) continue;
var sid = el.TryGetProperty("ScriptId", out var sidEl) ? sidEl.GetString() : null;
if (string.IsNullOrWhiteSpace(sid)) continue;
var src = el.TryGetProperty("SourceCode", out var srcEl) && srcEl.ValueKind == JsonValueKind.String
? srcEl.GetString() : null;
scriptSourceById[sid!] = src ?? string.Empty;
}
}
var result = new List<EquipmentVirtualTagPlan>(vtArr.GetArrayLength());
foreach (var el in vtArr.EnumerateArray())
{
if (el.ValueKind != JsonValueKind.Object) continue;
var virtualTagId = el.TryGetProperty("VirtualTagId", out var vidEl) ? vidEl.GetString() : null;
var equipmentId = el.TryGetProperty("EquipmentId", out var eqEl) ? eqEl.GetString() : null;
var name = el.TryGetProperty("Name", out var nmEl) ? nmEl.GetString() : null;
var dataType = el.TryGetProperty("DataType", out var dtEl) ? dtEl.GetString() : null;
var scriptId = el.TryGetProperty("ScriptId", out var sidEl) ? sidEl.GetString() : null;
if (string.IsNullOrWhiteSpace(virtualTagId) || string.IsNullOrWhiteSpace(equipmentId)
|| string.IsNullOrWhiteSpace(name)) continue;
var source = scriptId is not null && scriptSourceById.TryGetValue(scriptId, out var src)
? src : string.Empty;
result.Add(new EquipmentVirtualTagPlan(
VirtualTagId: virtualTagId!,
EquipmentId: equipmentId!,
FolderPath: string.Empty,
Name: name!,
DataType: dataType ?? "BaseDataType",
Expression: source,
DependencyRefs: ExtractDependencyRefs(source)));
}
result.Sort((a, b) =>
{
var byEquipment = string.CompareOrdinal(a.EquipmentId, b.EquipmentId);
return byEquipment != 0 ? byEquipment : string.CompareOrdinal(a.Name, b.Name);
});
return result;
}
private static readonly System.Text.RegularExpressions.Regex GetTagRefRegex =
new(@"ctx\s*\.\s*GetTag\s*\(\s*""([^""]+)""\s*\)", System.Text.RegularExpressions.RegexOptions.Compiled);
/// <summary>
/// Distinct <c>ctx.GetTag("ref")</c> string literals in a VirtualTag script source, in
/// first-seen order. The artifact-decode mirror of <c>Phase7Composer.ExtractDependencyRefs</c>
/// — replicated (with the same regex) because Runtime does not reference the OpcUaServer
/// compose assembly; kept in sync with that copy.
/// </summary>
private static IReadOnlyList<string> ExtractDependencyRefs(string scriptSource)
{
if (string.IsNullOrWhiteSpace(scriptSource)) return Array.Empty<string>();
var seen = new HashSet<string>(StringComparer.Ordinal);
var result = new List<string>();
foreach (System.Text.RegularExpressions.Match m in GetTagRefRegex.Matches(scriptSource))
{
var r = m.Groups[1].Value;
if (seen.Add(r)) result.Add(r);
}
return result;
}
/// <summary>
/// Extract the driver-side full reference from a tag's TagConfig JSON (top-level "FullName"
/// field). The artifact-decode mirror of <c>Phase7Composer.ExtractTagFullName</c> /
@@ -3,6 +3,7 @@ using Akka.Actor;
using Akka.Cluster.Tools.PublishSubscribe;
using Akka.Event;
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
@@ -14,6 +15,7 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
using CommonsNodeId = ZB.MOM.WW.OtOpcUa.Commons.Types.NodeId;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
@@ -52,8 +54,16 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
private readonly IActorRef? _dependencyMux;
private readonly IActorRef? _opcUaPublishActor;
private readonly IDriverHealthPublisher _healthPublisher;
private readonly IVirtualTagEvaluator _virtualTagEvaluator;
private readonly IActorRef? _virtualTagHostOverride;
private readonly ILoggingAdapter _log = Context.GetLogger();
/// <summary>The single VirtualTag-host child that spawns/reconciles Equipment-namespace
/// VirtualTagActors and bridges their results onto the OPC UA publish actor. Spawned in
/// <see cref="PreStart"/> when an OPC UA publish actor is wired; receives
/// <see cref="VirtualTagHostActor.ApplyVirtualTags"/> from <see cref="PushDesiredSubscriptions"/>.</summary>
private IActorRef? _virtualTagHost;
private RevisionHash? _currentRevision;
private DeploymentId? _applyingDeploymentId;
@@ -85,6 +95,14 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
/// <param name="opcUaPublishActor">Optional actor reference for OPC UA publishing.</param>
/// <param name="healthPublisher">Optional driver-health publisher; defaults to <see cref="NullDriverHealthPublisher"/>
/// so test harnesses and smoke fixtures don't need to wire it.</param>
/// <param name="virtualTagEvaluator">Optional evaluator handed to the spawned
/// <see cref="VirtualTagHostActor"/>'s children; defaults to <see cref="NullVirtualTagEvaluator"/>
/// (the dev/Mac path where no expression is evaluated). Production passes the DI-resolved
/// Roslyn evaluator.</param>
/// <param name="virtualTagHostOverride">Test seam: when supplied, this actor is used as the
/// VirtualTag host instead of spawning a real <see cref="VirtualTagHostActor"/> child, so tests
/// can intercept the <see cref="VirtualTagHostActor.ApplyVirtualTags"/> message. Null in
/// production (the real host is spawned).</param>
public static Props Props(
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
CommonsNodeId localNode,
@@ -93,9 +111,12 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
IReadOnlySet<string>? localRoles = null,
IActorRef? dependencyMux = null,
IActorRef? opcUaPublishActor = null,
IDriverHealthPublisher? healthPublisher = null) =>
IDriverHealthPublisher? healthPublisher = null,
IVirtualTagEvaluator? virtualTagEvaluator = null,
IActorRef? virtualTagHostOverride = null) =>
Akka.Actor.Props.Create(() => new DriverHostActor(
dbFactory, localNode, coordinator, driverFactory, localRoles, dependencyMux, opcUaPublishActor, healthPublisher));
dbFactory, localNode, coordinator, driverFactory, localRoles, dependencyMux, opcUaPublishActor,
healthPublisher, virtualTagEvaluator, virtualTagHostOverride));
/// <summary>Initializes a new DriverHostActor with the specified dependencies.</summary>
/// <param name="dbFactory">Database context factory for configuration database access.</param>
@@ -106,6 +127,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
/// <param name="dependencyMux">Optional actor reference for dependency multiplexing.</param>
/// <param name="opcUaPublishActor">Optional actor reference for OPC UA publishing.</param>
/// <param name="healthPublisher">Optional driver-health publisher; defaults to <see cref="NullDriverHealthPublisher"/>.</param>
/// <param name="virtualTagEvaluator">Optional evaluator handed to the VirtualTag host's children;
/// defaults to <see cref="NullVirtualTagEvaluator"/>.</param>
/// <param name="virtualTagHostOverride">Test seam: when supplied, used as the VirtualTag host
/// instead of spawning a real <see cref="VirtualTagHostActor"/> child.</param>
public DriverHostActor(
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
CommonsNodeId localNode,
@@ -114,7 +139,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
IReadOnlySet<string>? localRoles = null,
IActorRef? dependencyMux = null,
IActorRef? opcUaPublishActor = null,
IDriverHealthPublisher? healthPublisher = null)
IDriverHealthPublisher? healthPublisher = null,
IVirtualTagEvaluator? virtualTagEvaluator = null,
IActorRef? virtualTagHostOverride = null)
{
_dbFactory = dbFactory;
_localNode = localNode;
@@ -124,6 +151,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
_dependencyMux = dependencyMux;
_opcUaPublishActor = opcUaPublishActor;
_healthPublisher = healthPublisher ?? NullDriverHealthPublisher.Instance;
_virtualTagEvaluator = virtualTagEvaluator ?? NullVirtualTagEvaluator.Instance;
_virtualTagHostOverride = virtualTagHostOverride;
// Default behavior is Steady — PreStart may flip to Stale or replay an orphan apply.
Become(Steady);
@@ -136,9 +165,40 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
DistributedPubSub.Get(Context.System).Mediator.Tell(new Subscribe(DeploymentsTopic, Self));
// Subscribe to driver-control topic so AdminUI Reconnect/Restart commands land here.
DistributedPubSub.Get(Context.System).Mediator.Tell(new Subscribe(DriverControlTopic, Self));
// Spawn the VirtualTag host BEFORE Bootstrap so the bootstrap-restore path (which routes
// through PushDesiredSubscriptions and Tells ApplyVirtualTags) has a live host to target.
SpawnVirtualTagHost();
Bootstrap();
}
/// <summary>
/// Spawns the single <see cref="VirtualTagHostActor"/> child that owns the Equipment-namespace
/// VirtualTagActors and bridges their results onto the OPC UA publish actor. A test-supplied
/// <c>virtualTagHostOverride</c> short-circuits the spawn so a probe can intercept
/// <see cref="VirtualTagHostActor.ApplyVirtualTags"/>. The real host requires a non-null
/// <see cref="_opcUaPublishActor"/> (its ctor throws otherwise), so when no publish actor is
/// wired (legacy ControlPlane test harnesses with no OPC UA sink) the host is left null and
/// ApplyVirtualTags becomes a no-op — VirtualTags can't have anywhere to publish without it.
/// </summary>
private void SpawnVirtualTagHost()
{
if (_virtualTagHostOverride is not null)
{
_virtualTagHost = _virtualTagHostOverride;
return;
}
if (_opcUaPublishActor is null)
{
_log.Debug("DriverHost {Node}: no OPC UA publish actor wired; skipping VirtualTag host spawn", _localNode);
return;
}
_virtualTagHost = Context.ActorOf(
VirtualTagHostActor.Props(_opcUaPublishActor, _dependencyMux, _virtualTagEvaluator),
"virtual-tag-host");
}
private void Bootstrap()
{
// Read the most-recent NodeDeploymentState for this node; if it's Applied, jump
@@ -459,6 +519,22 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
_log.Info("DriverHost {Node}: SubscribeBulk pushed {Refs} references across {Drivers} driver(s)",
_localNode, total, refsByDriver.Count);
}
// Hand the Equipment-namespace VirtualTags to the host so it spawns/reconciles a
// VirtualTagActor per plan and streams their evaluated values back onto the just-rebuilt
// address space. Runs on BOTH the fresh-apply path (ApplyAndAck) and the bootstrap-restore
// path (RestoreApplied) because both call this method, so one send covers both.
// NOTE: the Stale-recovery path (TryRecoverFromStale) does NOT call PushDesiredSubscriptions,
// so — like drivers — VirtualTags remain empty after a Stale recovery until the next
// deployment dispatch. This is intentional and consistent with driver recovery: the Stale
// path only restores the revision marker + NodeDeploymentState; a subsequent dispatch
// (or a redeploy from AdminUI) triggers the full apply + subscribe pass.
_virtualTagHost?.Tell(new VirtualTagHostActor.ApplyVirtualTags(composition.EquipmentVirtualTags));
if (composition.EquipmentVirtualTags.Count > 0)
{
_log.Info("DriverHost {Node}: applied {Count} Equipment VirtualTag(s) to the VirtualTag host",
_localNode, composition.EquipmentVirtualTags.Count);
}
}
private void SpawnChild(DriverInstanceSpec spec)
@@ -238,6 +238,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})",
@@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Configuration;
@@ -89,6 +90,16 @@ public static class ServiceCollectionExtensions
var serviceLevel = resolver.GetService<IServiceLevelPublisher>() ?? NullServiceLevelPublisher.Instance;
var loggerFactory = resolver.GetService<ILoggerFactory>() ?? NullLoggerFactory.Instance;
var healthPublisher = resolver.GetService<IDriverHealthPublisher>() ?? NullDriverHealthPublisher.Instance;
// Production evaluator is the Host's RoslynVirtualTagEvaluator (registered as
// IVirtualTagEvaluator); fall back to the null evaluator for test harnesses that don't
// register one (VirtualTagActor children then evaluate to nothing).
var virtualTagEvaluator = resolver.GetService<IVirtualTagEvaluator>();
if (virtualTagEvaluator is null)
{
loggerFactory.CreateLogger("ZB.MOM.WW.OtOpcUa.Runtime.ServiceCollectionExtensions")
.LogWarning("IVirtualTagEvaluator not registered; Equipment VirtualTags will evaluate to NoChange (no live values). Expected only in test harnesses — driver-role nodes should register RoslynVirtualTagEvaluator.");
virtualTagEvaluator = NullVirtualTagEvaluator.Instance;
}
var dbHealth = system.ActorOf(
DbHealthProbeActor.Props(dbFactory),
@@ -119,7 +130,8 @@ public static class ServiceCollectionExtensions
driverFactory: driverFactory, localRoles: roleInfo.LocalRoles,
dependencyMux: mux,
opcUaPublishActor: publishActor,
healthPublisher: healthPublisher),
healthPublisher: healthPublisher,
virtualTagEvaluator: virtualTagEvaluator),
DriverHostActorName);
registry.Register<DriverHostActorKey>(driverHost);
@@ -0,0 +1,156 @@
using Akka.Actor;
using Akka.Event;
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
/// <summary>
/// Supervisor that gives Equipment-namespace VirtualTags live values. For each
/// <see cref="EquipmentVirtualTagPlan"/> in the desired set it spawns one child
/// <see cref="VirtualTagActor"/> (which self-registers with the dependency mux and evaluates its
/// expression on dependency changes) and remembers the plan's <b>folder-scoped NodeId</b>. When a
/// child reports a fresh <see cref="VirtualTagActor.EvaluationResult"/>, the host bridges it onto
/// an <see cref="OpcUaPublishActor.AttributeValueUpdate"/> targeting that NodeId so the
/// already-materialised Variable node (currently BadWaitingForInitialData) reflects the value.
///
/// <para>
/// The published NodeId is computed with the <b>identical</b> formula
/// <c>Phase7Applier.MaterialiseEquipmentVirtualTags</c> uses to materialise the variable —
/// <c>{parent}/{Name}</c> where <c>parent = IsNullOrWhiteSpace(FolderPath) ? EquipmentId :
/// {EquipmentId}/{FolderPath}</c> — or the value would land on a NodeId that does not exist.
/// </para>
/// </summary>
public sealed class VirtualTagHostActor : ReceiveActor
{
/// <summary>Reconciles the live VirtualTag children to exactly the supplied desired set:
/// stops children whose vtagId is gone, spawns children for new vtagIds, and rebuilds the
/// vtagId→NodeId map so renames are reflected.</summary>
/// <param name="Plans">The desired Equipment-namespace VirtualTag plans.</param>
public sealed record ApplyVirtualTags(IReadOnlyList<EquipmentVirtualTagPlan> Plans);
private readonly IActorRef _publishActor;
private readonly IActorRef? _mux;
private readonly IVirtualTagEvaluator _evaluator;
private readonly ILoggingAdapter _log = Context.GetLogger();
// vtagId -> spawned child VirtualTagActor.
private readonly Dictionary<string, IActorRef> _children = new(StringComparer.Ordinal);
// vtagId -> folder-scoped OPC UA NodeId the materialiser placed the variable at.
private readonly Dictionary<string, string> _nodeIdByVtag = new(StringComparer.Ordinal);
/// <summary>Factory method to create Props for a VirtualTagHostActor.</summary>
/// <param name="publishActor">The OPC UA publish actor that consumes
/// <see cref="OpcUaPublishActor.AttributeValueUpdate"/> bridged from child results.</param>
/// <param name="mux">Optional dependency multiplexer; passed to each spawned child so it can
/// register interest in its dependency refs. Null on the dev/Mac path (no live values).</param>
/// <param name="evaluator">The evaluator each child uses to compute its expression.</param>
public static Props Props(IActorRef publishActor, IActorRef? mux, IVirtualTagEvaluator evaluator) =>
Akka.Actor.Props.Create(() => new VirtualTagHostActor(publishActor, mux, evaluator));
/// <summary>Initializes a new instance of the <see cref="VirtualTagHostActor"/> class.</summary>
/// <param name="publishActor">The OPC UA publish actor results are bridged to.</param>
/// <param name="mux">Optional dependency multiplexer passed to each spawned child.</param>
/// <param name="evaluator">The evaluator each child uses to compute its expression.</param>
public VirtualTagHostActor(IActorRef publishActor, IActorRef? mux, IVirtualTagEvaluator evaluator)
{
ArgumentNullException.ThrowIfNull(publishActor);
ArgumentNullException.ThrowIfNull(evaluator);
_publishActor = publishActor;
_mux = mux;
_evaluator = evaluator;
Receive<ApplyVirtualTags>(OnApply);
Receive<VirtualTagActor.EvaluationResult>(OnResult);
Receive<Terminated>(OnChildTerminated);
}
private void OnApply(ApplyVirtualTags msg)
{
var desired = new HashSet<string>(msg.Plans.Select(p => p.VirtualTagId), StringComparer.Ordinal);
// Stop + forget children whose vtagId is no longer desired. Stopping the child triggers its
// PostStop, which unregisters its interest from the mux.
foreach (var vtagId in _children.Keys.Where(id => !desired.Contains(id)).ToList())
{
Context.Stop(_children[vtagId]);
_children.Remove(vtagId);
}
// Rebuild the NodeId map every apply so renames (Name/FolderPath/EquipmentId changes) are
// picked up. The map only contains currently-desired vtags, so a result for a removed vtag
// finds no entry and is dropped.
_nodeIdByVtag.Clear();
foreach (var p in msg.Plans)
{
_nodeIdByVtag[p.VirtualTagId] = NodeIdFor(p);
}
// Spawn children for new vtagIds only — existing children keep their mux subscriptions and
// last-value dedup state. Expression/dependency changes on an existing vtag are NOT
// re-applied here; the loader's vtags are stable, and a future enhancement can stop+respawn
// a child whose plan changed (the diff already identifies ChangedEquipmentVirtualTags).
foreach (var p in msg.Plans)
{
// TODO(equipment-virtualtags): when a plan's Expression/DependencyRefs change in place
// (ChangedEquipmentVirtualTags), stop+respawn the child here; today only spawn-new/stop-removed
// is handled (loader vtags are stable).
if (_children.ContainsKey(p.VirtualTagId)) continue;
// Auto-name the child: vtagIds can contain characters illegal in actor names, so let Akka
// assign a safe unique name. The child self-registers with the mux in PreStart.
var child = Context.ActorOf(VirtualTagActor.Props(
virtualTagId: p.VirtualTagId,
expression: p.Expression,
evaluator: _evaluator,
scriptId: p.VirtualTagId,
publisherFactory: null,
dependencyRefs: p.DependencyRefs,
mux: _mux));
Context.Watch(child);
_children[p.VirtualTagId] = child;
_log.Debug("VirtualTagHost: spawned child for vtag {VirtualTagId}", p.VirtualTagId);
}
_log.Debug("VirtualTagHost: applied (desired={Desired}, children={Children})",
desired.Count, _children.Count);
}
private void OnResult(VirtualTagActor.EvaluationResult result)
{
// A result may arrive for a vtag that was just removed from the desired set (the child's
// last in-flight message). With no NodeId mapping we have nowhere to land it — drop silently.
if (!_nodeIdByVtag.TryGetValue(result.VirtualTagId, out var nodeId))
{
return;
}
_publishActor.Tell(new OpcUaPublishActor.AttributeValueUpdate(
nodeId, result.Value, OpcUaQuality.Good, result.TimestampUtc));
}
private void OnChildTerminated(Terminated msg)
{
var stale = _children.Where(kv => kv.Value.Equals(msg.ActorRef)).Select(kv => kv.Key).ToList();
foreach (var id in stale)
{
_children.Remove(id);
// NodeId map is rebuilt on the next ApplyVirtualTags; leaving the mapping is harmless
// (no child will publish for it until respawned). A dead child is respawned on next apply.
_log.Warning("VirtualTagHost: child for vtag {VirtualTagId} terminated; will respawn on next apply", id);
}
}
/// <summary>Folder-scoped NodeId for a VirtualTag plan — MUST match
/// <c>Phase7Applier.MaterialiseEquipmentVirtualTags</c> exactly, or the published value lands on a
/// NodeId that was never materialised.</summary>
private static string NodeIdFor(EquipmentVirtualTagPlan p)
{
var parent = string.IsNullOrWhiteSpace(p.FolderPath)
? p.EquipmentId
: $"{p.EquipmentId}/{p.FolderPath}";
return $"{parent}/{p.Name}";
}
}
@@ -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>
@@ -281,6 +335,31 @@ public sealed class Phase7ApplierTests
sink.RebuildCalls.ShouldBe(1);
}
/// <summary>Verifies that added Equipment VirtualTags in an otherwise-empty plan trigger an
/// address-space rebuild (parity with the equipment-tag path — the planner now diffs VirtualTags,
/// so a VirtualTag-only deploy is no longer a silent no-op).</summary>
[Fact]
public void Added_equipment_virtual_tags_trigger_rebuild()
{
var sink = new RecordingSink();
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
var plan = EmptyPlan with
{
AddedEquipmentVirtualTags = new[]
{
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float",
Expression: "a + b", DependencyRefs: new[] { "a", "b" }),
},
};
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeTrue();
outcome.AddedNodes.ShouldBe(1);
sink.RebuildCalls.ShouldBe(1);
}
/// <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()
@@ -91,6 +91,125 @@ public sealed class Phase7ComposerPurityTests
node.DisplayName.ShouldNotBe("FILLING-EQ"); // not the colloquial MachineCode
}
[Fact]
public void Composition_carries_empty_equipment_virtualtags_by_default()
{
var r = new Phase7CompositionResult(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>());
r.EquipmentVirtualTags.ShouldBeEmpty();
}
[Fact]
public void EquipmentVirtualTagPlan_holds_id_equipment_name_datatype_expression_and_deps()
{
var p = new EquipmentVirtualTagPlan("vt-1", "eq-1", "", "speed-rpm", "Float64",
"return ctx.GetTag(\"TestMachine_001.TestDouble\").Value;",
new[] { "TestMachine_001.TestDouble" });
p.VirtualTagId.ShouldBe("vt-1");
p.EquipmentId.ShouldBe("eq-1");
p.Name.ShouldBe("speed-rpm");
p.DependencyRefs.ShouldHaveSingleItem();
}
/// <summary>Compose joins a <see cref="VirtualTag"/> to its <see cref="Script"/> by ScriptId,
/// emitting one <see cref="EquipmentVirtualTagPlan"/> carrying the script source as the
/// Expression and the parsed <c>ctx.GetTag("…")</c> literals as DependencyRefs.</summary>
[Fact]
public void Compose_emits_equipment_virtualtag_plan_joined_to_script()
{
var vt = new VirtualTag
{
VirtualTagId = "vt-1",
EquipmentId = "eq-1",
Name = "speed-rpm",
DataType = "Float64",
ScriptId = "s-1",
};
var script = new Script
{
ScriptId = "s-1",
Name = "speed-script",
SourceCode = "return ctx.GetTag(\"TestMachine_001.TestDouble\").Value;",
SourceHash = "hash-1",
};
var result = Phase7Composer.Compose(
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
Array.Empty<DriverInstance>(), Array.Empty<ScriptedAlarm>(),
Array.Empty<Tag>(), Array.Empty<Namespace>(),
virtualTags: new[] { vt },
scripts: new[] { script });
var plan = result.EquipmentVirtualTags.ShouldHaveSingleItem();
plan.VirtualTagId.ShouldBe("vt-1");
plan.EquipmentId.ShouldBe("eq-1");
plan.FolderPath.ShouldBe("");
plan.Name.ShouldBe("speed-rpm");
plan.DataType.ShouldBe("Float64");
plan.Expression.ShouldBe("return ctx.GetTag(\"TestMachine_001.TestDouble\").Value;");
plan.DependencyRefs.ShouldBe(new[] { "TestMachine_001.TestDouble" });
}
/// <summary>DependencyRefs are the distinct <c>ctx.GetTag("…")</c> literals in first-seen
/// order — a repeated ref collapses to one.</summary>
[Fact]
public void Compose_extracts_distinct_dependency_refs()
{
var vt = new VirtualTag
{
VirtualTagId = "vt-1",
EquipmentId = "eq-1",
Name = "sum",
DataType = "Float64",
ScriptId = "s-1",
};
var script = new Script
{
ScriptId = "s-1",
Name = "sum-script",
SourceCode = "return ctx.GetTag(\"A.X\").Value + ctx.GetTag(\"B.Y\").Value + ctx.GetTag(\"A.X\").Value;",
SourceHash = "hash-1",
};
var result = Phase7Composer.Compose(
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
Array.Empty<DriverInstance>(), Array.Empty<ScriptedAlarm>(),
Array.Empty<Tag>(), Array.Empty<Namespace>(),
virtualTags: new[] { vt },
scripts: new[] { script });
var plan = result.EquipmentVirtualTags.ShouldHaveSingleItem();
plan.DependencyRefs.ShouldBe(new[] { "A.X", "B.Y" });
}
/// <summary>A <see cref="VirtualTag"/> whose <c>ScriptId</c> has no matching <see cref="Script"/>
/// in the supplied list falls back to an empty Expression and an empty DependencyRefs —
/// the plan is always emitted (never dropped) and never carries a null Expression.</summary>
[Fact]
public void Compose_virtualtag_with_missing_script_yields_empty_expression_and_deps()
{
var vt = new VirtualTag
{
VirtualTagId = "vt-missing",
EquipmentId = "eq-1",
Name = "mystery-tag",
DataType = "Float64",
ScriptId = "s-does-not-exist",
};
var result = Phase7Composer.Compose(
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
Array.Empty<DriverInstance>(), Array.Empty<ScriptedAlarm>(),
Array.Empty<Tag>(), Array.Empty<Namespace>(),
virtualTags: new[] { vt },
scripts: Array.Empty<Script>());
var plan = result.EquipmentVirtualTags.ShouldHaveSingleItem();
plan.VirtualTagId.ShouldBe("vt-missing");
plan.Expression.ShouldBe(string.Empty);
plan.DependencyRefs.ShouldBeEmpty();
}
private static Equipment NewEquipment(string id) => new()
{
EquipmentId = id,
@@ -55,6 +55,128 @@ public sealed class Phase7PlannerTests
plan.ChangedEquipmentTags.ShouldBeEmpty();
}
/// <summary>Verifies a VirtualTag-only delta (no equipment/driver/alarm/galaxy/tag change)
/// yields a NON-empty plan with the new VirtualTag in AddedEquipmentVirtualTags, so a deploy that
/// only adds VirtualTags is no longer a silent no-op at the IsEmpty gate.</summary>
[Fact]
public void Equipment_virtual_tag_only_change_yields_non_empty_plan_with_added_tag()
{
var prev = new Phase7CompositionResult(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>());
var next = new Phase7CompositionResult(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentVirtualTags = new[]
{
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float",
Expression: "a + b", DependencyRefs: new[] { "a", "b" }),
},
};
var plan = Phase7Planner.Compute(prev, next);
plan.IsEmpty.ShouldBeFalse();
plan.AddedEquipmentVirtualTags.Single().VirtualTagId.ShouldBe("vt-1");
plan.RemovedEquipmentVirtualTags.ShouldBeEmpty();
plan.ChangedEquipmentVirtualTags.ShouldBeEmpty();
}
/// <summary>Verifies a disappeared VirtualTag routes to RemovedEquipmentVirtualTags.</summary>
[Fact]
public void Disappeared_virtual_tag_goes_to_RemovedEquipmentVirtualTags()
{
var prev = new Phase7CompositionResult(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentVirtualTags = new[]
{
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float",
Expression: "a + b", DependencyRefs: new[] { "a", "b" }),
},
};
var next = new Phase7CompositionResult(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>());
var plan = Phase7Planner.Compute(prev, next);
plan.IsEmpty.ShouldBeFalse();
plan.RemovedEquipmentVirtualTags.Single().VirtualTagId.ShouldBe("vt-1");
plan.AddedEquipmentVirtualTags.ShouldBeEmpty();
plan.ChangedEquipmentVirtualTags.ShouldBeEmpty();
}
/// <summary>Verifies a VirtualTag with the same id but a different Expression routes to
/// ChangedEquipmentVirtualTags (the diff identity is VirtualTagId; any field difference,
/// including the evaluated Expression, moves it from stable to changed).</summary>
[Fact]
public void Same_id_with_different_expression_routes_to_ChangedEquipmentVirtualTags()
{
var prev = new Phase7CompositionResult(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentVirtualTags = new[]
{
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float",
Expression: "a + b", DependencyRefs: new[] { "a", "b" }),
},
};
var next = new Phase7CompositionResult(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentVirtualTags = new[]
{
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float",
Expression: "a - b", DependencyRefs: new[] { "a", "b" }),
},
};
var plan = Phase7Planner.Compute(prev, next);
plan.IsEmpty.ShouldBeFalse();
plan.ChangedEquipmentVirtualTags.Single().Previous.Expression.ShouldBe("a + b");
plan.ChangedEquipmentVirtualTags.Single().Current.Expression.ShouldBe("a - b");
plan.AddedEquipmentVirtualTags.ShouldBeEmpty();
plan.RemovedEquipmentVirtualTags.ShouldBeEmpty();
}
/// <summary>Regression guard for structural equality on <see cref="EquipmentVirtualTagPlan.DependencyRefs"/>:
/// two snapshots containing the SAME VirtualTag built from SEPARATE list instances must diff to an empty plan
/// (IReadOnlyList equality is BY REFERENCE without the custom Equals override, so every VirtualTag with
/// dependencies would be wrongly flagged "Changed" on every parse, preventing IsEmpty short-circuits).</summary>
[Fact]
public void Identical_virtualtag_snapshots_diff_to_empty_plan()
{
// Two separate list instances with identical contents — proves structural (not reference) equality.
var refsA = new[] { "EQ1.Speed", "EQ1.Torque" };
var refsB = new[] { "EQ1.Speed", "EQ1.Torque" };
var prev = new Phase7CompositionResult(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentVirtualTags = new[]
{
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float",
Expression: "ctx.GetTag(\"EQ1.Speed\") / ctx.GetTag(\"EQ1.Torque\")", DependencyRefs: refsA),
},
};
var next = new Phase7CompositionResult(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentVirtualTags = new[]
{
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float",
Expression: "ctx.GetTag(\"EQ1.Speed\") / ctx.GetTag(\"EQ1.Torque\")", DependencyRefs: refsB),
},
};
var plan = Phase7Planner.Compute(prev, next);
plan.IsEmpty.ShouldBeTrue();
plan.ChangedEquipmentVirtualTags.ShouldBeEmpty();
plan.AddedEquipmentVirtualTags.ShouldBeEmpty();
plan.RemovedEquipmentVirtualTags.ShouldBeEmpty();
}
/// <summary>Verifies that new equipment goes to the AddedEquipment list.</summary>
[Fact]
public void New_equipment_goes_to_AddedEquipment()
@@ -251,6 +251,50 @@ public sealed class DeploymentArtifactTests
c.GalaxyTags.ShouldContain(g => g.TagId == "tag-gx");
}
/// <summary>
/// Verifies ParseComposition surfaces Equipment-namespace VirtualTags (joined to their Script
/// by ScriptId for the expression source) as <c>EquipmentVirtualTags</c>, with the
/// <c>DependencyRefs</c> extracted from the script's <c>ctx.GetTag("…")</c> literals — the
/// artifact-decode mirror of <c>Phase7Composer.Compose</c>'s VirtualTag producer.
/// </summary>
[Fact]
public void ParseComposition_reads_EquipmentVirtualTags_from_virtualtags_and_scripts()
{
var blob = JsonSerializer.SerializeToUtf8Bytes(new
{
Scripts = new[]
{
new
{
ScriptId = "scr-1",
SourceCode = "return ctx.GetTag(\"TestMachine_001.TestDouble\").Value;",
},
},
VirtualTags = new[]
{
new
{
VirtualTagId = "vt-1",
EquipmentId = "eq-1",
Name = "Doubled",
DataType = "Float",
ScriptId = "scr-1",
},
},
});
var c = DeploymentArtifact.ParseComposition(blob);
var vt = c.EquipmentVirtualTags.ShouldHaveSingleItem();
vt.VirtualTagId.ShouldBe("vt-1");
vt.EquipmentId.ShouldBe("eq-1");
vt.Name.ShouldBe("Doubled");
vt.DataType.ShouldBe("Float");
vt.FolderPath.ShouldBe("");
vt.Expression.ShouldBe("return ctx.GetTag(\"TestMachine_001.TestDouble\").Value;");
vt.DependencyRefs.ShouldBe(new[] { "TestMachine_001.TestDouble" });
}
/// <summary>
/// Verifies ParseComposition sets the equipment folder DisplayName to the UNS <c>Name</c>
/// segment — the source the live rebuild actually uses — not the colloquial MachineCode, so
@@ -377,6 +421,47 @@ public sealed class DeploymentArtifactTests
comp.DriverInstancePlans.ShouldBeEmpty();
}
/// <summary>Verifies the cluster-scoped overload keeps only EquipmentVirtualTags whose EquipmentId
/// belongs to an in-cluster driver (mirroring how EquipmentTags + ScriptedAlarms are filtered).</summary>
[Fact]
public void ParseComposition_scoped_keeps_only_my_clusters_virtual_tags()
{
var blob = BlobOf(new
{
Clusters = new[] { new { ClusterId = "MAIN" }, new { ClusterId = "SITE-A" } },
Nodes = new[]
{
new { NodeId = "central-1:4053", ClusterId = "MAIN" },
new { NodeId = "site-a-1:4053", ClusterId = "SITE-A" },
},
DriverInstances = new[]
{
new { DriverInstanceId = "main-modbus", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "MAIN", NamespaceId = "main-ns" },
new { DriverInstanceId = "sa-modbus", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "SITE-A", NamespaceId = "sa-ns" },
},
Equipment = new[]
{
new { EquipmentId = "eq-main", Name = "eqm", UnsLineId = "l1", DriverInstanceId = "main-modbus" },
new { EquipmentId = "eq-sa", Name = "eqs", UnsLineId = "l2", DriverInstanceId = "sa-modbus" },
},
Scripts = new[]
{
new { ScriptId = "scr", SourceCode = "return 1;" },
},
VirtualTags = new[]
{
new { VirtualTagId = "vt-main", EquipmentId = "eq-main", Name = "VM", DataType = "Float", ScriptId = "scr" },
new { VirtualTagId = "vt-sa", EquipmentId = "eq-sa", Name = "VS", DataType = "Float", ScriptId = "scr" },
},
});
var main = DeploymentArtifact.ParseComposition(blob, "central-1:4053");
main.EquipmentVirtualTags.Select(v => v.VirtualTagId).ShouldBe(new[] { "vt-main" });
var siteA = DeploymentArtifact.ParseComposition(blob, "site-a-1:4053");
siteA.EquipmentVirtualTags.Select(v => v.VirtualTagId).ShouldBe(new[] { "vt-sa" });
}
[Fact]
public void ParseComposition_single_cluster_node_id_overload_matches_legacy()
{
@@ -0,0 +1,141 @@
using System.Text.Json;
using Akka.Actor;
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Fleet;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// Verifies the live-deploy wiring of <see cref="VirtualTagHostActor"/>: the
/// <see cref="DriverHostActor"/> must forward the composition's
/// <c>EquipmentVirtualTags</c> to its spawned VirtualTag host via
/// <see cref="VirtualTagHostActor.ApplyVirtualTags"/> on BOTH the fresh-apply path and the
/// bootstrap-restore path (both route through <c>PushDesiredSubscriptions</c>). The host is
/// injected as a <see cref="Akka.TestKit.TestProbe"/> via the Props override seam so the
/// ApplyVirtualTags can be intercepted.
/// </summary>
public sealed class DriverHostActorVirtualTagTests : RuntimeActorTestBase
{
private static readonly NodeId TestNode = NodeId.Parse("driver-vt-test");
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
/// <summary>Fresh apply: dispatching a deployment whose artifact carries one Equipment
/// VirtualTag forwards an <see cref="VirtualTagHostActor.ApplyVirtualTags"/> carrying that
/// plan to the injected VirtualTag host.</summary>
[Fact]
public void Apply_forwards_EquipmentVirtualTags_to_virtual_tag_host()
{
var db = NewInMemoryDbFactory();
var deploymentId = SeedDeploymentWithVirtualTag(db, RevA);
var coordinator = CreateTestProbe();
var vtHost = CreateTestProbe();
var actor = Sys.ActorOf(DriverHostActor.Props(
db, TestNode, coordinator.Ref,
localRoles: new HashSet<string> { "driver" },
virtualTagEvaluator: NullVirtualTagEvaluator.Instance,
virtualTagHostOverride: vtHost.Ref));
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied);
var apply = vtHost.ExpectMsg<VirtualTagHostActor.ApplyVirtualTags>(TimeSpan.FromSeconds(5));
var plan = apply.Plans.ShouldHaveSingleItem();
plan.VirtualTagId.ShouldBe("vt-1");
plan.EquipmentId.ShouldBe("eq-1");
plan.Name.ShouldBe("Doubled");
}
/// <summary>Bootstrap-restore: a node that already has an <c>Applied</c> NodeDeploymentState
/// row for a VirtualTag-carrying deployment re-forwards the
/// <see cref="VirtualTagHostActor.ApplyVirtualTags"/> on PreStart (no dispatch needed), so a
/// restarted node restores its live VirtualTag children.</summary>
[Fact]
public void Restore_on_bootstrap_forwards_EquipmentVirtualTags_to_virtual_tag_host()
{
var db = NewInMemoryDbFactory();
var deploymentId = SeedDeploymentWithVirtualTag(db, RevA);
SeedAppliedNodeState(db, deploymentId);
var coordinator = CreateTestProbe();
var vtHost = CreateTestProbe();
// No DispatchDeployment — Bootstrap() should detect the Applied row and run RestoreApplied,
// which routes through PushDesiredSubscriptions and forwards ApplyVirtualTags.
Sys.ActorOf(DriverHostActor.Props(
db, TestNode, coordinator.Ref,
localRoles: new HashSet<string> { "driver" },
virtualTagEvaluator: NullVirtualTagEvaluator.Instance,
virtualTagHostOverride: vtHost.Ref));
var apply = vtHost.ExpectMsg<VirtualTagHostActor.ApplyVirtualTags>(TimeSpan.FromSeconds(5));
apply.Plans.ShouldHaveSingleItem().VirtualTagId.ShouldBe("vt-1");
}
private static DeploymentId SeedDeploymentWithVirtualTag(
IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev)
{
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
{
Scripts = new[]
{
new
{
ScriptId = "scr-1",
SourceCode = "return ctx.GetTag(\"TestMachine_001.TestDouble\").Value;",
},
},
VirtualTags = new[]
{
new
{
VirtualTagId = "vt-1",
EquipmentId = "eq-1",
Name = "Doubled",
DataType = "Float",
ScriptId = "scr-1",
},
},
});
var id = DeploymentId.NewId();
using var ctx = db.CreateDbContext();
ctx.Deployments.Add(new Deployment
{
DeploymentId = id.Value,
RevisionHash = rev.Value,
Status = DeploymentStatus.Sealed,
CreatedBy = "test",
SealedAtUtc = DateTime.UtcNow,
ArtifactBlob = artifact,
});
ctx.SaveChanges();
return id;
}
private static void SeedAppliedNodeState(
IDbContextFactory<OtOpcUaConfigDbContext> db, DeploymentId deploymentId)
{
using var ctx = db.CreateDbContext();
ctx.NodeDeploymentStates.Add(new NodeDeploymentState
{
NodeId = TestNode.Value,
DeploymentId = deploymentId.Value,
Status = NodeDeploymentStatus.Applied,
StartedAtUtc = DateTime.UtcNow,
AppliedAtUtc = DateTime.UtcNow,
});
ctx.SaveChanges();
}
}
@@ -0,0 +1,168 @@
using Akka.Actor;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.VirtualTags;
/// <summary>
/// Verifies <see cref="VirtualTagHostActor"/> reconciles a desired set of
/// <see cref="EquipmentVirtualTagPlan"/> into child <see cref="VirtualTagActor"/>s and bridges each
/// child's <see cref="VirtualTagActor.EvaluationResult"/> onto an
/// <see cref="OpcUaPublishActor.AttributeValueUpdate"/> carrying the folder-scoped NodeId computed by
/// the materialiser.
/// </summary>
public sealed class VirtualTagHostActorTests : RuntimeActorTestBase
{
/// <summary>A plan with no FolderPath maps onto NodeId "EquipmentId/Name".</summary>
private static EquipmentVirtualTagPlan Plan(
string vtagId, string equipmentId, string name, string folderPath = "") =>
new(
VirtualTagId: vtagId,
EquipmentId: equipmentId,
FolderPath: folderPath,
Name: name,
DataType: "Double",
Expression: "ctx.GetTag(\"a\")",
DependencyRefs: new[] { "a" });
/// <summary>Spawn: an apply with one plan spins up exactly one live child VirtualTagActor.</summary>
[Fact]
public void ApplyVirtualTags_spawns_one_child_per_plan()
{
var publish = CreateTestProbe();
var mux = CreateTestProbe();
var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux.Ref, new StubEvaluator()));
host.Tell(new VirtualTagHostActor.ApplyVirtualTags(new[] { Plan("vt-1", "eq-1", "speed-rpm") }));
// The child self-registers with the mux in PreStart, so a RegisterInterest landing on the
// mux probe is proof the host spawned a live child.
var reg = mux.ExpectMsg<DependencyMuxActor.RegisterInterest>();
reg.TagRefs.ShouldContain("a");
}
/// <summary>KEY TEST: a child EvaluationResult is bridged to the publish actor with the
/// folder-scoped NodeId, Value, Good quality, and source timestamp preserved.</summary>
[Fact]
public void EvaluationResult_is_bridged_with_folder_scoped_NodeId()
{
var publish = CreateTestProbe();
var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux: null, new StubEvaluator()));
host.Tell(new VirtualTagHostActor.ApplyVirtualTags(new[] { Plan("vt-1", "eq-1", "speed-rpm") }));
var ts = new DateTime(2026, 6, 7, 12, 0, 0, DateTimeKind.Utc);
host.Tell(new VirtualTagActor.EvaluationResult("vt-1", 42.0, ts, CorrelationId.NewId()));
var update = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>();
update.NodeId.ShouldBe("eq-1/speed-rpm");
update.Value.ShouldBe(42.0);
update.Quality.ShouldBe(OpcUaQuality.Good);
update.TimestampUtc.ShouldBe(ts);
}
/// <summary>FolderPath is honoured in the published NodeId (EquipmentId/FolderPath/Name).</summary>
[Fact]
public void EvaluationResult_NodeId_includes_folder_path_when_set()
{
var publish = CreateTestProbe();
var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux: null, new StubEvaluator()));
host.Tell(new VirtualTagHostActor.ApplyVirtualTags(
new[] { Plan("vt-1", "eq-1", "speed-rpm", folderPath: "metrics") }));
host.Tell(new VirtualTagActor.EvaluationResult("vt-1", 1.0, DateTime.UtcNow, CorrelationId.NewId()));
var update = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>();
update.NodeId.ShouldBe("eq-1/metrics/speed-rpm");
}
/// <summary>Stop-removed: a vtag dropped from the desired set is unmapped, so a later result for
/// it produces NO publish.</summary>
[Fact]
public void Removed_vtag_is_no_longer_bridged()
{
var publish = CreateTestProbe();
var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux: null, new StubEvaluator()));
host.Tell(new VirtualTagHostActor.ApplyVirtualTags(new[] { Plan("vt-1", "eq-1", "speed-rpm") }));
// Re-apply without vt-1 — it should be stopped + unmapped.
host.Tell(new VirtualTagHostActor.ApplyVirtualTags(Array.Empty<EquipmentVirtualTagPlan>()));
host.Tell(new VirtualTagActor.EvaluationResult("vt-1", 99.0, DateTime.UtcNow, CorrelationId.NewId()));
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
/// <summary>Unmapped result dropped: a result for an unknown vtagId is silently ignored.</summary>
[Fact]
public void Result_for_unknown_vtag_is_dropped()
{
var publish = CreateTestProbe();
var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux: null, new StubEvaluator()));
host.Tell(new VirtualTagHostActor.ApplyVirtualTags(new[] { Plan("vt-1", "eq-1", "speed-rpm") }));
host.Tell(new VirtualTagActor.EvaluationResult("vt-unknown", 7.0, DateTime.UtcNow, CorrelationId.NewId()));
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
/// <summary>
/// After a child actor terminates unexpectedly, a subsequent ApplyVirtualTags (still containing
/// that vtag) must re-spawn it. Proof: two distinct RegisterInterest messages arrive at the mux
/// probe — one for the original child and one for the replacement.
/// </summary>
[Fact]
public void Child_is_respawned_after_unexpected_termination()
{
var publish = CreateTestProbe();
var mux = CreateTestProbe();
var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux.Ref, new StubEvaluator()));
var plan = new[] { Plan("vt-1", "eq-1", "speed-rpm") };
// First apply — child self-registers; capture the child ref from the message sender.
host.Tell(new VirtualTagHostActor.ApplyVirtualTags(plan));
mux.ExpectMsg<DependencyMuxActor.RegisterInterest>();
var firstChild = mux.LastSender;
// Watch the child from the test side so we can await its death deterministically before
// re-applying, avoiding any race between Terminated delivery to the host and the re-apply.
Watch(firstChild);
Sys.Stop(firstChild);
ExpectTerminated(firstChild);
// The dying child's PostStop sends UnregisterInterest to the mux — drain it so the mux probe
// mailbox is clean before we look for the new RegisterInterest.
mux.ExpectMsg<DependencyMuxActor.UnregisterInterest>(TimeSpan.FromSeconds(5));
// Re-apply with the same plan — host should see vt-1 absent from _children and spawn fresh.
host.Tell(new VirtualTagHostActor.ApplyVirtualTags(plan));
var reg2 = mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(TimeSpan.FromSeconds(5));
reg2.TagRefs.ShouldContain("a");
// The new child must be a different actor ref than the one we killed.
var secondChild = mux.LastSender;
secondChild.ShouldNotBe(firstChild);
}
/// <summary>Deterministic no-op evaluator: keeps spawned children inert so tests drive the host's
/// OnResult path directly via synthetic EvaluationResults.</summary>
private sealed class StubEvaluator : IVirtualTagEvaluator
{
/// <summary>Returns NoChange so the child never emits on its own.</summary>
/// <param name="id">The tag identifier.</param>
/// <param name="expr">The expression string.</param>
/// <param name="deps">The dependency values.</param>
/// <returns>A NoChange result.</returns>
public VirtualTagEvalResult Evaluate(string id, string expr, IReadOnlyDictionary<string, object?> deps)
=> VirtualTagEvalResult.NoChange;
}
}