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