2befe9f9ba
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
168 lines
8.8 KiB
Markdown
168 lines
8.8 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 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"](../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, so the node holds it server-side for the next Good re-publish and the `(value, status)` dedup. (Over the wire the
|
|
OPC UA SDK nulls the value of a Bad-status read, so a client sees only the Bad status — see `docs/DriverLifecycle.md`.) 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
|