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
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.mdsays a defaultEnginedevice is auto-created. It is not — verified 2026-07-28, the string"Engine"has zero occurrences insrc/, and nothing in the AdminUI or the config layer special-casesCalculationat 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:
scriptIdexistence — 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 gateVirtualTagActoruses, 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
ScriptLogEntryon thescript-logstopic, attributed to the tag'sscriptId, so the AdminUI/script-logpage 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
docs/Raw.md— the/rawauthoring treedocs/VirtualTags.md— the equipment-scoped alternativedocs/ScriptEditor.md— the Roslyn-backed script editor