@@ -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
|
||||
```
|
||||
|
||||
#### 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
|
||||
|
||||
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
|
||||
fake-returned statuses
|
||||
- `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`,
|
||||
`IWritable`, `ITagDiscovery`, `ISubscribable`, `IHostConnectivityProbe`,
|
||||
|
||||
@@ -112,5 +112,43 @@ if ($arrayResult.ExitCode -eq 0) {
|
||||
$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
|
||||
if ($results | Where-Object { -not $_.Passed }) { exit 1 }
|
||||
|
||||
@@ -99,7 +99,8 @@ VALUES (@Gen, @DrvId, @ClusterId, @NsId, 'ablegacy-smoke', 'AbLegacy', N'{
|
||||
"Address": "N7:5",
|
||||
"DataType": "Int",
|
||||
"Writable": true,
|
||||
"WriteIdempotent": true
|
||||
"WriteIdempotent": true,
|
||||
"AbsoluteDeadband": 5
|
||||
},
|
||||
{
|
||||
"Name": "N7_Block",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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| >= 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
|
||||
|
||||
@@ -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| >= 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(
|
||||
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
|
||||
{
|
||||
|
||||
@@ -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