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(); + } +}