@@ -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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user