Files
lmxopcua/docs/drivers/Calculation.md
T

8.6 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 on (value, status) — an evaluation producing the same value and the same status as last time does not re-publish. The status half is load-bearing: a device that comes back after an outage usually comes back holding the reading it had before, so a value-only dedup would swallow the recovery and leave the node Bad forever.
  • Timers are grouped by interval, one timer per distinct interval, not one per tag.

Input quality

The contract is stated once, in docs/DriverLifecycle.md §"The quality contract": a value consumer never reports a quality better than its source's. A calc tag's inputs are other tags' live values, so its output inherits their quality:

Worst input status What the driver does What the calc tag publishes
Good evaluates the computed value, Good
Uncertain (any sub-code) evaluates — an Uncertain value is present and usable the computed value, stamped with that Uncertain sub-code
Bad (any sub-code) holds — the script does not run the last known value, stamped with that input's Bad sub-code, once per change of status
a dependency never pushed holds (the pre-existing warm-up gate) nothing — the node stays BadWaitingForInitialData

Notes worth knowing before authoring against this:

  • The sub-code propagates, so the output says why: BadNoCommunication means go look at the device feeding the input; BadInternalError means this tag's own script failed.
  • Holding on Bad is what removes the NRE storm. A comms-lost input used to arrive as Good, null, so (double)ctx.GetTag("x").Value threw on every trigger and filled /script-log with identical "script threw" entries. Now the script simply is not run, and no script-log entry is written — an input dying is not a script fault.
  • ⚠️ A script that relied on receiving null for a dead input no longer runs at all while that input is Bad. Rewrite such a script to assume its inputs are usable; the driver has already decided they are.
  • ctx.GetTag(path).StatusCode and .SourceTimestampUtc are real. The read cache used to stamp every input Good at evaluation time; it now passes the input's own snapshot through. Since Bad inputs never reach a script, what a script can observe is Good or Uncertain.
  • ReadAsync reports the same status the subscription carried, so a polled read and a subscribed client never disagree about the same node.

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