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

18 KiB
Raw Permalink Blame History

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:

{
  "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:

{
  "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:

{
  "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 iterationsReadAsync 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

# 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:

# 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.