Auto: abcip-4.1 — per-tag scan rate / scan group bucketing

Closes #238
This commit is contained in:
Joseph Doherty
2026-04-26 02:15:50 -04:00
parent e5c38a5a0e
commit b45713622f
8 changed files with 761 additions and 7 deletions

View File

@@ -104,3 +104,10 @@ For the warning *"AbCip device 'X' family 'Y' uses a narrow-buffer profile
511-byte legacy-firmware cap..."* see
[`docs/drivers/AbCip-Performance.md`](drivers/AbCip-Performance.md) — that
warning is fired by the driver host, not the CLI.
## Related operability knobs
- [`docs/drivers/AbCip-Operability.md`](drivers/AbCip-Operability.md) — Phase 4
per-tag knobs (per-tag scan rate, deadband, etc). The CLI does not expose
these knobs directly; they're set in driver config JSON and consumed by the
driver at subscribe time.

View File

@@ -0,0 +1,152 @@
# 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.