153 lines
6.6 KiB
Markdown
153 lines
6.6 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.
|