Auto: abcip-4.2 — write deadband / write-on-change

Closes #239
This commit is contained in:
Joseph Doherty
2026-04-26 02:31:50 -04:00
parent 9202ebe5ef
commit da9936f7f0
9 changed files with 855 additions and 5 deletions

View File

@@ -150,3 +150,150 @@ rather than a separate tier of scan-class definitions.
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.

View File

@@ -139,14 +139,19 @@ the RMW path is not exercised end-to-end.
No smoke test for:
- `IWritable.WriteAsync`
- `IWritable.WriteAsync` — atomic write coverage; PR abcip-4.2 added a
multi-write *suppression* smoke (jittery 5-write sequence with
`WriteDeadband: 1.0` against `ab_server`, asserting the driver's
diagnostics counter matches the expected suppression count) but pure
atomic-write coverage end-to-end is still unit-only.
- `ITagDiscovery.DiscoverAsync` (`@tags` walker)
- `ISubscribable.SubscribeAsync` (poll-group engine)
- `IHostConnectivityProbe` state transitions under wire failure
- `IPerCallHostResolver` multi-device routing
The driver implements all of these + they have unit coverage, but the only
end-to-end path `ab_server` validates today is atomic `ReadAsync`.
end-to-end paths `ab_server` validates today are atomic `ReadAsync` and
write-deadband / write-on-change suppression.
## Logix Emulate golden-box tier