[ablegacy] AbLegacy — Per-tag deadband / change filter #371
@@ -95,6 +95,31 @@ PLC-managed — use with caution.
|
|||||||
otopcua-ablegacy-cli subscribe -g ab://192.168.1.20/1,0 -a N7:10 -t Int -i 500
|
otopcua-ablegacy-cli subscribe -g ab://192.168.1.20/1,0 -a N7:10 -t Int -i 500
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Deadband
|
||||||
|
|
||||||
|
PR 8 — per-tag absolute / percent change filter on top of the polled subscription. The driver
|
||||||
|
caches the last *published* value per tag and suppresses `OnDataChange` notifications until the
|
||||||
|
new sample crosses the configured threshold.
|
||||||
|
|
||||||
|
| Flag | Effect |
|
||||||
|
|---|---|
|
||||||
|
| `--deadband-absolute <value>` | Suppress until `|new - prev| >= value`. |
|
||||||
|
| `--deadband-percent <value>` | Suppress until `|new - prev| >= |prev * value / 100|`. `prev == 0` always publishes (avoids div-by-zero). |
|
||||||
|
|
||||||
|
Booleans bypass the filter entirely (every transition publishes); strings + status changes
|
||||||
|
always publish; first-seen always publishes; both flags set → either passing triggers a
|
||||||
|
publish (Kepware-style logical OR).
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Float — drop sub-0.5 jitter from the noisy load-cell address.
|
||||||
|
otopcua-ablegacy-cli subscribe -g ab://192.168.1.20/1,0 -a F8:0 -t Float -i 500 `
|
||||||
|
--deadband-absolute 0.5
|
||||||
|
|
||||||
|
# Integer — only fire on >= 5% deviation from the last reported value.
|
||||||
|
otopcua-ablegacy-cli subscribe -g ab://192.168.1.20/1,0 -a N7:10 -t Int -i 500 `
|
||||||
|
--deadband-percent 5
|
||||||
|
```
|
||||||
|
|
||||||
## Array reads
|
## Array reads
|
||||||
|
|
||||||
PR 7 — one PCCC frame can carry up to ~120 words. Address an array tag with either the
|
PR 7 — one PCCC frame can carry up to ~120 words. Address an array tag with either the
|
||||||
|
|||||||
@@ -49,6 +49,12 @@ supplies a `FakeAbLegacyTag`.
|
|||||||
- `AbLegacyHostAndStatusTests` — probe + host-status transitions driven by
|
- `AbLegacyHostAndStatusTests` — probe + host-status transitions driven by
|
||||||
fake-returned statuses
|
fake-returned statuses
|
||||||
- `AbLegacyDriverTests` — `IDriver` lifecycle
|
- `AbLegacyDriverTests` — `IDriver` lifecycle
|
||||||
|
- `AbLegacyDeadbandTests` — PR 8 per-tag deadband / change filter:
|
||||||
|
absolute-only suppression sequence `[10.0, 10.5, 11.5, 11.6] -> [10.0, 11.5]`,
|
||||||
|
percent-only suppression with a zero-prev short-circuit, both-set logical-OR
|
||||||
|
semantics (Kepware), Boolean edge-only publish, string change-only publish,
|
||||||
|
status-change always-publish, first-seen always-publish, ReinitializeAsync
|
||||||
|
cache wipe, JSON DTO round-trip.
|
||||||
|
|
||||||
Capability surfaces whose contract is verified: `IDriver`, `IReadable`,
|
Capability surfaces whose contract is verified: `IDriver`, `IReadable`,
|
||||||
`IWritable`, `ITagDiscovery`, `ISubscribable`, `IHostConnectivityProbe`,
|
`IWritable`, `ITagDiscovery`, `ISubscribable`, `IHostConnectivityProbe`,
|
||||||
|
|||||||
@@ -112,5 +112,43 @@ if ($arrayResult.ExitCode -eq 0) {
|
|||||||
$results += @{ Passed = $false; Reason = "array read exit $($arrayResult.ExitCode)" }
|
$results += @{ Passed = $false; Reason = "array read exit $($arrayResult.ExitCode)" }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# PR 8 — deadband subscribe assertion. Subscribe with --deadband-absolute 5,
|
||||||
|
# write three small deltas (each within the 5-unit deadband), assert exactly
|
||||||
|
# one notification fires (the first-seen sample). The fourth write breaks
|
||||||
|
# above the threshold and the subscription should fire again.
|
||||||
|
Write-Header "Deadband subscribe (--deadband-absolute 5)"
|
||||||
|
$baseValue = Get-Random -Minimum 100 -Maximum 200
|
||||||
|
& $abLegacyCli.File @($abLegacyCli.PrefixArgs) `
|
||||||
|
@("write") + $commonAbLegacy + @("-a", $Address, "-t", "Int", "-v", $baseValue) | Out-Null
|
||||||
|
$subscribeProc = Start-Process -FilePath $abLegacyCli.File `
|
||||||
|
-ArgumentList ($abLegacyCli.PrefixArgs + @("subscribe") + $commonAbLegacy `
|
||||||
|
+ @("-a", $Address, "-t", "Int", "-i", "200", "--deadband-absolute", "5")) `
|
||||||
|
-PassThru -RedirectStandardOutput "$env:TEMP/ablegacy-deadband.out" `
|
||||||
|
-RedirectStandardError "$env:TEMP/ablegacy-deadband.err"
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
# Three small deltas within deadband.
|
||||||
|
& $abLegacyCli.File @($abLegacyCli.PrefixArgs) `
|
||||||
|
@("write") + $commonAbLegacy + @("-a", $Address, "-t", "Int", "-v", ($baseValue + 1)) | Out-Null
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
& $abLegacyCli.File @($abLegacyCli.PrefixArgs) `
|
||||||
|
@("write") + $commonAbLegacy + @("-a", $Address, "-t", "Int", "-v", ($baseValue + 2)) | Out-Null
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
& $abLegacyCli.File @($abLegacyCli.PrefixArgs) `
|
||||||
|
@("write") + $commonAbLegacy + @("-a", $Address, "-t", "Int", "-v", ($baseValue + 3)) | Out-Null
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
Stop-Process -Id $subscribeProc.Id -Force -ErrorAction SilentlyContinue
|
||||||
|
$subscribeOutput = Get-Content "$env:TEMP/ablegacy-deadband.out" -ErrorAction SilentlyContinue
|
||||||
|
# Count `=` lines (the SubscribeCommand format prints one per OnDataChange). Expect exactly 1
|
||||||
|
# (the first-seen sample at $baseValue) — none of the +1/+2/+3 deltas crosses the 5 absolute.
|
||||||
|
$notifyLines = @($subscribeOutput | Where-Object { $_ -match " = " })
|
||||||
|
if ($notifyLines.Count -eq 1) {
|
||||||
|
Write-Pass "deadband subscribe emitted 1 notification (initial only); 3 sub-threshold writes suppressed"
|
||||||
|
$results += @{ Passed = $true }
|
||||||
|
} else {
|
||||||
|
Write-Fail "deadband subscribe expected 1 notification; got $($notifyLines.Count)"
|
||||||
|
Write-Host ($subscribeOutput -join "`n")
|
||||||
|
$results += @{ Passed = $false; Reason = "deadband notify count $($notifyLines.Count)" }
|
||||||
|
}
|
||||||
|
|
||||||
Write-Summary -Title "AB Legacy e2e" -Results $results
|
Write-Summary -Title "AB Legacy e2e" -Results $results
|
||||||
if ($results | Where-Object { -not $_.Passed }) { exit 1 }
|
if ($results | Where-Object { -not $_.Passed }) { exit 1 }
|
||||||
|
|||||||
@@ -99,7 +99,8 @@ VALUES (@Gen, @DrvId, @ClusterId, @NsId, 'ablegacy-smoke', 'AbLegacy', N'{
|
|||||||
"Address": "N7:5",
|
"Address": "N7:5",
|
||||||
"DataType": "Int",
|
"DataType": "Int",
|
||||||
"Writable": true,
|
"Writable": true,
|
||||||
"WriteIdempotent": true
|
"WriteIdempotent": true,
|
||||||
|
"AbsoluteDeadband": 5
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"Name": "N7_Block",
|
"Name": "N7_Block",
|
||||||
|
|||||||
@@ -24,6 +24,16 @@ public sealed class SubscribeCommand : AbLegacyCommandBase
|
|||||||
"Publishing interval in milliseconds (default 1000).")]
|
"Publishing interval in milliseconds (default 1000).")]
|
||||||
public int IntervalMs { get; init; } = 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)
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
{
|
{
|
||||||
ConfigureLogging();
|
ConfigureLogging();
|
||||||
@@ -35,7 +45,9 @@ public sealed class SubscribeCommand : AbLegacyCommandBase
|
|||||||
DeviceHostAddress: Gateway,
|
DeviceHostAddress: Gateway,
|
||||||
Address: Address,
|
Address: Address,
|
||||||
DataType: DataType,
|
DataType: DataType,
|
||||||
Writable: false);
|
Writable: false,
|
||||||
|
AbsoluteDeadband: DeadbandAbsolute,
|
||||||
|
PercentDeadband: DeadbandPercent);
|
||||||
var options = BuildOptions([tag]);
|
var options = BuildOptions([tag]);
|
||||||
|
|
||||||
await using var driver = new AbLegacyDriver(options, DriverInstanceId);
|
await using var driver = new AbLegacyDriver(options, DriverInstanceId);
|
||||||
|
|||||||
@@ -17,6 +17,18 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
|||||||
private readonly PollGroupEngine _poll;
|
private readonly PollGroupEngine _poll;
|
||||||
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private readonly Dictionary<string, AbLegacyTagDefinition> _tagsByName = 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);
|
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||||
|
|
||||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||||
@@ -31,8 +43,99 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
|||||||
_tagFactory = tagFactory ?? new LibplctagLegacyTagFactory();
|
_tagFactory = tagFactory ?? new LibplctagLegacyTagFactory();
|
||||||
_poll = new PollGroupEngine(
|
_poll = new PollGroupEngine(
|
||||||
reader: ReadAsync,
|
reader: ReadAsync,
|
||||||
onChange: (handle, tagRef, snapshot) =>
|
onChange: DispatchPollChange);
|
||||||
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)));
|
}
|
||||||
|
|
||||||
|
/// <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;
|
public string DriverInstanceId => _driverInstanceId;
|
||||||
@@ -91,6 +194,10 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
|||||||
}
|
}
|
||||||
_devices.Clear();
|
_devices.Clear();
|
||||||
_tagsByName.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);
|
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,9 @@ public static class AbLegacyDriverFactoryExtensions
|
|||||||
tagName: t.Name),
|
tagName: t.Name),
|
||||||
Writable: t.Writable ?? true,
|
Writable: t.Writable ?? true,
|
||||||
WriteIdempotent: t.WriteIdempotent ?? false,
|
WriteIdempotent: t.WriteIdempotent ?? false,
|
||||||
ArrayLength: t.ArrayLength))]
|
ArrayLength: t.ArrayLength,
|
||||||
|
AbsoluteDeadband: t.AbsoluteDeadband,
|
||||||
|
PercentDeadband: t.PercentDeadband))]
|
||||||
: [],
|
: [],
|
||||||
Probe = new AbLegacyProbeOptions
|
Probe = new AbLegacyProbeOptions
|
||||||
{
|
{
|
||||||
@@ -118,6 +120,20 @@ public static class AbLegacyDriverFactoryExtensions
|
|||||||
/// driver issues a single contiguous PCCC block read for N elements.
|
/// driver issues a single contiguous PCCC block read for N elements.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int? ArrayLength { get; init; }
|
public int? ArrayLength { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR 8 — optional absolute change filter for numeric tags. <c>OnDataChange</c> is
|
||||||
|
/// suppressed unless <c>|new - prev| >= 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| >= |prev * Percent / 100|</c>.
|
||||||
|
/// <c>prev == 0</c> always publishes (avoids division-by-zero).
|
||||||
|
/// </summary>
|
||||||
|
public double? PercentDeadband { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed class AbLegacyProbeDto
|
internal sealed class AbLegacyProbeDto
|
||||||
|
|||||||
@@ -22,9 +22,21 @@ public sealed record AbLegacyDeviceOptions(
|
|||||||
string? DeviceName = null);
|
string? DeviceName = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One PCCC-backed OPC UA variable. <paramref name="Address"/> is the canonical PCCC
|
/// One PCCC-backed OPC UA variable. <c>Address</c> is the canonical PCCC file-address
|
||||||
/// file-address string that parses via <see cref="AbLegacyAddress.TryParse(string?)"/>.
|
/// string that parses via <see cref="AbLegacyAddress.TryParse(string?)"/>.
|
||||||
/// </summary>
|
/// </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| >= AbsoluteDeadband</c>.</item>
|
||||||
|
/// <item><c>PercentDeadband</c> — when set, suppresses unless
|
||||||
|
/// <c>|new - prev| >= |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(
|
public sealed record AbLegacyTagDefinition(
|
||||||
string Name,
|
string Name,
|
||||||
string DeviceHostAddress,
|
string DeviceHostAddress,
|
||||||
@@ -32,7 +44,9 @@ public sealed record AbLegacyTagDefinition(
|
|||||||
AbLegacyDataType DataType,
|
AbLegacyDataType DataType,
|
||||||
bool Writable = true,
|
bool Writable = true,
|
||||||
bool WriteIdempotent = false,
|
bool WriteIdempotent = false,
|
||||||
int? ArrayLength = null);
|
int? ArrayLength = null,
|
||||||
|
double? AbsoluteDeadband = null,
|
||||||
|
double? PercentDeadband = null);
|
||||||
|
|
||||||
public sealed class AbLegacyProbeOptions
|
public sealed class AbLegacyProbeOptions
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR 8 — per-tag deadband / change filter tests. The driver layers the deadband on top of
|
||||||
|
/// <see cref="PollGroupEngine"/>'s built-in change-detection: the engine fires its callback
|
||||||
|
/// whenever value or status differs from its own last snapshot; the driver then applies the
|
||||||
|
/// per-tag absolute / percent / type-aware filter before raising <c>OnDataChange</c>.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class AbLegacyDeadbandTests
|
||||||
|
{
|
||||||
|
private const uint Good = 0x00000000u;
|
||||||
|
private const uint BadCommunication = 0x80050000u;
|
||||||
|
|
||||||
|
private static async Task<AbLegacyDriver> BuildDriverAsync(params AbLegacyTagDefinition[] tags)
|
||||||
|
{
|
||||||
|
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [], // ShouldPublish only inspects the tag-by-name map; no device is needed.
|
||||||
|
Tags = tags,
|
||||||
|
Probe = new AbLegacyProbeOptions { Enabled = false, ProbeAddress = null },
|
||||||
|
}, "drv-deadband", new FakeAbLegacyTagFactory());
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
return drv;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DataValueSnapshot Snap(object? v, uint status = Good) =>
|
||||||
|
new(v, status, DateTime.UtcNow, DateTime.UtcNow);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Absolute_deadband_suppresses_changes_below_threshold()
|
||||||
|
{
|
||||||
|
var tag = new AbLegacyTagDefinition("t", "ab://h/", "F8:0", AbLegacyDataType.Float,
|
||||||
|
AbsoluteDeadband: 1.0);
|
||||||
|
await using var drv = await BuildDriverAsync(tag);
|
||||||
|
|
||||||
|
// Walk the published flag for each sample — the asserted sequence is [10.0, 11.5].
|
||||||
|
drv.ShouldPublish("t", Snap(10.0)).ShouldBeTrue(); // first-seen
|
||||||
|
drv.ShouldPublish("t", Snap(10.5)).ShouldBeFalse(); // 0.5 < 1.0
|
||||||
|
drv.ShouldPublish("t", Snap(11.5)).ShouldBeTrue(); // 1.5 from last published 10.0
|
||||||
|
drv.ShouldPublish("t", Snap(11.6)).ShouldBeFalse(); // 0.1 from last published 11.5
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Percent_deadband_suppresses_changes_below_threshold()
|
||||||
|
{
|
||||||
|
var tag = new AbLegacyTagDefinition("t", "ab://h/", "F8:0", AbLegacyDataType.Float,
|
||||||
|
PercentDeadband: 10.0);
|
||||||
|
await using var drv = await BuildDriverAsync(tag);
|
||||||
|
|
||||||
|
drv.ShouldPublish("t", Snap(100.0)).ShouldBeTrue(); // first-seen
|
||||||
|
drv.ShouldPublish("t", Snap(105.0)).ShouldBeFalse(); // 5% < 10%
|
||||||
|
drv.ShouldPublish("t", Snap(115.0)).ShouldBeTrue(); // |115-100|=15 >= 10% of 100
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Percent_deadband_with_zero_prev_always_publishes_on_change()
|
||||||
|
{
|
||||||
|
var tag = new AbLegacyTagDefinition("t", "ab://h/", "F8:0", AbLegacyDataType.Float,
|
||||||
|
PercentDeadband: 50.0);
|
||||||
|
await using var drv = await BuildDriverAsync(tag);
|
||||||
|
|
||||||
|
drv.ShouldPublish("t", Snap(0.0)).ShouldBeTrue(); // first-seen
|
||||||
|
drv.ShouldPublish("t", Snap(0.001)).ShouldBeTrue(); // prev=0 short-circuit
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Boolean_publishes_every_transition_and_suppresses_no_op()
|
||||||
|
{
|
||||||
|
var tag = new AbLegacyTagDefinition("t", "ab://h/", "B3:0/0", AbLegacyDataType.Bit,
|
||||||
|
AbsoluteDeadband: 1.0);
|
||||||
|
await using var drv = await BuildDriverAsync(tag);
|
||||||
|
|
||||||
|
// Edge sequence false -> true -> false -> true => 4 publishes.
|
||||||
|
drv.ShouldPublish("t", Snap(false)).ShouldBeTrue();
|
||||||
|
drv.ShouldPublish("t", Snap(true)).ShouldBeTrue();
|
||||||
|
drv.ShouldPublish("t", Snap(false)).ShouldBeTrue();
|
||||||
|
drv.ShouldPublish("t", Snap(true)).ShouldBeTrue();
|
||||||
|
|
||||||
|
// No-op (true -> true) suppressed.
|
||||||
|
drv.ShouldPublish("t", Snap(true)).ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Status_change_always_publishes_even_within_deadband()
|
||||||
|
{
|
||||||
|
var tag = new AbLegacyTagDefinition("t", "ab://h/", "F8:0", AbLegacyDataType.Float,
|
||||||
|
AbsoluteDeadband: 100.0); // ridiculously large — would normally suppress everything
|
||||||
|
await using var drv = await BuildDriverAsync(tag);
|
||||||
|
|
||||||
|
drv.ShouldPublish("t", Snap(10.0, Good)).ShouldBeTrue(); // first-seen
|
||||||
|
drv.ShouldPublish("t", Snap(10.5, Good)).ShouldBeFalse(); // within deadband
|
||||||
|
drv.ShouldPublish("t", Snap(10.5, BadCommunication)).ShouldBeTrue(); // status flip
|
||||||
|
drv.ShouldPublish("t", Snap(10.5, Good)).ShouldBeTrue(); // status flip back
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task String_publishes_on_value_change_and_suppresses_no_op()
|
||||||
|
{
|
||||||
|
var tag = new AbLegacyTagDefinition("t", "ab://h/", "ST20:0", AbLegacyDataType.String,
|
||||||
|
AbsoluteDeadband: 1.0); // deadband meaningless on strings — should be ignored
|
||||||
|
await using var drv = await BuildDriverAsync(tag);
|
||||||
|
|
||||||
|
drv.ShouldPublish("t", Snap("alpha")).ShouldBeTrue(); // first-seen
|
||||||
|
drv.ShouldPublish("t", Snap("alpha")).ShouldBeFalse(); // identical
|
||||||
|
drv.ShouldPublish("t", Snap("beta")).ShouldBeTrue(); // change
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Both_deadbands_set_either_triggers_publish()
|
||||||
|
{
|
||||||
|
// 1.0 absolute OR 50% percent — picking deltas where one passes, the other does not.
|
||||||
|
var tag = new AbLegacyTagDefinition("t", "ab://h/", "F8:0", AbLegacyDataType.Float,
|
||||||
|
AbsoluteDeadband: 1.0, PercentDeadband: 50.0);
|
||||||
|
await using var drv = await BuildDriverAsync(tag);
|
||||||
|
|
||||||
|
drv.ShouldPublish("t", Snap(2.0)).ShouldBeTrue(); // first-seen
|
||||||
|
// |3.0 - 2.0| = 1.0 -> absolute passes (>= 1.0); percent test 50% of 2.0 = 1.0 -> 1.0 >= 1.0 also passes.
|
||||||
|
drv.ShouldPublish("t", Snap(3.0)).ShouldBeTrue();
|
||||||
|
// After last-publish=3.0: small delta of 0.6, absolute fails (0.6 < 1.0), percent fails (0.6 < 1.5).
|
||||||
|
drv.ShouldPublish("t", Snap(3.6)).ShouldBeFalse();
|
||||||
|
// |5.0 - 3.0| = 2.0; absolute passes (>= 1.0).
|
||||||
|
drv.ShouldPublish("t", Snap(5.0)).ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task First_seen_always_publishes_even_with_huge_deadband()
|
||||||
|
{
|
||||||
|
var tag = new AbLegacyTagDefinition("t", "ab://h/", "F8:0", AbLegacyDataType.Float,
|
||||||
|
AbsoluteDeadband: 1e9);
|
||||||
|
await using var drv = await BuildDriverAsync(tag);
|
||||||
|
|
||||||
|
drv.ShouldPublish("t", Snap(1.0)).ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReinitializeAsync_clears_last_published_cache()
|
||||||
|
{
|
||||||
|
var tag = new AbLegacyTagDefinition("t", "ab://h/", "F8:0", AbLegacyDataType.Float,
|
||||||
|
AbsoluteDeadband: 1.0);
|
||||||
|
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [],
|
||||||
|
Tags = [tag],
|
||||||
|
Probe = new AbLegacyProbeOptions { Enabled = false, ProbeAddress = null },
|
||||||
|
}, "drv-reinit", new FakeAbLegacyTagFactory());
|
||||||
|
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
drv.ShouldPublish("t", Snap(10.0)).ShouldBeTrue();
|
||||||
|
drv.ShouldPublish("t", Snap(10.2)).ShouldBeFalse();
|
||||||
|
|
||||||
|
await drv.ReinitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
// After reinit the cache is empty — the very next sample is treated as first-seen and
|
||||||
|
// publishes regardless of how close it is to the pre-reinit last-published value.
|
||||||
|
drv.ShouldPublish("t", Snap(10.2)).ShouldBeTrue();
|
||||||
|
|
||||||
|
await drv.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Tag_without_deadband_publishes_every_sample_reaching_dispatcher()
|
||||||
|
{
|
||||||
|
// No deadband fields set — the driver passes through whatever the engine gives it.
|
||||||
|
// (PollGroupEngine itself filters by value-equality, so any sample reaching ShouldPublish
|
||||||
|
// is already a real change.)
|
||||||
|
var tag = new AbLegacyTagDefinition("t", "ab://h/", "F8:0", AbLegacyDataType.Float);
|
||||||
|
await using var drv = await BuildDriverAsync(tag);
|
||||||
|
|
||||||
|
drv.ShouldPublish("t", Snap(1.0)).ShouldBeTrue();
|
||||||
|
drv.ShouldPublish("t", Snap(1.001)).ShouldBeTrue();
|
||||||
|
drv.ShouldPublish("t", Snap(1.002)).ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Dto_round_trip_preserves_deadband_fields()
|
||||||
|
{
|
||||||
|
const string json = """
|
||||||
|
{
|
||||||
|
"Devices": [
|
||||||
|
{ "HostAddress": "ab://10.0.0.5/1,0", "PlcFamily": "Slc500" }
|
||||||
|
],
|
||||||
|
"Tags": [
|
||||||
|
{
|
||||||
|
"Name": "T1",
|
||||||
|
"DeviceHostAddress": "ab://10.0.0.5/1,0",
|
||||||
|
"Address": "F8:0",
|
||||||
|
"DataType": "Float",
|
||||||
|
"AbsoluteDeadband": 0.5,
|
||||||
|
"PercentDeadband": 5.0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
var drv = AbLegacyDriverFactoryExtensions.CreateInstance("drv-json", json);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await drv.InitializeAsync(json, CancellationToken.None);
|
||||||
|
// ShouldPublish exercises the deadband — confirms the DTO values landed on the
|
||||||
|
// AbLegacyTagDefinition.
|
||||||
|
drv.ShouldPublish("T1", Snap(100.0)).ShouldBeTrue(); // first-seen
|
||||||
|
drv.ShouldPublish("T1", Snap(100.4)).ShouldBeFalse(); // 0.4 < 0.5 absolute
|
||||||
|
drv.ShouldPublish("T1", Snap(101.0)).ShouldBeTrue(); // 1.0 >= 0.5 absolute
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await drv.DisposeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user