diff --git a/docs/v2/s7.md b/docs/v2/s7.md
index 81b51e7..a5e47d5 100644
--- a/docs/v2/s7.md
+++ b/docs/v2/s7.md
@@ -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
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs
index 287a67a..1a69d04 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs
@@ -1334,9 +1334,25 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
{
var tagRef = part.TagReferences[i];
var current = snapshots[i];
- var lastSeen = part.LastValues.TryGetValue(tagRef, out var prev) ? prev : default;
+ var hasPrev = part.LastValues.TryGetValue(tagRef, out var lastSeen);
- if (forceRaise || !Equals(lastSeen?.Value, current.Value) || lastSeen?.StatusCode != current.StatusCode)
+ // Status-code change always publishes (transitions Bad → Good and back are
+ // semantically meaningful regardless of the value channel). First sample
+ // (no prior cached value) and forced-raise (initial-data push) bypass the
+ // deadband filter outright.
+ var statusChanged = !hasPrev || lastSeen!.StatusCode != current.StatusCode;
+ var publish = forceRaise || statusChanged;
+
+ if (!publish)
+ {
+ // Tag def lookup is best-effort — if absent (defensive against test paths
+ // that seed only a partition state) fall back to exact equality, which is
+ // the legacy behaviour.
+ _tagsByName.TryGetValue(tagRef, out var def);
+ publish = ShouldPublish(def, lastSeen!.Value, current.Value);
+ }
+
+ if (publish)
{
part.LastValues[tagRef] = current;
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, current));
@@ -1344,6 +1360,114 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
}
}
+ ///
+ /// PR-S7-C4 — per-tag deadband / on-change filter. Pure-function entry point so
+ /// unit tests can drive every edge case (NaN, ±Infinity, sign flip, near-zero
+ /// baseline) without spinning up a partition state. Returns true when the
+ /// sample should be emitted on OnDataChange, false when the deadband
+ /// suppresses it.
+ ///
+ ///
+ ///
+ /// Decision matrix:
+ ///
+ /// - Either value NaN / ±Infinity → publish (degenerate samples
+ /// surface to the client).
+ /// - Non-numeric types (string, bool, byte[]) → exact equality
+ /// (!Equals); deadband knobs are ignored.
+ /// - Numeric, both deadbands null → exact equality.
+ /// - Numeric with DeadbandAbsolute set → suppress when
+ /// |delta| < DeadbandAbsolute.
+ /// - Numeric with DeadbandPercent set → suppress when
+ /// |delta| < |prev| * pct / 100; |prev| < 1e-6 falls
+ /// back to absolute (and publishes if no absolute is configured — there
+ /// is no meaningful percent-of-zero threshold).
+ /// - Both set → publish if EITHER threshold says publish (Kepware
+ /// semantics; mirrors AbLegacy's ShouldPublish for consistency).
+ ///
+ ///
+ ///
+ internal static bool ShouldPublish(S7TagDefinition? tag, object? prev, object? current)
+ {
+ // No deadband knobs (or no def at all) → legacy exact-equality.
+ if (tag is null || (tag.DeadbandAbsolute is null && tag.DeadbandPercent is null))
+ return !Equals(prev, current);
+
+ // Non-numeric types ignore deadband — exact equality. Same rule applies if either
+ // sample isn't a numeric scalar (e.g. mid-flight type change, status flip).
+ if (!TryAsDouble(prev, out var prevD) || !TryAsDouble(current, out var currD))
+ return !Equals(prev, current);
+
+ // NaN / ±Infinity bypass: NaN never equals NaN, and ±Inf is a degenerate signal
+ // worth surfacing rather than silently filtering. Treat as "publish".
+ if (double.IsNaN(prevD) || double.IsNaN(currD) ||
+ double.IsInfinity(prevD) || double.IsInfinity(currD))
+ return true;
+
+ var delta = Math.Abs(currD - prevD);
+
+ // Absolute first — cheap and exact.
+ var absPass = tag.DeadbandAbsolute is double abs && delta >= abs;
+
+ bool percentPass;
+ if (tag.DeadbandPercent is double pct)
+ {
+ // Near-zero baseline rule: |prev| < 1e-6 → fall back to absolute. If no
+ // absolute is configured the sample publishes (no usable percent threshold).
+ if (Math.Abs(prevD) < 1e-6)
+ percentPass = tag.DeadbandAbsolute is null ? delta > 0 : false;
+ else
+ percentPass = delta >= Math.Abs(prevD) * pct / 100.0;
+ }
+ else percentPass = false;
+
+ // OR semantics — publish if EITHER deadband triggers. Matches AbLegacy +
+ // Kepware's "either threshold triggers" convention.
+ var pass = (tag.DeadbandAbsolute is not null && absPass)
+ || (tag.DeadbandPercent is not null && percentPass);
+
+ // Edge case: deadbands configured but neither threshold "passes" yet the values
+ // genuinely differ. Suppress — that's the whole point of a deadband filter.
+ return pass;
+ }
+
+ ///
+ /// PR-S7-C4 — best-effort numeric coercion for the deadband filter. Returns
+ /// false for non-numerics (string, bool, byte[], null) so the caller falls
+ /// back to exact-equality.
+ ///
+ private static bool TryAsDouble(object? value, out double result)
+ {
+ switch (value)
+ {
+ case null:
+ case bool:
+ case string:
+ case byte[]:
+ case Array:
+ result = 0;
+ return false;
+ case double d: result = d; return true;
+ case float f: result = f; return true;
+ case int i: result = i; return true;
+ case uint u: result = u; return true;
+ case short s: result = s; return true;
+ case ushort us: result = us; return true;
+ case long l: result = l; return true;
+ case ulong ul: result = ul; return true;
+ case byte b: result = b; return true;
+ case sbyte sb: result = sb; return true;
+ case IConvertible conv:
+ try
+ {
+ result = conv.ToDouble(System.Globalization.CultureInfo.InvariantCulture);
+ return true;
+ }
+ catch { result = 0; return false; }
+ default: result = 0; return false;
+ }
+ }
+
///
/// One subscription owns N partitions, one per distinct publishing interval.
/// is the original (unpartitioned) request preserved for
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverFactoryExtensions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverFactoryExtensions.cs
index e63cce8..41dc890 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverFactoryExtensions.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverFactoryExtensions.cs
@@ -90,7 +90,9 @@ public static class S7DriverFactoryExtensions
Writable: t.Writable ?? true,
StringLength: t.StringLength ?? 254,
WriteIdempotent: t.WriteIdempotent ?? false,
- ScanGroup: string.IsNullOrWhiteSpace(t.ScanGroup) ? null : t.ScanGroup);
+ ScanGroup: string.IsNullOrWhiteSpace(t.ScanGroup) ? null : t.ScanGroup,
+ DeadbandAbsolute: t.DeadbandAbsolute,
+ DeadbandPercent: t.DeadbandPercent);
private static T ParseEnum(string? raw, string driverInstanceId, string field,
string? tagName = null, T? fallback = null) where T : struct, Enum
@@ -167,6 +169,23 @@ public static class S7DriverFactoryExtensions
/// default publishing interval).
///
public string? ScanGroup { get; init; }
+
+ ///
+ /// PR-S7-C4 — optional absolute deadband threshold. Numeric tags whose
+ /// |new - prev| is strictly less than this value are suppressed at
+ /// the driver layer before OnDataChange fires. Ignored for non-numeric
+ /// types. NaN / ±Infinity samples bypass the filter and always publish.
+ ///
+ public double? DeadbandAbsolute { get; init; }
+
+ ///
+ /// PR-S7-C4 — optional percent deadband (0..100). Numeric tags whose
+ /// |new - prev| is strictly less than |prev| * pct / 100 are
+ /// suppressed. Falls back to when
+ /// |prev| < 1e-6 (near-zero baseline rule). When both deadbands are
+ /// set the filters are OR'd — publish if EITHER threshold triggers.
+ ///
+ public double? DeadbandPercent { get; init; }
}
internal sealed class S7ProbeDto
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverOptions.cs
index 5afaec1..b73e2be 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverOptions.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverOptions.cs
@@ -253,6 +253,21 @@ public sealed class S7ProbeOptions
/// (legacy single-rate behaviour). Group names are matched case-insensitively. See
/// docs/v2/s7.md "Per-tag scan groups" section.
///
+///
+/// PR-S7-C4 — optional absolute change threshold for numeric tags. When set the driver
+/// suppresses OnDataChange publishes whose |new - prev| is strictly less
+/// than this value. Ignored for non-numeric types (string, bool, byte[]) which keep
+/// exact-equality semantics. NaN / ±Infinity samples bypass the filter and always
+/// publish. See docs/v2/s7.md "Deadband / on-change" section.
+///
+///
+/// PR-S7-C4 — optional percent-of-baseline change threshold for numeric tags
+/// (0..100; e.g. 10 = "publish when |delta| ≥ 10% of |prev|"). Falls back to
+/// when |prev| < 1e-6 (near-zero baseline rule)
+/// to avoid div-by-zero / divergence; if no absolute is configured the sample publishes.
+/// When both deadbands are set the filters are OR'd — the value publishes if EITHER
+/// threshold says publish (Kepware-style semantics). See docs/v2/s7.md.
+///
public sealed record S7TagDefinition(
string Name,
string Address,
@@ -261,7 +276,9 @@ public sealed record S7TagDefinition(
int StringLength = 254,
bool WriteIdempotent = false,
int? ElementCount = null,
- string? ScanGroup = null);
+ string? ScanGroup = null,
+ double? DeadbandAbsolute = null,
+ double? DeadbandPercent = null);
public enum S7DataType
{
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DeadbandTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DeadbandTests.cs
new file mode 100644
index 0000000..52a15cd
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DeadbandTests.cs
@@ -0,0 +1,291 @@
+using System.Text.Json;
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
+
+///
+/// PR-S7-C4 — unit coverage for the deadband / on-change filter
+/// (). The filter is a pure function so the tests
+/// drive every decision branch (absolute, percent, both, sign flip, NaN, ±Inf,
+/// near-zero baseline, non-numeric) without spinning up the partition / poll-loop
+/// machinery. End-to-end deadband behaviour through PollOnceAsync is implicit
+/// — the same helper is the only suppressor in the publish path.
+///
+[Trait("Category", "Unit")]
+public sealed class S7DeadbandTests
+{
+ private static S7TagDefinition Tag(double? abs = null, double? pct = null,
+ S7DataType type = S7DataType.Float32, string name = "T") =>
+ new(name, "DB1.DBD0", type, DeadbandAbsolute: abs, DeadbandPercent: pct);
+
+ // ---- DeadbandAbsolute --------------------------------------------------
+
+ [Fact]
+ public void Absolute_deadband_walks_the_canonical_sequence()
+ {
+ // DeadbandAbsolute=1.0, samples [10.0, 10.5, 11.5, 11.6] → publishes [10.0, 11.5].
+ // We feed the helper "prev → current" pairs after the first-publish bootstrap.
+ var tag = Tag(abs: 1.0);
+
+ // 10.0 → 10.5: |delta| = 0.5 < 1.0 → suppress
+ S7Driver.ShouldPublish(tag, 10.0, 10.5).ShouldBeFalse();
+ // 10.0 → 11.5: |delta| = 1.5 ≥ 1.0 → publish (prev still 10.0 because last suppressed)
+ S7Driver.ShouldPublish(tag, 10.0, 11.5).ShouldBeTrue();
+ // 11.5 → 11.6: |delta| = 0.1 < 1.0 → suppress
+ S7Driver.ShouldPublish(tag, 11.5, 11.6).ShouldBeFalse();
+ }
+
+ [Fact]
+ public void Absolute_deadband_at_threshold_publishes()
+ {
+ // delta == threshold counts as "publish" (Kepware-aligned ≥ semantics).
+ var tag = Tag(abs: 1.0);
+ S7Driver.ShouldPublish(tag, 10.0, 11.0).ShouldBeTrue();
+ }
+
+ // ---- DeadbandPercent ---------------------------------------------------
+
+ [Fact]
+ public void Percent_deadband_walks_the_canonical_sequence()
+ {
+ // DeadbandPercent=10, samples [100, 105, 115] → publishes [100, 115].
+ var tag = Tag(pct: 10);
+
+ // 100 → 105: |delta|=5 < 10% of 100 (= 10) → suppress
+ S7Driver.ShouldPublish(tag, 100.0, 105.0).ShouldBeFalse();
+ // 100 → 115: |delta|=15 ≥ 10 → publish
+ S7Driver.ShouldPublish(tag, 100.0, 115.0).ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Percent_deadband_negative_baseline_uses_absolute_value()
+ {
+ // |prev|=100, threshold = |delta| ≥ 10. Negative baseline doesn't flip the sign
+ // of the threshold.
+ var tag = Tag(pct: 10);
+ S7Driver.ShouldPublish(tag, -100.0, -95.0).ShouldBeFalse(); // delta=5 < 10
+ S7Driver.ShouldPublish(tag, -100.0, -85.0).ShouldBeTrue(); // delta=15 ≥ 10
+ }
+
+ // ---- Both deadbands set: OR semantics ---------------------------------
+
+ [Fact]
+ public void Both_set_publishes_when_either_threshold_triggers()
+ {
+ // abs=10 fails (delta=5<10), pct=2% with |prev|=100 → threshold=2, delta=5 ≥ 2 → publish.
+ var tag = Tag(abs: 10, pct: 2);
+ S7Driver.ShouldPublish(tag, 100.0, 105.0).ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Both_set_suppresses_when_neither_triggers()
+ {
+ // abs=10 fails (delta=1<10), pct=10% of 100 = 10 fails (1<10) → both suppress → suppress.
+ var tag = Tag(abs: 10, pct: 10);
+ S7Driver.ShouldPublish(tag, 100.0, 101.0).ShouldBeFalse();
+ }
+
+ [Fact]
+ public void Both_set_publishes_when_only_absolute_triggers()
+ {
+ // abs=1 passes (delta=2 ≥ 1), pct=5% of 100 = 5 fails (2 < 5) → publish (OR).
+ var tag = Tag(abs: 1, pct: 5);
+ S7Driver.ShouldPublish(tag, 100.0, 102.0).ShouldBeTrue();
+ }
+
+ // ---- First sample / null prev ------------------------------------------
+ //
+ // PollOnceAsync handles "no prior sample" via the hasPrev check and forces publish.
+ // ShouldPublish is only entered when there IS a prev — but defensively, a null prev
+ // with deadbands set should still publish (an absent baseline isn't a deadband miss).
+
+ [Fact]
+ public void Null_prev_with_deadbands_falls_back_to_inequality()
+ {
+ // null != 5.0 → publish. The helper isn't on the first-sample path in the live
+ // poll loop (PollOnceAsync gates that out), but a null prev arriving here means
+ // "no usable baseline" and the safe answer is publish.
+ var tag = Tag(abs: 1.0);
+ S7Driver.ShouldPublish(tag, null, 5.0).ShouldBeTrue();
+ }
+
+ [Fact]
+ public void No_deadband_falls_back_to_exact_equality()
+ {
+ var tag = Tag();
+ S7Driver.ShouldPublish(tag, 5.0, 5.0).ShouldBeFalse();
+ S7Driver.ShouldPublish(tag, 5.0, 5.001).ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Null_tag_def_falls_back_to_exact_equality()
+ {
+ // Defensive path — no tag def in the map (test seed gap). Behaves like legacy.
+ S7Driver.ShouldPublish(null, 5.0, 5.0).ShouldBeFalse();
+ S7Driver.ShouldPublish(null, 5.0, 6.0).ShouldBeTrue();
+ }
+
+ // ---- Non-numeric types ignore deadband --------------------------------
+
+ [Fact]
+ public void Non_numeric_string_ignores_deadband()
+ {
+ var tag = new S7TagDefinition("S", "DB1.DBB0", S7DataType.String,
+ DeadbandAbsolute: 1.0, DeadbandPercent: 10);
+ S7Driver.ShouldPublish(tag, "hello", "hello").ShouldBeFalse();
+ S7Driver.ShouldPublish(tag, "hello", "world").ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Non_numeric_bool_ignores_deadband()
+ {
+ var tag = new S7TagDefinition("B", "M0.0", S7DataType.Bool,
+ DeadbandAbsolute: 1.0, DeadbandPercent: 10);
+ S7Driver.ShouldPublish(tag, false, false).ShouldBeFalse();
+ S7Driver.ShouldPublish(tag, false, true).ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Non_numeric_byte_array_ignores_deadband()
+ {
+ var tag = Tag(abs: 1.0);
+ var prev = new byte[] { 1, 2, 3 };
+ var same = new byte[] { 1, 2, 3 };
+ var diff = new byte[] { 1, 2, 4 };
+ // byte[] equality is reference, so even "same content" goes through the
+ // !Equals fallback — which is the behaviour PollOnceAsync had before C4
+ // (legacy semantics preserved for non-numeric).
+ S7Driver.ShouldPublish(tag, prev, prev).ShouldBeFalse();
+ S7Driver.ShouldPublish(tag, prev, same).ShouldBeTrue();
+ S7Driver.ShouldPublish(tag, prev, diff).ShouldBeTrue();
+ }
+
+ // ---- NaN / Infinity edges ---------------------------------------------
+
+ [Fact]
+ public void NaN_current_publishes()
+ {
+ var tag = Tag(abs: 1.0);
+ S7Driver.ShouldPublish(tag, 10.0, double.NaN).ShouldBeTrue();
+ }
+
+ [Fact]
+ public void NaN_prev_publishes()
+ {
+ var tag = Tag(abs: 1.0);
+ S7Driver.ShouldPublish(tag, double.NaN, 10.0).ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Positive_infinity_prev_publishes()
+ {
+ var tag = Tag(abs: 1.0);
+ S7Driver.ShouldPublish(tag, double.PositiveInfinity, 10.0).ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Negative_infinity_current_publishes()
+ {
+ var tag = Tag(abs: 1.0);
+ S7Driver.ShouldPublish(tag, 10.0, double.NegativeInfinity).ShouldBeTrue();
+ }
+
+ // ---- Sign flip ---------------------------------------------------------
+
+ [Fact]
+ public void Sign_flip_with_absolute_deadband_publishes()
+ {
+ // 10 → -10, abs=1 → |delta|=20 ≥ 1 → publish.
+ var tag = Tag(abs: 1.0);
+ S7Driver.ShouldPublish(tag, 10.0, -10.0).ShouldBeTrue();
+ }
+
+ // ---- Near-zero baseline -----------------------------------------------
+
+ [Fact]
+ public void Near_zero_baseline_with_percent_only_publishes()
+ {
+ // |prev|=1e-7 < 1e-6 → fall back to absolute. None set → publish.
+ var tag = Tag(pct: 10);
+ S7Driver.ShouldPublish(tag, 1e-7, 5.0).ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Near_zero_baseline_with_percent_and_absolute_uses_absolute()
+ {
+ // |prev|=1e-7 < 1e-6 → percent rule disabled, absolute kicks in.
+ var tag = Tag(abs: 10.0, pct: 10);
+ // delta=5 < abs=10 → suppress
+ S7Driver.ShouldPublish(tag, 1e-7, 5.0).ShouldBeFalse();
+ // delta=15 ≥ abs=10 → publish
+ S7Driver.ShouldPublish(tag, 1e-7, 15.0).ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Zero_baseline_with_percent_only_publishes_any_change()
+ {
+ // |prev|=0 < 1e-6 → near-zero rule, no abs → publish on any change.
+ var tag = Tag(pct: 10);
+ S7Driver.ShouldPublish(tag, 0.0, 0.0001).ShouldBeTrue();
+ }
+
+ // ---- Mixed numeric types coerce to double -----------------------------
+
+ [Fact]
+ public void Int_and_float_compare_via_double_coercion()
+ {
+ // prev as Int16, current as float — both coerce to double.
+ var tag = Tag(abs: 1.0);
+ S7Driver.ShouldPublish(tag, (short)10, 10.5f).ShouldBeFalse(); // |delta|=0.5 < 1
+ S7Driver.ShouldPublish(tag, (short)10, 12f).ShouldBeTrue(); // |delta|=2 ≥ 1
+ }
+
+ // ---- DTO JSON round-trip ----------------------------------------------
+
+ [Fact]
+ public void Tag_dto_json_round_trip_preserves_deadband_fields()
+ {
+ const string json = """
+ {
+ "Host": "192.0.2.10",
+ "Tags": [
+ {
+ "Name": "Pressure",
+ "Address": "DB1.DBD0",
+ "DataType": "Float32",
+ "DeadbandAbsolute": 0.5,
+ "DeadbandPercent": 2.5
+ }
+ ]
+ }
+ """;
+
+ var driver = (S7Driver)S7DriverFactoryExtensions.CreateInstance("s7-deadband", json);
+ // Reach into _tagsByName via reflection — same trick the partitioning tests use.
+ var field = typeof(S7Driver).GetField("_tagsByName",
+ System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+ field.ShouldNotBeNull();
+ // Pre-init: dictionary is empty until InitializeAsync. Build the tag directly via
+ // a JsonSerializer round-trip on the DTO instead so we don't have to spin up TCP.
+ var dtoOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
+ using var doc = JsonDocument.Parse(json);
+ var tagJson = doc.RootElement.GetProperty("Tags")[0].GetRawText();
+ // The DTO type is internal; round-trip through a public projection instead.
+ var probe = JsonSerializer.Deserialize(tagJson, dtoOptions);
+ probe.GetProperty("DeadbandAbsolute").GetDouble().ShouldBe(0.5);
+ probe.GetProperty("DeadbandPercent").GetDouble().ShouldBe(2.5);
+
+ // And the actual driver factory must have plumbed both knobs onto the options so
+ // the live tag definition carries them — interrogate via reflection.
+ var optsField = typeof(S7Driver).GetField("_options",
+ System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+ optsField.ShouldNotBeNull();
+ var opts = (S7DriverOptions)optsField!.GetValue(driver)!;
+ opts.Tags.Count.ShouldBe(1);
+ opts.Tags[0].DeadbandAbsolute.ShouldBe(0.5);
+ opts.Tags[0].DeadbandPercent.ShouldBe(2.5);
+
+ driver.Dispose();
+ }
+}