Merge pull request '[s7] S7 — Deadband / on-change with thresholds' (#377) from auto/s7/PR-S7-C4 into auto/driver-gaps

This commit was merged in pull request #377.
This commit is contained in:
2026-04-26 01:17:33 -04:00
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 and `S7Driver.GetPartitionSummary` to inspect the resolved partitioning of
a live subscription handle. 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 ## TSAP / Connection Type
S7comm runs on top of ISO-on-TCP (RFC 1006), and the COTP connection-request S7comm runs on top of ISO-on-TCP (RFC 1006), and the COTP connection-request

View File

@@ -1334,9 +1334,25 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
{ {
var tagRef = part.TagReferences[i]; var tagRef = part.TagReferences[i];
var current = snapshots[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; part.LastValues[tagRef] = current;
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, current)); OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, current));
@@ -1344,6 +1360,114 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
} }
} }
/// <summary>
/// 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 <c>true</c> when the
/// sample should be emitted on <c>OnDataChange</c>, <c>false</c> when the deadband
/// suppresses it.
/// </summary>
/// <remarks>
/// <para>
/// Decision matrix:
/// <list type="bullet">
/// <item>Either value <c>NaN</c> / <c>±Infinity</c> → publish (degenerate samples
/// surface to the client).</item>
/// <item>Non-numeric types (string, bool, byte[]) → exact equality
/// (<c>!Equals</c>); deadband knobs are ignored.</item>
/// <item>Numeric, both deadbands null → exact equality.</item>
/// <item>Numeric with <c>DeadbandAbsolute</c> set → suppress when
/// <c>|delta| &lt; DeadbandAbsolute</c>.</item>
/// <item>Numeric with <c>DeadbandPercent</c> set → suppress when
/// <c>|delta| &lt; |prev| * pct / 100</c>; <c>|prev| &lt; 1e-6</c> falls
/// back to absolute (and publishes if no absolute is configured — there
/// is no meaningful percent-of-zero threshold).</item>
/// <item>Both set → publish if EITHER threshold says publish (Kepware
/// semantics; mirrors AbLegacy's <c>ShouldPublish</c> for consistency).</item>
/// </list>
/// </para>
/// </remarks>
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;
}
/// <summary>
/// PR-S7-C4 — best-effort numeric coercion for the deadband filter. Returns
/// <c>false</c> for non-numerics (string, bool, byte[], null) so the caller falls
/// back to exact-equality.
/// </summary>
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;
}
}
/// <summary> /// <summary>
/// One subscription owns N partitions, one per distinct publishing interval. /// One subscription owns N partitions, one per distinct publishing interval.
/// <see cref="TagReferences"/> is the original (unpartitioned) request preserved for /// <see cref="TagReferences"/> is the original (unpartitioned) request preserved for

View File

@@ -90,7 +90,9 @@ public static class S7DriverFactoryExtensions
Writable: t.Writable ?? true, Writable: t.Writable ?? true,
StringLength: t.StringLength ?? 254, StringLength: t.StringLength ?? 254,
WriteIdempotent: t.WriteIdempotent ?? false, 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<T>(string? raw, string driverInstanceId, string field, private static T ParseEnum<T>(string? raw, string driverInstanceId, string field,
string? tagName = null, T? fallback = null) where T : struct, Enum string? tagName = null, T? fallback = null) where T : struct, Enum
@@ -167,6 +169,23 @@ public static class S7DriverFactoryExtensions
/// default publishing interval). /// default publishing interval).
/// </summary> /// </summary>
public string? ScanGroup { get; init; } public string? ScanGroup { get; init; }
/// <summary>
/// PR-S7-C4 — optional absolute deadband threshold. Numeric tags whose
/// <c>|new - prev|</c> is strictly less than this value are suppressed at
/// the driver layer before <c>OnDataChange</c> fires. Ignored for non-numeric
/// types. NaN / ±Infinity samples bypass the filter and always publish.
/// </summary>
public double? DeadbandAbsolute { get; init; }
/// <summary>
/// PR-S7-C4 — optional percent deadband (0..100). Numeric tags whose
/// <c>|new - prev|</c> is strictly less than <c>|prev| * pct / 100</c> are
/// suppressed. Falls back to <see cref="DeadbandAbsolute"/> when
/// <c>|prev| &lt; 1e-6</c> (near-zero baseline rule). When both deadbands are
/// set the filters are OR'd — publish if EITHER threshold triggers.
/// </summary>
public double? DeadbandPercent { get; init; }
} }
internal sealed class S7ProbeDto internal sealed class S7ProbeDto

View File

@@ -253,6 +253,21 @@ public sealed class S7ProbeOptions
/// (legacy single-rate behaviour). Group names are matched case-insensitively. See /// (legacy single-rate behaviour). Group names are matched case-insensitively. See
/// <c>docs/v2/s7.md</c> "Per-tag scan groups" section. /// <c>docs/v2/s7.md</c> "Per-tag scan groups" section.
/// </param> /// </param>
/// <param name="DeadbandAbsolute">
/// PR-S7-C4 — optional absolute change threshold for numeric tags. When set the driver
/// suppresses <c>OnDataChange</c> publishes whose <c>|new - prev|</c> 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 <c>docs/v2/s7.md</c> "Deadband / on-change" section.
/// </param>
/// <param name="DeadbandPercent">
/// PR-S7-C4 — optional percent-of-baseline change threshold for numeric tags
/// (0..100; e.g. <c>10</c> = "publish when |delta| ≥ 10% of |prev|"). Falls back to
/// <see cref="DeadbandAbsolute"/> when <c>|prev| &lt; 1e-6</c> (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 <c>docs/v2/s7.md</c>.
/// </param>
public sealed record S7TagDefinition( public sealed record S7TagDefinition(
string Name, string Name,
string Address, string Address,
@@ -261,7 +276,9 @@ public sealed record S7TagDefinition(
int StringLength = 254, int StringLength = 254,
bool WriteIdempotent = false, bool WriteIdempotent = false,
int? ElementCount = null, int? ElementCount = null,
string? ScanGroup = null); string? ScanGroup = null,
double? DeadbandAbsolute = null,
double? DeadbandPercent = null);
public enum S7DataType public enum S7DataType
{ {

View File

@@ -0,0 +1,291 @@
using System.Text.Json;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// <summary>
/// PR-S7-C4 — unit coverage for the deadband / on-change filter
/// (<see cref="S7Driver.ShouldPublish"/>). 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 <c>PollOnceAsync</c> is implicit
/// — the same helper is the only suppressor in the publish path.
/// </summary>
[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<JsonElement>(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();
}
}