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

134 lines
6.3 KiB
Markdown

# 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`](../plans/2026-07-15-calculation-driver-mini-design.md);
[`docs/Raw.md`](../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`:
```json
{
"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
```json
{ "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`](../Raw.md) — the `/raw` authoring tree
- [`docs/VirtualTags.md`](../VirtualTags.md) — the equipment-scoped alternative
- [`docs/ScriptEditor.md`](../ScriptEditor.md) — the Roslyn-backed script editor