fix(modbus): deadband baseline on (value, status) so the Good recovery publishes

H2: a Bad/null publish drops the deadband baseline and any status change
publishes, so a device that returns holding the identical value is no longer
swallowed by |new - old| < deadband (the #527 value-only-dedup trap).
Integration case drives a real drop/restore through a loopback relay.

Refs #741
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 15:05:25 -04:00
parent 530f1b2f0b
commit ae20519e9d
3 changed files with 128 additions and 12 deletions
@@ -49,10 +49,10 @@ public sealed class ModbusDriver
// authored _tagsByRawPath table (v3: the reference is always a RawPath; a miss is a miss).
private readonly EquipmentTagRefResolver<ModbusTagDefinition> _resolver;
// Last-published value per tag, keyed by FullReference. Used by ShouldPublish to apply
// the deadband filter. Stored as object so all numeric types share one map; the comparison
// does a typed cast inside.
private readonly ConcurrentDictionary<string, object> _lastPublishedByRef = new(StringComparer.OrdinalIgnoreCase);
// Last-published (value, status) per tag, keyed by FullReference. Used by ShouldPublish to apply
// the deadband filter. The value is stored as object so all numeric types share one map; the
// comparison does a typed cast inside. The status is part of the baseline — see ShouldPublish.
private readonly ConcurrentDictionary<string, (object Value, uint Status)> _lastPublishedByRef = new(StringComparer.OrdinalIgnoreCase);
// Per-cycle buffer of the items this driver actually PUBLISHED (i.e. that passed ShouldPublish),
// keyed by subscription handle. The poll engine collects its own batch list unconditionally — it
@@ -233,31 +233,51 @@ public sealed class ModbusDriver
/// <summary>Format a per-slave host string. Multi-slave deployments distinguish breakers by this string.</summary>
private string BuildSlaveHostName(byte unitId) => $"{_options.Host}:{_options.Port}/unit{unitId}";
private bool ShouldPublish(string tagRef, DataValueSnapshot snapshot)
/// <summary>
/// Deadband filter for the subscribe path. The baseline is keyed on <b>(value, status)</b>, never
/// value alone: a Bad/null publish drops the baseline, and a status change always publishes. A
/// device that comes back after a comms drop usually comes back holding the SAME reading, so the
/// old value-only comparison (<c>|10 − 10| &lt; deadband</c>) swallowed the Good recovery and left
/// the node Bad until the value drifted past the deadband — the #527 trap.
/// </summary>
/// <param name="tagRef">The subscribed reference.</param>
/// <param name="snapshot">The snapshot the poll engine is about to raise.</param>
/// <returns><see langword="true"/> when the snapshot must be published.</returns>
internal bool ShouldPublish(string tagRef, DataValueSnapshot snapshot)
{
if (!_resolver.TryResolve(tagRef, out var tag) || tag.Deadband is null) return true;
if (snapshot.Value is null) return true;
// Deadband only applies to numeric scalar types — array / Bool / String publishes
// unconditionally. Easier to special-case skip than to enumerate the supported types.
if (tag.ArrayCount.HasValue || tag.DataType is ModbusDataType.Bool or ModbusDataType.BitInRegister or ModbusDataType.String)
return true;
if (!_lastPublishedByRef.TryGetValue(tagRef, out var prev))
if (snapshot.Value is null || StatusCodeIsBad(snapshot.StatusCode))
{
// First sample passes through unconditionally — the threshold can't be evaluated
// without a baseline. The publish lands and seeds the comparison.
_lastPublishedByRef[tagRef] = snapshot.Value;
// A Bad publish always passes AND drops the baseline, so the next Good sample is
// compared against nothing and publishes — whatever value it carries.
_lastPublishedByRef.TryRemove(tagRef, out _);
return true;
}
if (!_lastPublishedByRef.TryGetValue(tagRef, out var prev) || prev.Status != snapshot.StatusCode)
{
// First sample, or a quality transition (Good ↔ Uncertain): passes unconditionally and
// (re)seeds the comparison. The threshold cannot be evaluated across a status change.
_lastPublishedByRef[tagRef] = (snapshot.Value, snapshot.StatusCode);
return true;
}
var newD = Convert.ToDouble(snapshot.Value);
var oldD = Convert.ToDouble(prev);
var oldD = Convert.ToDouble(prev.Value);
if (Math.Abs(newD - oldD) < tag.Deadband.Value) return false;
_lastPublishedByRef[tagRef] = snapshot.Value;
_lastPublishedByRef[tagRef] = (snapshot.Value, snapshot.StatusCode);
return true;
}
/// <summary>OPC UA severity bits 31..30 = 10 ⇒ Bad.</summary>
private static bool StatusCodeIsBad(uint statusCode) => (statusCode & 0xC0000000u) == 0x80000000u;
/// <inheritdoc />
public string DriverInstanceId => _driverInstanceId;
/// <inheritdoc />