Files
lmxopcua/docs/plans/2026-07-15-calculation-driver-mini-design.md
T
Joseph Doherty cf03ca279d docs(v3): implementation plans — master orchestration + 4 batch plans
- v3 design finalized: all 7 open items resolved (live historian probe:
  255-char tagname limit; SDK multi-notifier spike: native AddNotifier,
  never duplicate ReportEvent; NamespaceKind.Simulated retired; CSV column
  dictionaries; ScadaBridge re-bind sized; rename-warning scan decided),
  reconciled with the pre-v3 universal discovery browser (its new §11
  v3 forward note re-targets the browse commit contract).
- Calculation pseudo-driver mini-design (IDependencyConsumer capability,
  mux-fed change/timer triggers, Tarjan cycle deploy gate, VT-parity
  error semantics).
- Implementation plans for Opus-agent execution, grounded against exact
  files/symbols: master plan (batch DAG, worktree-isolated parallel
  waves, contract-first fan-out, global gotcha list) + one plan per
  batch (1 schema+RawPath identity, 2 /raw UI+Calculation, 3 UNS
  reference-only+{{equip}}, 4 dual-namespace address space = v3.0),
  each with per-wave file ownership and a mandatory docker-dev live gate.
2026-07-15 16:50:30 -04:00

9.1 KiB

