Files
lmxopcua/docs/drivers/AbCip-Operability.md
2026-04-26 03:16:28 -04:00

407 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# AB CIP — Operability knobs
Phase 4 of the AB CIP driver plan introduces operator-tunable behaviour that
changes how the driver schedules per-tag traffic, deduplicates updates, and
surfaces health — knobs that an operator typically reaches for *after* the
address space is in place and the deployment is past the green-field phase.
The Phase 3 doc (`AbCip-Performance.md`) covers connection-shape and
read-strategy knobs; this doc is the home for the per-tag scheduling and
operability levers as PRs land.
PR abcip-4.1 ships the first knob: per-tag **Scan Rate** (Kepware-parity scan
classes).
## Per-tag scan rate
### What it is
A per-tag override of the OPC UA subscription's `publishingInterval`. The AB
CIP driver mirrors the Galaxy hierarchy as a single OPC UA address space, so
every tag served from one driver normally ticks at the publishing interval the
client requested when it created the Subscription. This knob lets specific
tags publish at a different cadence — fast HMI tags at 100 ms, batch /
historian tags at 110 s — without forcing the operator to split tags into
separate subscriptions or driver instances.
It is the Kepware "scan classes" model expressed per-tag. The same shape is
already shipped in the S7 driver (`S7TagDefinition.ScanGroup`) and the AB
Legacy / TwinCAT drivers; AB CIP adopts a leaner per-tag-only form because the
CIP single-connection model means the practical knob a deployment reaches for
is "this one tag, faster", not "every tag in this group".
### How it interacts with OPC UA publishingInterval
OPC UA semantics:
- The Subscription's `publishingInterval` is the *upper bound* on how often
the server publishes a NotificationMessage. Each MonitoredItem also has its
own `samplingInterval`; that's where this knob lands.
- A per-tag `samplingInterval` shorter than the Subscription's
`publishingInterval` means the server samples faster but only publishes at
the next Subscription tick — clients may receive multiple values for one
tag in a single Publish response.
- A per-tag `samplingInterval` longer than the Subscription's
`publishingInterval` is legal too — the server simply skips ticks for that
tag.
AB CIP-side: the driver's `SubscribeAsync` receives one `publishingInterval`
plus a list of tag references. With per-tag `ScanRateMs` it buckets the input
list by resolved interval and registers one `PollGroupEngine` subscription per
bucket. Each bucket runs an independent timer, so a 100 ms tag never waits
for a 1000 ms tag's `Task.Delay` to expire.
### Override knob
`AbCipTagDefinition.ScanRateMs` (`int?`, default `null`). `null` = use the
subscription's default `publishingInterval` (legacy behaviour). Bind via
driver config JSON:
```json
{
"Tags": [
{
"Name": "Motor1.Speed",
"DeviceHostAddress": "ab://10.0.0.5/1,0",
"TagPath": "Motor1.Speed",
"DataType": "DInt",
"ScanRateMs": 100
},
{
"Name": "Motor1.RunHours",
"DeviceHostAddress": "ab://10.0.0.5/1,0",
"TagPath": "Motor1.RunHours",
"DataType": "DInt",
"ScanRateMs": 5000
},
{
"Name": "Motor1.NamePlate",
"DeviceHostAddress": "ab://10.0.0.5/1,0",
"TagPath": "Motor1.NamePlate",
"DataType": "String"
}
]
}
```
Result: three buckets — 100 ms, 5000 ms, and the subscription default for
`NamePlate`. UDT members inherit the parent tag's `ScanRateMs` at fan-out
time, so a UDT declared at 100 ms publishes every member at 100 ms without
the operator having to repeat the override on each member.
### Floor and degenerate cases
- `PollGroupEngine` floors every bucket at **100 ms** — a `ScanRateMs: 25`
is clamped up. The floor matches the Modbus / S7 / TwinCAT floors and
protects the wire from sub-mailbox-scan polling.
- `ScanRateMs: 0` and negative values are treated as unset — the tag falls
back to the subscription default. Mis-typed config degrades, doesn't fault.
- A `ScanRateMs` equal to the subscription default collapses into the same
bucket as plain tags. The driver doesn't fragment poll loops when the
override is redundant.
- Tags whose names don't appear in the driver's tag map (typo / discovery
miss) fall through to the subscription default — same "config typo
degrades" stance as the rest of the driver.
### Wire impact
Per-bucket independent timers do **not** parallelise CIP traffic. The driver
serializes wire-side reads through its per-device libplctag handles, so a
fast bucket and a slow bucket trade off against each other on the wire — the
multi-rate split decouples *cadence* (the 100 ms bucket isn't queued behind
the 1000 ms bucket's `Task.Delay`), not *throughput*. The wire still moves
one CIP request at a time per device.
If you're reading a large tag set and the slow bucket starves the fast
bucket, the lever is `AbCipDeviceOptions.ConnectionSize` (Phase 3) — pack
more tags into one CIP RTT so the slow bucket finishes faster. Per-tag scan
rate is a *scheduling* knob, not a *throughput* knob.
### Comparison to Kepware scan classes
| Kepware concept | AB CIP equivalent |
|---|---|
| Scan class table (named groups → rate) | implicit: each distinct `ScanRateMs` value is its own bucket |
| Default scan class | OPC UA Subscription's `publishingInterval` |
| Per-tag scan class assignment | `AbCipTagDefinition.ScanRateMs` |
| "Scan mode: Respect client" | always — the OPC UA `publishingInterval` is the default |
| "Force write" / "Write through cache" | not exposed — AB CIP writes always go to the wire |
The leaner shape (per-tag rate, not named groups) keeps the JSON config flat
and reflects how operators tend to use the knob in practice — a handful of
"this specific tag needs to be fast" overrides on top of a sensible default,
rather than a separate tier of scan-class definitions.
### Verification
- **Unit**: `AbCipPerTagScanRateTests` (`tests/.../AbCip.Tests`). Asserts
bucketing math, default-rate collapse, UDT member inheritance, JSON DTO
round-trip, and end-to-end cadence against the in-process fake.
- **Integration**: `AbCipPerTagScanRateTests`
(`tests/.../AbCip.IntegrationTests`). Drives two tags at 100 ms / 1000 ms
against a live `ab_server` and asserts the bucket count + each tag receives
the initial-data push.
- **E2E**: `scripts/e2e/test-abcip.ps1` — see the *PerTagScanRate* assertion.
### Cross-references
- `docs/Driver.AbCip.Cli.md` — there is no CLI surface change for this knob;
scan rate is a config-time concern.
- `docs/drivers/AbCip-Performance.md` — Phase 3 throughput knobs that pair
with per-tag scan rate when a slow bucket starves a fast one.
- S7 driver `ScanGroup` model in `src/.../S7DriverOptions.cs` — the
named-group form of the same idea.
## Write deadband / write-on-change
PR abcip-4.2 ships the second operability knob: per-tag write coalescing,
the *write-side* companion to the read-side deadband already shipped at the
OPC UA monitored-item layer. The driver remembers the value last
successfully written for a tag and can suppress redundant or below-threshold
follow-up writes — they return `Good` to the OPC UA client without ever
hitting the wire.
### What it is
- **`AbCipTagDefinition.WriteDeadband`** (`double?`, default `null`) —
numeric absolute-difference threshold. When set, a write whose
`|new last|` is below the deadband is suppressed.
- **`AbCipTagDefinition.WriteOnChange`** (`bool`, default `false`) —
equality gate. When set, a write whose value equals the last successfully
written value is suppressed.
Both knobs combine on the same tag. For numerics, the deadband path takes
priority; the equality fallback covers the cases the deadband doesn't (BOOL
setpoints, STRING constants, `WriteDeadband=0`, etc).
### Worked setpoint-jitter example
A motor speed setpoint published from an HMI tends to wobble by a few
ticks even when the operator hasn't touched it — UI rounding, Modbus
gateway re-encoding, RPN script noise. With `WriteDeadband: 0.5`:
```json
{
"Tags": [
{
"Name": "Motor1.Speed.SP",
"DeviceHostAddress": "ab://10.0.0.5/1,0",
"TagPath": "Motor1.Speed.SP",
"DataType": "Real",
"WriteDeadband": 0.5
}
]
}
```
Sequence of writes from the HMI (one every 100 ms, no operator input):
| Time | Value | `\|new last\|` | Wire? |
|---|---|---|---|
| 0 ms | 50.0 | n/a (first) | yes |
| 100 ms | 50.2 | 0.2 < 0.5 | suppressed |
| 200 ms | 50.3 | 0.3 < 0.5 | suppressed |
| 300 ms | 50.6 | 0.6 ≥ 0.5 | yes |
| 400 ms | 50.6 | 0.0 < 0.5 | suppressed |
| 500 ms | 51.5 | 0.9 ≥ 0.5 | yes |
Three writes hit the wire; three are suppressed. The OPC UA client sees
`Good` on every call. The PLC sees only the values that actually crossed
the deadband.
### Combining with WriteOnChange
A digital reset bit driven by a UI that pulses it at every cycle:
```json
{
"Name": "Conveyor.Reset",
"DeviceHostAddress": "ab://10.0.0.5/1,0",
"TagPath": "Conveyor.Reset",
"DataType": "Bool",
"WriteOnChange": true
}
```
Three consecutive `false → false → false` writes from the UI collapse to
one wire write (`false`, the first). When the operator clicks the reset
button (`true`), that write passes; subsequent `true → true` repeats
suppress until the UI clears it back to `false`.
Numeric tags can also opt into both: `WriteDeadband: 0.5` plus
`WriteOnChange: true` is well-defined — the deadband suppresses jitter, the
equality gate suppresses exact repeats (which the deadband path also catches
because `|0| < 0.5`, but having both set documents the operator's intent).
### Special cases
- **First write** always passes through. The coalescer has no prior value
to compare against, so the first write of any tag pays the full
round-trip and seeds the cache.
- **NaN / Infinity** bypass deadband suppression. IEEE-754 comparisons
against NaN are undefined and a stale `+Inf` shouldn't silently swallow
a real reset; the wire decides. `WriteOnChange` equality on NaN still
follows .NET semantics (`Equals(NaN, NaN) == true` for `double` boxed in
`object`), so a `WriteOnChange` tag stuck on NaN will suppress repeats
until something else writes a real value.
- **Failed writes** do *not* seed the cache. If the wire write fails, the
next attempt with the same value still hits the wire because the
coalescer never recorded a "last successful value" for it.
- **Reconnect drops the cache**. The driver's host-state probe transitions
`Stopped → Running` after a reconnect; both transitions reset the
per-device coalescer cache, so the first post-reconnect write of any
value pays the full round-trip. The PLC may have been restarted while
the driver was offline and our cached "we already wrote 42" is stale.
- **Two devices, same tag address**. The cache is keyed on
`(deviceHostAddress, tagAddress)` so two PLCs running the same Logix
program keep independent caches — writing 42 to A doesn't suppress
writing 42 to B.
- **Bit-in-DINT writes** consult the coalescer too, so a UI that pulses
`Flags.3` at every cycle benefits from the same `WriteOnChange`
suppression as a plain BOOL tag.
- **Plain back-compat tags** (no `WriteDeadband`, no `WriteOnChange`)
take a fast-path through the coalescer that increments only the
`WritesPassedThrough` counter — no dictionary lookup, no allocation. The
knobs are zero-overhead opt-in.
### Diagnostics
The driver surfaces two counters through `DriverHealth.Diagnostics` (the
same path the `driver-diagnostics` RPC + Admin UI render for Modbus / S7 /
OPC UA Client):
- `AbCip.WritesSuppressed` — total writes the coalescer skipped.
- `AbCip.WritesPassedThrough` — total writes that hit the wire after
consulting the coalescer.
Their ratio is the "wire savings" headline. A deployment with `0`
suppressions either has no tags opted in or has the deadband too tight /
the equality threshold too loose; revisit the per-tag config.
### Verification
- **Unit**: `AbCipWriteDeadbandTests` (`tests/.../AbCip.Tests`). Asserts
the deadband math, the equality fallback, the first-write pass-through,
reset-on-reconnect, two-device cache independence, suppressed-Good
status, NaN bypass, the back-compat fast path, and DTO round-trip.
- **Integration**: `AbCipWriteDeadbandTests`
(`tests/.../AbCip.IntegrationTests`). Drives a 5-write jittery sequence
with `WriteDeadband: 1.0` against a live `ab_server` and asserts the
driver's diagnostics counter matches the expected suppression count.
- **E2E**: `scripts/e2e/test-abcip.ps1` — see the *WriteCoalesce*
assertion.
### Cross-references
- `docs/drivers/AbServer-Test-Fixture.md` §7 — capability surfaces beyond
read; mentions write-coalesce coverage.
- Modbus driver — read-side deadband in `ModbusDriver` predates this
write-side equivalent; the config shape is intentionally similar.
- Kepware "Deadband (write)" knob — this is the AB CIP equivalent.
## System tags / `_System` folder
PR abcip-4.3 surfaces five read-only diagnostic variables under
`AbCip/<device>/_System/` so SCADA / Admin clients can pivot from "is the
wire up?" to "what's our scan rate / tag count?" without leaving the OPC UA
address space. The values come straight from the live
`IHostConnectivityProbe` + `DriverHealth` surfaces — reads bypass libplctag
and are served from the in-memory snapshot the probe loop / read loop
updates. PR abcip-4.4 added `_RefreshTagDb` as a sixth, writeable entry —
the Kepware-style refresh trigger.
### What it ships
| Variable | Type | Access | Source | Notes |
|---|---|---|---|---|
| `_ConnectionStatus` | String | ViewOnly | `HostState` | `Running` / `Stopped` / `Unknown` / `Faulted`. Mirrors what the connectivity probe sees. |
| `_ScanRate` | Float64 | ViewOnly | `AbCipProbeOptions.Interval` | Configured probe interval in milliseconds — compare against `_LastScanTimeMs` to spot wire stretch. |
| `_TagCount` | Int32 | ViewOnly | `_tagsByName` | Discovered tag count for this device, excluding `_System/*`. |
| `_DeviceError` | String | ViewOnly | `DriverHealth.LastError` | Most recent error message; empty when the device is healthy. |
| `_LastScanTimeMs` | Float64 | ViewOnly | `ReadAsync` wall-clock | Duration of the most-recent `ReadAsync` iteration on this device. |
| `_RefreshTagDb` | Boolean | **Operate** | n/a (write-only trigger) | PR abcip-4.4 — Kepware-style refresh trigger. Reads always return `false`. Writing any truthy value (`true`, non-zero number, `"true"` / `"1"` strings, case-insensitive) dispatches to `RebrowseAsync` against the device's cached `IAddressSpaceBuilder`. Falsy / unparseable writes are no-ops that report `Good` so a UI that resets the trigger flag doesn't see a phantom error. The `AbCip.RefreshTriggers` diagnostic counter increments per truthy write. |
### When the snapshot updates
- **Probe transitions** — every `Running ↔ Stopped` flip refreshes the
device's snapshot inline, so a client subscribed to
`_System/_ConnectionStatus` sees the new state on the next OPC UA
publish tick.
- **Read iterations** — `ReadAsync` recomputes `_LastScanTimeMs` per
device that owned at least one reference in the batch + writes a fresh
snapshot before returning.
- **Driver init** — every device gets a seeded snapshot
(`Unknown` / `0` / `""`) before the probe loop spins up so a read that
arrives before the first probe iteration returns a stable shape rather
than null.
### Browse + read example
```powershell
# Browse the synthetic folder
otopcua-client-cli browse -u opc.tcp://localhost:4840 \
-n "ns=2;s=AbCip/ab://10.0.0.5/1,0/_System"
# Read the connection status
otopcua-client-cli read -u opc.tcp://localhost:4840 \
-n "ns=2;s=AbCip/ab://10.0.0.5/1,0/_System/_ConnectionStatus"
```
The driver-side reference embeds the device host address (the
`_System/<device>/<name>` form) so the dispatcher can route by device
without an additional registry. PR abcip-4.4 turned `_RefreshTagDb` into
a writeable refresh trigger; the rest of the surface remains `ViewOnly`.
### Refreshing the tag DB via OPC UA write
PR abcip-4.4 wires `_RefreshTagDb` to the same `RebrowseAsync` entry point
the CLI's `rebrowse` command exercises (issue #233). Operators have two
roughly-equivalent ways to force a controller-side `@tags` re-walk after a
program download:
```powershell
# Path A — OPC UA write to the system tag (production / Admin UI path)
otopcua-client-cli write -u opc.tcp://localhost:4840 \
-n "ns=2;s=AbCip/ab://10.0.0.5/1,0/_System/_RefreshTagDb" \
-v true --type Boolean
# Path B — direct CLI rebrowse against a transient driver (admin / debug path)
otopcua-abcip-cli rebrowse -g ab://10.0.0.5/1,0
```
Both paths drop the UDT template cache + re-run the enumerator walk. Path A
is the operator-facing surface (the same `IDriverControl.RebrowseAsync`
contract, just dispatched from the OPC UA write surface instead of an
in-process call). Path B spins up its own driver instance so it doesn't
share the live server's cache, which makes it useful for one-off
controller-side validation.
The `AbCip.RefreshTriggers` driver-diagnostics counter increments per
successful truthy write, so the Admin UI / driver-diagnostics RPC can show
a "Refreshes since boot" tile that pairs naturally with the existing
`WritesSuppressed` / `WritesPassedThrough` write-coalescer counters.
### Verification
- **Unit**: `AbCipSystemTagSourceTests`
(`tests/.../AbCip.Tests`) — covers snapshot round-trip, two-device
isolation, recognised-name lookup, default-shape on unseeded devices,
discovery emits the six canonical nodes, and `ReadAsync` dispatches
through the source instead of libplctag.
- **Unit**: `AbCipRefreshTagDbTests`
(`tests/.../AbCip.Tests`) — PR abcip-4.4 — covers discovery emits the
trigger as Operate, reads always return `false`, truthy/falsy/null write
semantics, the `AbCip.RefreshTriggers` counter, two-device counter
independence, defends-in-depth `BadNotWritable` for read-only system
variables, no-op-Good when no builder is cached yet, and mixed-batch
routing alongside ordinary tag writes.
- **Integration**: `AbCipSystemTagDiscoveryTests`
(`tests/.../AbCip.IntegrationTests`) — `[AbServerFact]` connects to a
real `ab_server`, browses `_System/`, reads each variable, asserts
every one returns Good with a non-null value.
- **Integration**: `AbCipRefreshTagDbTests`
(`tests/.../AbCip.IntegrationTests`) — PR abcip-4.4 — `[AbServerFact]`
drives a `_RefreshTagDb` write, asserts the template cache drops + the
per-device counter advances against a live `ab_server`.
- **E2E**: `scripts/e2e/test-abcip.ps1` — see the *SystemTagBrowse* +
*RefreshTagDbWrite* assertions.