Auto: ablegacy-8 — per-tag deadband / change filter

Closes #251
This commit is contained in:
Joseph Doherty
2026-04-25 23:50:07 -04:00
parent 69069aa3be
commit eb5286148e
9 changed files with 443 additions and 8 deletions

View File

@@ -24,6 +24,16 @@ public sealed class SubscribeCommand : AbLegacyCommandBase
"Publishing interval in milliseconds (default 1000).")]
public int IntervalMs { get; init; } = 1000;
[CommandOption("deadband-absolute", Description =
"PR 8 — absolute change filter. Suppress notifications until |new - prev| >= this value. " +
"Booleans bypass; strings + status changes always publish.")]
public double? DeadbandAbsolute { get; init; }
[CommandOption("deadband-percent", Description =
"PR 8 — percent-of-previous change filter. Suppress notifications until " +
"|new - prev| >= |prev * pct / 100|. prev=0 always publishes.")]
public double? DeadbandPercent { get; init; }
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
@@ -35,7 +45,9 @@ public sealed class SubscribeCommand : AbLegacyCommandBase
DeviceHostAddress: Gateway,
Address: Address,
DataType: DataType,
Writable: false);
Writable: false,
AbsoluteDeadband: DeadbandAbsolute,
PercentDeadband: DeadbandPercent);
var options = BuildOptions([tag]);
await using var driver = new AbLegacyDriver(options, DriverInstanceId);

View File

@@ -17,6 +17,18 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
private readonly PollGroupEngine _poll;
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, AbLegacyTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// PR 8 — per-tag last published <c>(value, status)</c> cache for the deadband filter.
/// Layered on top of <see cref="PollGroupEngine"/> because the engine's change-detection
/// is binary (publish on any value/status diff). Cleared on <see cref="ShutdownAsync"/>
/// so a reconnect doesn't suppress legitimate post-reconnect updates against stale state.
/// Keyed by full reference (== tag name) — matches the engine's own <c>LastValues</c> key
/// space.
/// </summary>
private readonly Dictionary<string, (object? Value, uint StatusCode)> _lastPublished =
new(StringComparer.OrdinalIgnoreCase);
private readonly object _lastPublishedLock = new();
private DriverHealth _health = new(DriverState.Unknown, null, null);
public event EventHandler<DataChangeEventArgs>? OnDataChange;
@@ -31,8 +43,99 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
_tagFactory = tagFactory ?? new LibplctagLegacyTagFactory();
_poll = new PollGroupEngine(
reader: ReadAsync,
onChange: (handle, tagRef, snapshot) =>
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)));
onChange: DispatchPollChange);
}
/// <summary>
/// PR 8 — wraps the <see cref="PollGroupEngine"/> change callback with a per-tag
/// deadband filter. Booleans bypass (publish on every edge); strings + status changes
/// always publish; numerics pass only when <c>|new - prev|</c> meets the configured
/// absolute and / or percent deadband. First-seen always publishes.
/// </summary>
private void DispatchPollChange(ISubscriptionHandle handle, string tagRef, DataValueSnapshot snapshot)
{
if (!ShouldPublish(tagRef, snapshot)) return;
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot));
}
/// <summary>
/// PR 8 — deadband decision for one new sample. Updates the per-tag last-published
/// cache when the publish goes through so the next sample compares against the actual
/// emitted value (not every polled value).
/// </summary>
internal bool ShouldPublish(string tagRef, DataValueSnapshot snapshot)
{
// Tags absent from config (impossible via the engine path, defensive against callers
// that exercise the dispatch logic in isolation) bypass the filter.
var hasTag = _tagsByName.TryGetValue(tagRef, out var def);
lock (_lastPublishedLock)
{
var firstSeen = !_lastPublished.TryGetValue(tagRef, out var prev);
// First-seen, status change, or no tag config: always publish.
if (firstSeen || prev.StatusCode != snapshot.StatusCode || !hasTag)
{
_lastPublished[tagRef] = (snapshot.Value, snapshot.StatusCode);
return true;
}
// No deadband configured -> defer to PollGroupEngine's value-equality decision
// (the engine already filtered to "different from last engine snapshot" before we
// got here, so any sample reaching this point is a legitimate change).
if (def!.AbsoluteDeadband is null && def.PercentDeadband is null)
{
_lastPublished[tagRef] = (snapshot.Value, snapshot.StatusCode);
return true;
}
// Booleans + strings + non-numerics: deadband is meaningless; publish whenever the
// value differs from the last published one.
if (!TryAsDouble(snapshot.Value, out var newD) || !TryAsDouble(prev.Value, out var prevD))
{
if (Equals(prev.Value, snapshot.Value)) return false;
_lastPublished[tagRef] = (snapshot.Value, snapshot.StatusCode);
return true;
}
var delta = Math.Abs(newD - prevD);
var absPass = def.AbsoluteDeadband is double abs && delta >= abs;
// Percent: |prev| == 0 short-circuits to "always publish on any change" — avoids
// div-by-zero and matches Kepware's documented behaviour.
bool percentPass;
if (def.PercentDeadband is double pct)
{
if (prevD == 0) percentPass = delta > 0;
else percentPass = delta >= Math.Abs(prevD * pct / 100.0);
}
else percentPass = false;
// Logical OR — either filter triggering is enough. Matches the spec note in the
// PR plan ("Both deadbands set -> either triggers, Kepware semantics").
var pass = (def.AbsoluteDeadband is not null && absPass)
|| (def.PercentDeadband is not null && percentPass);
if (!pass) return false;
_lastPublished[tagRef] = (snapshot.Value, snapshot.StatusCode);
return true;
}
}
private static bool TryAsDouble(object? value, out double result)
{
switch (value)
{
case null: result = 0; return false;
case bool: result = 0; return false; // booleans use the equality fast path
case string: result = 0; return false;
case Array: result = 0; return false;
case IConvertible conv:
try { result = conv.ToDouble(System.Globalization.CultureInfo.InvariantCulture); return true; }
catch { result = 0; return false; }
default: result = 0; return false;
}
}
public string DriverInstanceId => _driverInstanceId;
@@ -91,6 +194,10 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
}
_devices.Clear();
_tagsByName.Clear();
// PR 8 — clear the deadband last-published cache so a ReinitializeAsync (or a
// reconnect-driven shutdown) doesn't suppress the very first post-reconnect sample
// by comparing it against pre-disconnect state.
lock (_lastPublishedLock) { _lastPublished.Clear(); }
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
}

