Merge branch 'fix/r3-02-stale-good-family' — R3-02: one quality contract at four seams — comms loss marks raw+UNS value variables BadNoCommunication (WriteStatuses sink member, recovery audit), Calculation + VT hold on Bad / worst-of sub-code / (value,status) dedup, MQTT initial push + ConnectionLost stale, Sparkplug birth replay (archreview 03/S15, 05/STAB-19, 05/STAB-18, 02/S14)
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
||||
|
||||
/// <summary>
|
||||
@@ -18,6 +20,34 @@ public interface IVirtualTagEvaluator
|
||||
/// <param name="dependencies">Read-only dictionary of variable names to values for expression evaluation.</param>
|
||||
/// <returns>Result containing success flag, evaluated value, and optional failure reason.</returns>
|
||||
VirtualTagEvalResult Evaluate(string virtualTagId, string expression, IReadOnlyDictionary<string, object?> dependencies);
|
||||
|
||||
/// <summary>
|
||||
/// Evaluate <paramref name="expression"/> against dependency <b>snapshots</b> — value, status
|
||||
/// code and source timestamp per input — so a script's <c>ctx.GetTag(path).StatusCode</c> reports
|
||||
/// what the source actually said (archreview 02/S14).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Default-implemented on purpose.</b> It projects to values and calls the older overload, so
|
||||
/// every existing implementation — the Roslyn evaluator aside, that is the null evaluator and the
|
||||
/// test fakes — compiles and behaves unchanged; an implementation that can do better (the Roslyn
|
||||
/// one) overrides it. Making it abstract would have been a breaking change for a fix whose whole
|
||||
/// point is that it changes nothing on the Good path.
|
||||
/// <para>The default necessarily discards the status codes it was given, which is exactly the
|
||||
/// laundering this overload exists to end — so a consumer that cares must call an implementation
|
||||
/// that overrides it.</para>
|
||||
/// </remarks>
|
||||
/// <param name="virtualTagId">The unique identifier of the virtual tag being evaluated.</param>
|
||||
/// <param name="expression">The expression string to evaluate.</param>
|
||||
/// <param name="dependencies">Read-only dictionary of dependency snapshots, keyed by tag path.</param>
|
||||
/// <returns>Result containing success flag, evaluated value, and optional failure reason.</returns>
|
||||
VirtualTagEvalResult Evaluate(
|
||||
string virtualTagId, string expression, IReadOnlyDictionary<string, DataValueSnapshot> dependencies)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dependencies);
|
||||
var values = new Dictionary<string, object?>(dependencies.Count, StringComparer.Ordinal);
|
||||
foreach (var kv in dependencies) values[kv.Key] = kv.Value?.Value;
|
||||
return Evaluate(virtualTagId, expression, values);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Result of one virtual-tag expression eval. Stash a Reason on every Failure so
|
||||
|
||||
@@ -104,6 +104,15 @@ public static class OtOpcUaTelemetry
|
||||
Meter.CreateCounter<long>("otopcua.redundancy.service_level_change", unit: "{change}",
|
||||
description: "OPC UA Server.ServiceLevel transitions emitted by the redundancy state.");
|
||||
|
||||
/// <summary>Archreview 03/S15 — tag refs a driver left <c>BadNoCommunication</c> after it reconnected:
|
||||
/// the host marked them Bad on the outage, and the driver never re-published them. Non-zero means a
|
||||
/// driver that reconnects without a full re-publish (a push driver missing its initial push, or a
|
||||
/// value-only dedup swallowing an unchanged value), which leaves those nodes Bad indefinitely.
|
||||
/// Tagged driver + driver_type.</summary>
|
||||
public static readonly Counter<long> DriverCommsLatchedRefs =
|
||||
Meter.CreateCounter<long>("otopcua.driver.comms_latched_refs", unit: "{ref}",
|
||||
description: "Refs still BadNoCommunication after their driver reconnected (driver, driver_type).");
|
||||
|
||||
/// <summary>Inbound operations denied by the Primary data-plane gate (archreview 03/S4) —
|
||||
/// site=write|ack|alarm-emit, reason=secondary|detached|role-unknown.</summary>
|
||||
public static readonly Counter<long> PrimaryGateDenied =
|
||||
|
||||
@@ -32,6 +32,14 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical
|
||||
// (actors inject THIS wrapper, not the inner SdkAddressSpaceSink) — same trap as WireAlarmNotifiers above.
|
||||
public void WriteValues(IReadOnlyList<ValueWrite> values) => _inner.WriteValues(values);
|
||||
|
||||
/// <inheritdoc />
|
||||
// Forward the status-only batch (03/S15). Unforwarded, the interface's DEFAULT runs here instead —
|
||||
// and that default writes a NULL value, so a comms-loss annotation would erase the last known value
|
||||
// of every node on the driver rather than retaining it, on every driver-role host (actors inject
|
||||
// THIS wrapper, not the inner SdkAddressSpaceSink). Same trap as WriteValues / WireAlarmNotifiers
|
||||
// above, except this one degrades to wrong data rather than to slow.
|
||||
public void WriteStatuses(IReadOnlyList<StatusWrite> statuses) => _inner.WriteStatuses(statuses);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
|
||||
=> _inner.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc, realm);
|
||||
|
||||
@@ -42,6 +42,31 @@ public interface IOpcUaAddressSpaceSink
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Batched <b>status-only</b> write: each node keeps its CURRENT value and only its
|
||||
/// StatusCode + SourceTimestamp change. This is the comms-loss annotation for value variables
|
||||
/// (archreview 03/S15) — a driver that has stopped communicating leaves the node holding its last
|
||||
/// known value (the Part 8 convention SCADA clients expect) under a
|
||||
/// <see cref="OpcUaStatus.BadNoCommunication"/> status, rather than nulling it.
|
||||
/// <para>A sink that HOLDS the node's value must retain it — the SDK-backed sink does, because the
|
||||
/// node itself is where the value lives. This DEFAULT implementation cannot: the host holds no value
|
||||
/// cache (v3's single-source design), so it degrades to <see cref="WriteValue"/> with a null value,
|
||||
/// which is the honest statement for a value-less sink and keeps every fake compiling unchanged.</para>
|
||||
/// <para>Same per-item isolation contract as <see cref="WriteValues"/>. An unknown or
|
||||
/// unmaterialised NodeId is a <b>no-op, never a create</b>: a status carries nothing to create a node
|
||||
/// from, and the caller's index can legitimately name a node a surgical remove has just dropped.</para></summary>
|
||||
/// <param name="statuses">The status writes to apply, in order.</param>
|
||||
/// <exception cref="ValueWriteBatchException">A batching implementation throws this when some — but not
|
||||
/// necessarily all — items failed; every other item has been applied.</exception>
|
||||
void WriteStatuses(IReadOnlyList<StatusWrite> statuses)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(statuses);
|
||||
for (var i = 0; i < statuses.Count; i++)
|
||||
{
|
||||
var s = statuses[i];
|
||||
WriteValue(s.NodeId, null, s.StatusCode, s.SourceTimestampUtc, s.Realm);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Write an alarm-condition's full Part 9 state. When a real condition node has been
|
||||
/// materialised for <paramref name="alarmNodeId"/> via <see cref="MaterialiseAlarmCondition"/>,
|
||||
/// this projects the whole <see cref="AlarmConditionSnapshot"/>
|
||||
@@ -190,6 +215,16 @@ public interface IOpcUaAddressSpaceSink
|
||||
public readonly record struct ValueWrite(
|
||||
string NodeId, object? Value, uint StatusCode, DateTime SourceTimestampUtc, AddressSpaceRealm Realm);
|
||||
|
||||
/// <summary>One status-only write within a <see cref="IOpcUaAddressSpaceSink.WriteStatuses"/> batch: the
|
||||
/// node's current VALUE is retained and only <paramref name="StatusCode"/> +
|
||||
/// <paramref name="SourceTimestampUtc"/> change.</summary>
|
||||
/// <param name="NodeId">The OPC UA node id of the variable, within <paramref name="Realm"/>.</param>
|
||||
/// <param name="StatusCode">The full status code, sub-code included, passed to the SDK verbatim (#519).</param>
|
||||
/// <param name="SourceTimestampUtc">The source timestamp in UTC.</param>
|
||||
/// <param name="Realm">Which of the two v3 namespaces <paramref name="NodeId"/> lives in.</param>
|
||||
public readonly record struct StatusWrite(
|
||||
string NodeId, uint StatusCode, DateTime SourceTimestampUtc, AddressSpaceRealm Realm);
|
||||
|
||||
/// <summary>Aggregate outcome of a partially-failed <see cref="IOpcUaAddressSpaceSink.WriteValues"/> batch:
|
||||
/// every non-failing item HAS been applied; this carries what the caller needs to log and meter honestly.</summary>
|
||||
/// <remarks>A batched sink must NOT abandon the tail of a batch when one item throws: the poll engine
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// The one worst-of-input rule every derived-value seam uses — scripted alarms, virtual tags and
|
||||
/// the Calculation driver.
|
||||
///
|
||||
/// <para><b>The contract it implements</b> (archreview R3-02, "the stale-Good family"): a value
|
||||
/// consumer never reports a quality better than its source's. A node derived from driver values
|
||||
/// carries, at worst, the worst status among its inputs — a Bad input makes the derived node Bad
|
||||
/// with that input's sub-code, an Uncertain input makes it Uncertain, and a source that has
|
||||
/// stopped communicating is a Bad input for everything downstream of it.</para>
|
||||
///
|
||||
/// <para><b>Why it lives here.</b> Core.Abstractions is the one assembly all three seams already
|
||||
/// reference (Commons, Runtime and the driver assemblies all sit above it), and
|
||||
/// <see cref="DataValueSnapshot"/> — the shape the rule reads — is declared beside it. Stating
|
||||
/// the rule three times is how the three seams drifted apart in the first place.</para>
|
||||
///
|
||||
/// <para><b>Severity, not equality.</b> Comparison is on the OPC UA Part 4 severity bits (the top
|
||||
/// two: <c>00</c> Good, <c>01</c> Uncertain, <c>10</c>/<c>11</c> Bad); the sub-code below them is
|
||||
/// carried through verbatim because the sub-code is the diagnostic
|
||||
/// (<c>BadNoCommunication</c> = go look at the device, <c>BadWaitingForInitialData</c> = it never
|
||||
/// arrived, <c>BadInternalError</c> = the script).</para>
|
||||
/// </summary>
|
||||
public static class InputQuality
|
||||
{
|
||||
/// <summary>
|
||||
/// No value has ever been received for this input — the status a fresh node is materialised
|
||||
/// with, and what <see cref="WorstOf"/> reports for a dependency slot that holds no snapshot
|
||||
/// at all. Spelled here rather than taken from <c>Commons.OpcUa.OpcUaStatus</c> because
|
||||
/// Core.Abstractions sits <em>below</em> Commons and deliberately references nothing.
|
||||
/// </summary>
|
||||
public const uint BadWaitingForInitialData = 0x80320000u;
|
||||
|
||||
/// <summary>
|
||||
/// The worst status among <paramref name="inputs"/>: the highest Part 4 severity, carrying
|
||||
/// that input's own sub-code. A <see langword="null"/> entry is a dependency that has never
|
||||
/// been pushed and reports <see cref="BadWaitingForInitialData"/> — reporting Good for it is
|
||||
/// the laundering this helper exists to stop. An empty set is Good: there is nothing to be
|
||||
/// worse than.
|
||||
/// </summary>
|
||||
/// <param name="inputs">The dependency snapshots, in a stable order.</param>
|
||||
/// <returns>The worst status code observed.</returns>
|
||||
/// <remarks>
|
||||
/// Among <em>equal</em> severity the first entry seen wins, so a re-evaluation over the same
|
||||
/// inputs reports the same sub-code instead of oscillating between two equally-bad codes.
|
||||
/// </remarks>
|
||||
public static uint WorstOf(IEnumerable<DataValueSnapshot?> inputs)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(inputs);
|
||||
|
||||
var worst = 0u;
|
||||
var worstSeverity = 0u;
|
||||
foreach (var input in inputs)
|
||||
{
|
||||
var status = input?.StatusCode ?? BadWaitingForInitialData;
|
||||
var severity = status >> 30;
|
||||
if (severity <= worstSeverity) continue;
|
||||
worstSeverity = severity;
|
||||
worst = status;
|
||||
}
|
||||
|
||||
return worst;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when a consumer may evaluate against a value carrying <paramref name="statusCode"/>.
|
||||
/// Good and Uncertain are usable — an Uncertain value is present and a script can make a
|
||||
/// qualified decision from it; Bad is not, and a consumer holds instead (the alarm engine's
|
||||
/// long-standing policy, generalised).
|
||||
/// </summary>
|
||||
/// <param name="statusCode">The status code to classify.</param>
|
||||
/// <returns><see langword="true"/> for Good or Uncertain severity.</returns>
|
||||
public static bool IsUsable(uint statusCode) => (statusCode >> 30) < 2;
|
||||
}
|
||||
@@ -919,26 +919,18 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
/// the case the skip was hiding was the case that matters most. The upstream source now stamps a
|
||||
/// never-pushed path <see cref="StatusNeverPushed"/> and nothing else does, so the two are tellable
|
||||
/// apart. A null snapshot is still skipped: that is a cache the refill did not populate, not an
|
||||
/// observation.</para></summary>
|
||||
/// observation.</para>
|
||||
/// <para><b>The comparison itself lives in <see cref="InputQuality.WorstOf"/></b> (archreview R3-02)
|
||||
/// — the same rule the virtual-tag and Calculation seams now use, so "worst input wins, carrying its
|
||||
/// sub-code, first-seen on a severity tie" is stated once. What stays here is the alarm-specific
|
||||
/// <em>filter</em>: this engine's two skips are deliberately NOT part of the shared rule, because the
|
||||
/// other two seams treat a never-pushed input as Bad rather than as absent.</para></summary>
|
||||
private static uint WorstInputStatus(IReadOnlyDictionary<string, DataValueSnapshot> cache)
|
||||
{
|
||||
uint worst = 0u;
|
||||
var worstSeverity = 0u;
|
||||
foreach (var kv in cache)
|
||||
{
|
||||
// No entry at all — nothing was observed, so there is nothing to report on.
|
||||
if (kv.Value is null) continue;
|
||||
// Observed as "no data has ever arrived" — the cold-start placeholder, not a fault.
|
||||
if (kv.Value.StatusCode == StatusNeverPushed) continue;
|
||||
var status = kv.Value.StatusCode;
|
||||
var severity = status >> 30;
|
||||
if (severity > worstSeverity)
|
||||
{
|
||||
worstSeverity = severity;
|
||||
worst = status;
|
||||
}
|
||||
}
|
||||
return worst;
|
||||
// No entry at all — nothing was observed, so there is nothing to report on.
|
||||
// Observed as "no data has ever arrived" — the cold-start placeholder, not a fault.
|
||||
var observed = cache.Values.Where(v => v is not null && v.StatusCode != StatusNeverPushed);
|
||||
return InputQuality.WorstOf(observed);
|
||||
}
|
||||
|
||||
/// <summary>Update the tracked worst-quality bucket for an alarm; return true iff the 3-state bucket
|
||||
|
||||
Reference in New Issue
Block a user