300 lines
13 KiB
Markdown
300 lines
13 KiB
Markdown
# 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 1–10 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.
|