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

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

View File

@@ -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| &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>
/// One subscription owns N partitions, one per distinct publishing interval.
/// <see cref="TagReferences"/> is the original (unpartitioned) request preserved for

View File

@@ -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| &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

View File

@@ -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| &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(
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
{