100
docs/v2/s7.md
100
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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
/// <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| < DeadbandAbsolute</c>.</item>
|
||||
/// <item>Numeric with <c>DeadbandPercent</c> set → suppress when
|
||||
/// <c>|delta| < |prev| * pct / 100</c>; <c>|prev| < 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>
|
||||
/// One subscription owns N partitions, one per distinct publishing interval.
|
||||
/// <see cref="TagReferences"/> is the original (unpartitioned) request preserved for
|
||||
|
||||
@@ -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<T>(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).
|
||||
/// </summary>
|
||||
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| < 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
|
||||
|
||||
@@ -253,6 +253,21 @@ public sealed class S7ProbeOptions
|
||||
/// (legacy single-rate behaviour). Group names are matched case-insensitively. See
|
||||
/// <c>docs/v2/s7.md</c> "Per-tag scan groups" section.
|
||||
/// </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| < 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(
|
||||
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
|
||||
{
|
||||
|
||||
291
tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DeadbandTests.cs
Normal file
291
tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DeadbandTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user