Calculation pseudo-driver — mini-design (v3 task #11)

Date: 2026-07-15 Status: design complete (resolves v3 open item "Calculation driver mini-design"). Parent: docs/plans/2026-07-15-raw-uns-two-subtree-v3-design.md §"Calculated tags (two-tier)".

Signal-level calculated tags are ordinary raw tags bound to a Calculation driver. They inherit the entire raw pipeline (folders, tag-groups, CSV, historization, native alarms, UNS references) with no special-casing. This doc pins the runtime mechanism, storage, triggers, gates, and error semantics.

1. Driver shape

CalculationDriver : IDriver, ISubscribable, IReadable in a new src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/ project.

  • Not IWritable — calc tags are computed sources; author them read-only (AccessLevel enforced at authoring + CSV validation).
  • Not ITagDiscovery — tags are authored, never discovered (SupportsOnlineDiscovery default false ⇒ no Browse button, manual/CSV entry only — consistent with the universal-browser gate).
  • IReadable.ReadAsync returns the last computed snapshot per ref (BadWaitingForInitialData before first evaluation).
  • Registration: CalculationDriverFactoryExtensions.DriverTypeName = "Calculation" + one line in DriverFactoryBootstrap.AddOtOpcUaDriverFactories and one TryAddEnumerable in AddOtOpcUaDriverProbes — the standard pattern. (Note: the live registration seam is DriverFactoryRegistry; DriverTypeRegistry (metadata/JSON-schema) is vestigial — nothing in src/ calls its Register.)
  • Devices: one auto-created default device (Engine), empty DeviceConfig. No PollGroups.
  • GetHealth() = always Connected (no backend); memory footprint = compiled-cache size.

2. Per-tag TagConfig

{ "scriptId": "…", "changeTriggered": true, "timerIntervalMs": 5000 }
  • scriptId (required) — logical FK to the shared Script entity (same convention as VirtualTag.ScriptId; generation-scoped, SourceHash compile-cache key). The AdminUI tag editor reuses the VirtualTagModal's script dropdown + "New script" + inline Monaco panel components; the standalone /scripts/{id} page works unchanged.
  • Trigger: changeTriggered (default true) and/or timerIntervalMs (≥ 50), at least one required — the authored shape mirrors VirtualTag, but see §4 for the runtime reality.

3. Value production — how inputs reach the driver

The gap: drivers are host-blind; nothing feeds one driver another driver's values. The per-node DependencyMuxActor already receives every AttributeValuePublished (via DriverHostActor.ForwardToMux), keyed by wire-ref — under v3, RawPath.

Mechanism — a new composable capability interface (in the spirit of IDriver's capability set):

/// <summary>Optional capability: the driver consumes other tags' live values.
/// The host registers the declared refs with its dependency mux and forwards changes.</summary>
public interface IDependencyConsumer
{
    /// <summary>Wire-refs (RawPaths) this driver needs, derived from its authored tags.
    /// Re-read by the host after Initialize/Reinitialize.</summary>
    IReadOnlyCollection<string> DependencyRefs { get; }

    /// <summary>Host push of a dependency value change. Called from an actor context —
    /// implementations must be non-blocking.</summary>
    void OnDependencyValue(string rawPath, object? value, uint statusCode, DateTime timestampUtc);
}

DriverHostActor wiring (~30 lines): when a spawned driver implements IDependencyConsumer, spawn a tiny mux-adapter child that RegisterInterest(driver.DependencyRefs, Self) on the existing DependencyMuxActor and forwards DependencyValueChanged into the driver callback. Re-register on every apply (refs change when tags/scripts change).

Output path is the ordinary driver path — evaluation raises ISubscribable.OnDataChangeDriverInstanceActorAttributeValuePublished → node fan-out and back into the mux. Two things fall out for free:

  1. Calc-of-calc chains work with no extra machinery — calc A's published output re-enters the mux, calc B (subscribed to A's RawPath) re-evaluates. This is exactly why the deploy-time cycle gate (§5) is mandatory: an undetected A→B→A cycle is a live oscillation loop, not just a stale value.
  2. Historization, native alarms, and UNS references attach to the calc tag's raw node exactly like any driver tag.

DependencyRefs = the union of EquipmentScriptPaths.ExtractDependencyRefs(source) over the driver's authored tags' scripts (literal-only ctx.GetTag paths, enforced by DependencyExtractor).

4. Evaluation engine + triggers

  • Evaluator: a CalculationEvaluator with the same construction as RoslynVirtualTagEvaluatorCompiledScriptCache<VirtualTagContext, object?> keyed by source, TimedScriptEvaluator (2 s default timeout), passthrough fast-path, single-tag mode (ctx.SetVirtualTag dropped + logged). Reuses VirtualTagContext so the Monaco editor, sandbox, and diagnostics pipeline apply verbatim. Catalog keys for completions are RawPaths; the {{equip}} branch is already conditional in ScriptAnalysisService and simply never triggers for raw-level scripts.
  • Change trigger: evaluate on OnDependencyValue for any declared dep of that tag, gated like VirtualTagActor: publish nothing until all declared deps have arrived at least once; dedupe equal results.
  • Timer trigger: owned inside the driver by reusing the dormant-but-tested TimerTriggerScheduler (Core.VirtualTags) — one timer per distinct interval group, per-group in-flight skip. (Fact: the live VirtualTag runtime is change-trigger only — VirtualTagActor has no timer handler and EquipmentVirtualTagPlan drops the trigger fields. The Calc driver revives the scheduler without touching the VT runtime; whether the VT plan seam later gains timer support is out of scope here.)
  • Concurrency: evaluations run inline on the driver's dispatch (same "fast enough to run inline" contract as the VT evaluator); the compile cache makes steady-state evaluation cheap. Compiled scripts are dropped on ReinitializeAsync when the source set changed (SourceHash comparison, mirroring IScriptCacheOwner usage).

5. Deploy gates (new — DraftValidator)

Today DraftValidator checks only name uniqueness; DependencyGraph (Tarjan SCC) exists but is dormant. v3 adds, for Calculation-bound tags:

  1. scriptId existence — the referenced Script row must exist in the draft generation.
  2. Cycle gate (hard error) — build the calc→calc edge set (tag → deps that are themselves Calculation tags) and run DependencyGraph.DetectCycles; any SCC ⇒ deploy error naming the cycle members. Cross-driver refs (calc reading a Modbus tag) are terminal nodes, never edges.
  3. Compile: deliberately NOT a hard gate — parity with VirtualTags. The editor's live diagnostics mirror the compile exactly, the StartDeployment compile-cost advisory stays warn-only, and a runtime compile failure lands as Bad quality + script-log (§6). A hard compile gate would make deploys O(scripts) slow and diverge Calc tags from VT behavior.

6. Error semantics (mirrors VirtualTagActor 02/S13)

  • Evaluator throw / compile error / timeout ⇒ publish Bad quality carrying the last-known value, once per Good→Bad transition, only after all deps have arrived; emit a ScriptLogEntry on the script-logs DPS topic per failure (visible on the script-log panel). Recovery to Good is force-published. No retry — re-evaluation on next trigger.
  • Historized Bad results record BadInternalError (0x80020000), matching VT historization.

7. Probe

CalculationDriverProbe : IDriverProbe (DriverType = "Calculation"). ProbeAsync = validate the driver config parses; there is no backend to connect. (Per-script compile verification belongs to the editor diagnostics + the tag editor's inline panel, not the driver probe — the probe receives only DriverConfig, which for Calculation is empty.) Returns Ok with negligible latency; never throws.

8. Testing

  • Unit: evaluator parity tests vs the VT evaluator (passthrough, timeout, sandbox violation, SetVirtualTag drop); dependency-ref extraction from authored tags; change-gate (no publish until all deps seen); dedupe; timer-group scheduling via TimerTriggerScheduler; Bad-transition + script-log emission; cycle gate (self-cycle, 2-cycle, cross-driver terminal).
  • Integration: deploy a Calc tag reading a fixture-driver tag on the 2-node harness; assert the raw node publishes computed values, a UNS reference sees them, and calc-of-calc chains propagate.
  • Live /run (Batch 2 gate): author a Calc driver + tag on docker-dev via /raw, script it against a Modbus fixture tag, watch the value compute; break the script, watch Bad + script-log row; fix, watch recovery.

9. Out of scope

  • Multi-tag scripts / ctx.SetVirtualTag fan-out (single-tag mode, as VT today).
  • Timer support in the UNS VirtualTag runtime (dormant there; revived only inside this driver).
  • Write-through calc tags (inverse calculations).