Files
lmxopcua/docs/drivers/Calculation.md
T
Joseph Doherty f3cd685da5 docs(drivers): write the two missing driver guides (Sql, Calculation)
Closes the §7.2 priority-3 item. Sql was a shipped, merged driver with NO
user-facing documentation at all -- only design and plan files, which describe
what was intended rather than what an operator needs to author one.

Sql.md covers: SQL Server as the only constructed provider (the other four
SqlProvider names are reserved, and authoring one fails rather than falling
back), the KeyValue / WideRow tag models with worked TagConfig, Query being
rejected end-to-end, the config table with real defaults read from
SqlDriverOptions, connectionStringRef never carrying credentials into an
artifact, per-source query grouping and its case-sensitive group keys, and the
value-bound-vs-identifier-validated split that makes it injection-safe.

Calculation.md covers: what a pseudo-driver is and why it occupies a driver slot,
a Calculation-vs-VirtualTags decision table (the two are easy to confuse), the
literal-only ctx.GetTag dependency extraction and the runtime-built-path trap,
the two deploy gates, the all-deps-arrived publish gate, and the Good->Bad-once
error semantics.

Two stale claims found by checking against source rather than inheriting them:

  - The driver README's Calculation row still carried G-1 ("no typed config form,
    RunTimeout unauthorable"), fixed on 2026-07-27 by CalculationDriverForm.
  - docs/Raw.md's "one auto-created default Engine device" NEVER SHIPPED. I had
    copied it into the new doc before checking; "Engine" has zero occurrences in
    src/ and nothing special-cases Calculation at device creation. Corrected in
    both files. The config key was also wrong in my first draft: runTimeoutMs, an
    int in milliseconds, not a TimeSpan string -- and it is ignored on reinit
    because the evaluator's timeout is constructor-fixed.

Full solution: 49 suites pass; the 2 failures are the known pre-existing
environment ones (AbLegacy.IntegrationTests docker fixture, Host.IntegrationTests).
OpcUaClient.IntegrationTests, which was silently failing, is now green.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
2026-07-28 14:40:51 -04:00

6.3 KiB

Calculation Driver

Getting-started guide for the Calculation pseudo-driver — signal-level calculated tags computed by C# scripts over other tags' live values. For the design rationale read docs/plans/2026-07-15-calculation-driver-mini-design.md; docs/Raw.md covers where it sits in the /raw tree.

What it talks to

Nothing. There is no backend, no endpoint, no connection and no discovery. Its inputs are other tags' live values, fed to it by the driver host; its outputs are ordinary raw tags. That is why it is called a pseudo-driver — it occupies a driver slot so its tags get the same authoring, deployment, fan-out, historization and alarming as any device tag, without any of the I/O.

Because it is an ordinary driver from the host's point of view, a calc tag's value flows out through OnDataChange exactly like a Modbus register's — so the dual-namespace fan-out, historian registration and calc-of-calc (a calc tag reading another calc tag, via mux re-entry) all work with no special-casing.

Calculation vs. Virtual Tags — which one to use

They are close cousins and easy to confuse:

Calculation driver Virtual tags
Lives in the /raw tree, as a raw tag the UNS tree, on an equipment
Identity a RawPath {Equipment}/{EffectiveName}
Reads by ctx.GetTag("<RawPath>") — literal raw paths ctx.GetTag("{{equip}}/<RefName>") — equipment-relative
Use when the computation is a property of the signal (scaling, a derived rate, combining two registers) the computation is a property of the equipment (an OEE roll-up, a per-machine state)

If the same script should apply to many machines, you want a virtual tag with {{equip}}. If a single derived signal belongs next to the device tags it is derived from, you want a calc tag.

Authoring a calc tag

Calc tags are authored in /raw like any other tag: create a Calculation driver, add a device under it, and add tags under that.

⚠️ docs/Raw.md says a default Engine device is auto-created. It is not — verified 2026-07-28, the string "Engine" has zero occurrences in src/, and nothing in the AdminUI or the config layer special-cases Calculation at device-creation time. Create the device yourself; the name is arbitrary but becomes a segment of every calc tag's RawPath, so pick it deliberately.

Per-tag TagConfig:

{
  "scriptId": "scr-oven-delta",
  "changeTriggered": true,
  "timerIntervalMs": 5000
}
Field Meaning
scriptId FK to the shared Script entity. Required — a tag with no identifiable script is not a calc tag and is rejected at parse.
changeTriggered Re-evaluate whenever any declared dependency changes.
timerIntervalMs Also re-evaluate on this cadence. Absent ⇒ change-trigger only.

scriptSource is resolved at deploy time from scriptId and travels in the artifact — the driver never reads the config DB. A scriptId that resolves to empty source still maps: the driver logs it and the tag simply never computes, rather than the whole driver failing.

Dependencies are discovered from the script

You do not declare dependencies. The deploy pipeline extracts them from the script's literal ctx.GetTag("…") reads and registers exactly those RawPaths with the dependency mux.

⚠️ Literal means literal. A path built at runtime — ctx.GetTag("Plant/" + line + "/Temp") — is invisible to the extractor, so that dependency is never fed and the tag never sees its value. Write the path as a constant string.

Two deploy-time gates run over the result:

  • scriptId existence — a calc tag naming a script that does not exist fails the deploy.
  • Cycle detection (Tarjan) — calc-of-calc is supported, but a cycle fails the deploy rather than livelocking at runtime.

Trigger and publish semantics

  • Nothing publishes until every declared dependency has arrived at least once. Before that a read returns BadWaitingForInitialData. This is the same gate VirtualTagActor uses, and it exists so a calculation cannot publish a confident-looking value derived from defaults.
  • Equal results are deduped — an evaluation producing the same value as last time does not re-publish.
  • Timers are grouped by interval, one timer per distinct interval, not one per tag.

Error semantics

An evaluator failure publishes Bad quality carrying the last-known value — the value is not zeroed, because a stale-but-flagged reading is more useful to an operator than a fabricated one. It publishes:

  • once per Good→Bad transition, not on every failed evaluation (a permanently broken script does not flood), and only after all dependencies have arrived;
  • with a ScriptLogEntry on the script-logs topic, attributed to the tag's scriptId, so the AdminUI /script-log page names the failing script;
  • recovery to Good is force-published, bypassing the dedup, so a recovered tag does not sit Bad waiting for its value to change.

A historized Bad records BadInternalError.

Config

{ "runTimeoutMs": 2000 }
Key Default Notes
runTimeoutMs 2000 (2 s) Per-script wall-clock evaluation budget, in milliseconds — not a TimeSpan string, unlike most driver timeouts in this repo. Parity with the virtual-tag evaluator.

⚠️ runTimeoutMs is ignored on a config reinit — the evaluator's timeout is fixed at construction. A changed value takes effect because a changed DriverConfig respawns the driver (#516), not because the driver re-reads it in place.

There is nothing else — no endpoint, no credentials, no pooling. The driver is single-connection in the AdminUI's sense (nothing lives on the device).

Capabilities

IDriver (lifecycle + always-Connected health), IDependencyConsumer (the host feeds it upstream values), ISubscribable (computed values flow out), IReadable (returns the last computed snapshot).

No IWritable — a computed value has no meaningful write target. No ITagDiscovery — there is nothing to browse.

See also