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
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:
@@ -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 0–6, 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 T1–T6
|
||||
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 1–6 (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 0–6 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()
|
||||
{
|
||||
|
||||
+141
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user