Auto: s7-c4 — deadband / on-change with thresholds

Closes #297
This commit is contained in:
Joseph Doherty
2026-04-26 01:14:59 -04:00
parent 8909302929
commit 06b39a28fa
5 changed files with 555 additions and 4 deletions

View File

@@ -668,6 +668,106 @@ tick rate). Tests can call the internal helpers `S7Driver.GetPartitionCount`
and `S7Driver.GetPartitionSummary` to inspect the resolved partitioning of
a live subscription handle.
### Deadband / on-change
Before PR-S7-C4 the subscription poll loop emitted `OnDataChange` whenever
the freshly-read value differed from the last cached one — a strict
`!Equals(prev, current)` test. That's correct for booleans and discrete
state, but for analog tags (Float32 / Float64 / scaled integer set-points)
it floods the OPC UA subscription queue with insignificant noise: the last
counts of an ADC's least-significant-bit jitter, sub-percent setpoint drift,
sensor-grade flutter on a flow rate. PR-S7-C4 lets the operator configure
**per-tag deadband thresholds** so the driver suppresses uninteresting
publishes at source, before they cross the OPC UA boundary.
Two knobs, both optional, both per-tag:
- `DeadbandAbsolute` (`double?`) — minimum value change in raw units.
Suppress when `|new - prev| < DeadbandAbsolute`.
- `DeadbandPercent` (`double?`, 0..100) — minimum value change as a
percentage of the previous published value. Suppress when
`|new - prev| < |prev| * DeadbandPercent / 100`.
When both knobs are set the filters are **OR'd** — the value publishes if
**either** threshold says publish. This matches Kepware's documented
"either threshold triggers" semantics and mirrors the AbLegacy driver's
shipped behaviour for cross-driver consistency.
#### JSON config example
```json
{
"Host": "10.0.0.50",
"Tags": [
{ "Name": "BoilerPressure", "Address": "DB1.DBD0", "DataType": "Float32",
"DeadbandAbsolute": 0.5 },
{ "Name": "FlowRate", "Address": "DB1.DBD4", "DataType": "Float32",
"DeadbandPercent": 1.0 },
{ "Name": "Temperature", "Address": "DB1.DBD8", "DataType": "Float32",
"DeadbandAbsolute": 0.1, "DeadbandPercent": 0.5 }
]
}
```
`BoilerPressure` only republishes after a 0.5-bar change; `FlowRate` only
when the rate moves by more than 1% of its last published value;
`Temperature` whenever **either** `0.1 °C absolute` **or** `0.5% of last`
is satisfied.
#### Edge cases
- **First sample.** `PollOnceAsync` gates `forceRaise` and the
no-prior-value case ahead of the deadband filter — the first sample for
a tag always publishes (otherwise an OPC UA subscription would never see
an initial-data push).
- **Status-code change.** Any transition in the OPC UA `StatusCode` channel
(`Bad → Good`, `Good → Bad`, etc.) bypasses deadband and publishes,
because quality is a semantically different signal from value.
- **Non-numeric types.** `String` / `WString` / `Char` / `WChar` /
`DateTime` / byte-array tags ignore deadband entirely and keep the
legacy `!Equals` semantics. Configuring `DeadbandAbsolute` on a
`String` tag is harmless — the filter just doesn't engage.
- **`NaN` samples.** If either `prev` or `current` is `NaN`, the filter
publishes. NaN never equals NaN; treating it as "changed" surfaces the
degenerate float to the client rather than hiding it.
- **`±Infinity` samples.** Same rationale as NaN — degenerate values are
always published, never deadbanded.
- **Sign flip.** A tag swinging `+10 → -10` produces `|delta|=20`; the
deadband math operates on the **absolute** delta so a sign flip with
`DeadbandAbsolute=1` always publishes. This is the right answer for
bidirectional set-points (positive / negative torque, valve-direction
flags encoded as signed scalars).
- **Near-zero baseline (`|prev| < 1e-6`).** A percent threshold against a
zero or near-zero baseline diverges (any tiny change is "infinity
percent"), so the driver falls back to absolute when `|prev| < 1e-6`:
- If `DeadbandAbsolute` is also configured, that threshold takes over.
- If only `DeadbandPercent` is set (no absolute fallback), the sample
publishes — there's no usable threshold and silently dropping changes
against a near-zero baseline would mask a genuine signal.
The `1e-6` cutoff is a deliberately conservative floor: floats below
`~1e-7` are already in denormal-precision territory; anything above
`~1e-6` carries enough magnitude that `|prev| * pct / 100` produces a
meaningful threshold.
#### Implementation notes
- The filter is the pure-function helper `S7Driver.ShouldPublish(tag,
prev, current)`. It's exposed at `internal` scope so unit tests can
drive every decision branch (NaN, ±Inf, sign flip, near-zero baseline,
both-set OR semantics) without spinning up a partition or poll loop.
- `LastValues` continues to cache the **last published** snapshot, not
the last polled one. After a deadband suppression the next sample
compares against the cached (previously published) value, so a slow
drift that never crosses the threshold in any single tick still gets
caught the moment cumulative drift exceeds the threshold.
- Deadband is a **publish-time** filter, not a wire-level one — every
configured tag is still read every tick, the filter only decides
whether to invoke `OnDataChange`. The mailbox / PDU / coalescing path
is untouched.
## TSAP / Connection Type
S7comm runs on top of ISO-on-TCP (RFC 1006), and the COTP connection-request