View File

@@ -52,7 +52,9 @@ public static class AbLegacyDriverFactoryExtensions
tagName: t.Name),
Writable: t.Writable ?? true,
WriteIdempotent: t.WriteIdempotent ?? false,
ArrayLength: t.ArrayLength))]
ArrayLength: t.ArrayLength,
AbsoluteDeadband: t.AbsoluteDeadband,
PercentDeadband: t.PercentDeadband))]
: [],
Probe = new AbLegacyProbeOptions
{
@@ -118,6 +120,20 @@ public static class AbLegacyDriverFactoryExtensions
/// driver issues a single contiguous PCCC block read for N elements.
/// </summary>
public int? ArrayLength { get; init; }
/// <summary>
/// PR 8 — optional absolute change filter for numeric tags. <c>OnDataChange</c> is
/// suppressed unless <c>|new - prev| &gt;= AbsoluteDeadband</c>. Booleans bypass;
/// strings + status changes always publish.
/// </summary>
public double? AbsoluteDeadband { get; init; }
/// <summary>
/// PR 8 — optional percent-of-previous change filter for numeric tags.
/// <c>OnDataChange</c> is suppressed unless <c>|new - prev| &gt;= |prev * Percent / 100|</c>.
/// <c>prev == 0</c> always publishes (avoids division-by-zero).
/// </summary>
public double? PercentDeadband { get; init; }
}
internal sealed class AbLegacyProbeDto

View File

@@ -22,9 +22,21 @@ public sealed record AbLegacyDeviceOptions(
string? DeviceName = null);
/// <summary>
/// One PCCC-backed OPC UA variable. <paramref name="Address"/> is the canonical PCCC
/// file-address string that parses via <see cref="AbLegacyAddress.TryParse(string?)"/>.
/// One PCCC-backed OPC UA variable. <c>Address</c> is the canonical PCCC file-address
/// string that parses via <see cref="AbLegacyAddress.TryParse(string?)"/>.
/// </summary>
/// <remarks>
/// PR 8 deadband fields:
/// <list type="bullet">
/// <item><c>AbsoluteDeadband</c> — when set, suppresses <c>OnDataChange</c> for numeric
/// tags unless <c>|new - prev| &gt;= AbsoluteDeadband</c>.</item>
/// <item><c>PercentDeadband</c> — when set, suppresses unless
/// <c>|new - prev| &gt;= |prev * Percent / 100|</c>; <c>prev == 0</c> always publishes.</item>
/// </list>
/// Booleans bypass deadband entirely (every transition publishes); strings + status
/// changes always publish; first-seen always publishes; both set → logical-OR (Kepware
/// semantics).
/// </remarks>
public sealed record AbLegacyTagDefinition(
string Name,
string DeviceHostAddress,
@@ -32,7 +44,9 @@ public sealed record AbLegacyTagDefinition(
AbLegacyDataType DataType,
bool Writable = true,
bool WriteIdempotent = false,
int? ArrayLength = null);
int? ArrayLength = null,
double? AbsoluteDeadband = null,
double? PercentDeadband = null);
public sealed class AbLegacyProbeOptions
{