feat(alarms): condition Quality tracks source connectivity (#477)
Part 9 ConditionType.Quality was never assigned; default(StatusCode)==Good so every native + scripted condition reported Good unconditionally — a comms-lost device still showed a healthy, inactive, Good condition (a wrong-VALUE bug, distinct from the null-value #473/#475). Clients (and HMIs bucketing on IsGood) could not tell "genuinely inactive" from "lost contact". Layer 1 — make Quality a real, plumbed field: - AlarmConditionSnapshot gains OpcUaQuality Quality (default Good). - MaterialiseAlarmCondition sets it (native BadWaitingForInitialData, scripted Good). - WriteAlarmCondition projects snapshot.Quality; the delta-gate gains a Quality member so a quality-bucket change fires a Part 9 event. Layer 2 — drive native quality from driver connectivity (a comms-lost driver emits no alarm transitions, and an alarm-bearing raw tag has no value variable, so quality can't come from either existing channel): - DriverInstanceActor Tells parent ConnectivityChanged on Connected/Reconnecting. - DriverHostActor fans it to every native condition the driver owns as OpcUaPublishActor.AlarmQualityUpdate (Good on connect, Bad on disconnect). - New dedicated IOpcUaAddressSpaceSink.WriteAlarmQuality sets ONLY Quality and fires only on a bucket change — never touches Active/Acked/Retain (an active alarm that loses comms stays active). Not a full-snapshot re-projection, so it can't clobber severity/message and works for a never-fired condition. Forwarded through DeferredAddressSpaceSink (F10b trap; auto-verified by the reflection forwarding guard). Ungated by redundancy role; no /alerts row. Scripted conditions stay Good; worst-of-input quality deferred to #478 (Layer 3). Tests: node-level (materialise/project/no-clobber/unknown-node no-op), NativeAlarmProjector, DriverInstanceActor connectivity emission, DriverHostActor fan-out, OpcUaPublishActor routing, and the wire-level guard (Condition_event_Quality_tracks_source_connectivity_on_the_wire) — RED-verified against a simulated pre-fix always-Good server. Existing DriverInstanceActor parent probes ignore the new ConnectivityChanged. Docs: docs/AlarmTracking.md §"Condition source-data Quality (#477)"; design doc docs/plans/2026-07-17-alarm-condition-quality-477-design.md.
This commit is contained in:
+2
@@ -163,6 +163,8 @@ public sealed class AddressSpaceApplierFailureSurfaceTests
|
||||
|
||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
|
||||
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||
{
|
||||
if (ThrowOnAlarmWrite) throw new InvalidOperationException("simulated WriteAlarmCondition fault");
|
||||
|
||||
@@ -312,6 +312,7 @@ public sealed class AddressSpaceApplierHierarchyTests : IDisposable
|
||||
/// <param name="alarmNodeId">The node ID of the alarm condition.</param>
|
||||
/// <param name="state">The full condition state snapshot.</param>
|
||||
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
||||
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
/// <summary>Materialises an alarm condition (stub implementation for testing).</summary>
|
||||
/// <param name="alarmNodeId">The alarm node ID (== ScriptedAlarmId).</param>
|
||||
|
||||
@@ -301,6 +301,7 @@ public sealed class AddressSpaceApplierRawUnsTests
|
||||
=> References.Add((sourceNodeId, sourceRealm, targetNodeId, targetRealm, referenceType));
|
||||
|
||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||
public void RebuildAddressSpace() { }
|
||||
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) { }
|
||||
|
||||
@@ -2268,6 +2268,7 @@ public sealed class AddressSpaceApplierTests
|
||||
/// <param name="alarmNodeId">The alarm node ID.</param>
|
||||
/// <param name="state">The full condition state snapshot.</param>
|
||||
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
||||
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||
=> AlarmQueue.Enqueue((alarmNodeId, state));
|
||||
/// <summary>Records an alarm-condition materialise call.</summary>
|
||||
@@ -2322,6 +2323,7 @@ public sealed class AddressSpaceApplierTests
|
||||
/// <summary>Records a value write (no-op in this sink).</summary>
|
||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
/// <summary>No-op alarm condition write call.</summary>
|
||||
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
/// <summary>No-op alarm-condition materialise call.</summary>
|
||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
|
||||
@@ -2355,6 +2357,7 @@ public sealed class AddressSpaceApplierTests
|
||||
/// <param name="state">The full condition state snapshot.</param>
|
||||
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
||||
/// <exception cref="InvalidOperationException">Thrown when configured to throw on alarm write.</exception>
|
||||
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||
{
|
||||
if (_throwOnAlarmWrite) throw new InvalidOperationException("simulated sink fault");
|
||||
|
||||
@@ -231,6 +231,7 @@ public sealed class DeferredAddressSpaceSinkTests
|
||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||
=> CallQueue.Enqueue($"WV:{nodeId}");
|
||||
/// <inheritdoc />
|
||||
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||
=> CallQueue.Enqueue($"WA:{alarmNodeId}");
|
||||
/// <inheritdoc />
|
||||
@@ -289,6 +290,7 @@ public sealed class DeferredAddressSpaceSinkTests
|
||||
/// <inheritdoc />
|
||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
/// <inheritdoc />
|
||||
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
/// <inheritdoc />
|
||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
|
||||
|
||||
+114
@@ -196,6 +196,120 @@ public sealed class NodeManagerAlarmSourceFieldsTests : IDisposable
|
||||
condition.ConditionClassName!.Value.Text.ShouldBe("BaseConditionClass");
|
||||
}
|
||||
|
||||
/// <summary>#477 — a freshly materialised NATIVE condition reports BadWaitingForInitialData quality (not the
|
||||
/// accidentally-Good default), matching the value-variable "no driver data yet" convention. Its quality only
|
||||
/// becomes Good once its driver's connectivity confirms it (Layer 2).</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task Native_condition_materialises_with_waiting_for_initial_data_quality()
|
||||
{
|
||||
await using var host = await BootAsync();
|
||||
var nm = host.Nm;
|
||||
|
||||
nm.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
|
||||
nm.MaterialiseAlarmCondition(RawAlarmPath, RawDeviceFolder, "HR200", "OffNormalAlarm", 700,
|
||||
realm: AddressSpaceRealm.Raw, isNative: true);
|
||||
|
||||
var condition = nm.TryGetAlarmCondition(RawAlarmPath, AddressSpaceRealm.Raw);
|
||||
condition.ShouldNotBeNull();
|
||||
condition.Quality.ShouldNotBeNull();
|
||||
condition.Quality.Value.ShouldBe((StatusCode)StatusCodes.BadWaitingForInitialData);
|
||||
StatusCode.IsGood(condition.Quality.Value).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>#477 — a SCRIPTED condition is script-computed and always "live" in v1, so it materialises Good.
|
||||
/// (Scripted worst-of-input quality is deferred to Layer 3.)</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task Scripted_condition_materialises_with_good_quality()
|
||||
{
|
||||
await using var host = await BootAsync();
|
||||
var nm = host.Nm;
|
||||
|
||||
nm.EnsureFolder("eq-q1", parentNodeId: null, displayName: "Station Q1", realm: AddressSpaceRealm.Uns);
|
||||
nm.MaterialiseAlarmCondition("scripted-q", "eq-q1", "Scripted Q", "OffNormalAlarm", 700,
|
||||
realm: AddressSpaceRealm.Uns, isNative: false);
|
||||
|
||||
var condition = nm.TryGetAlarmCondition("scripted-q", AddressSpaceRealm.Uns);
|
||||
condition.ShouldNotBeNull();
|
||||
condition.Quality.ShouldNotBeNull();
|
||||
StatusCode.IsGood(condition.Quality.Value).ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>#477 — WriteAlarmCondition projects the snapshot's Quality onto the live condition node, so a
|
||||
/// Bad-quality snapshot (comms-lost source) makes the condition report non-Good.</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task WriteAlarmCondition_projects_snapshot_quality_onto_the_condition()
|
||||
{
|
||||
await using var host = await BootAsync();
|
||||
var nm = host.Nm;
|
||||
|
||||
nm.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
|
||||
nm.MaterialiseAlarmCondition(RawAlarmPath, RawDeviceFolder, "HR200", "OffNormalAlarm", 700,
|
||||
realm: AddressSpaceRealm.Raw, isNative: true);
|
||||
|
||||
// A comms-lost snapshot: the alarm state is unchanged but Quality is Bad.
|
||||
var badSnapshot = new AlarmConditionSnapshot(
|
||||
Active: false, Acknowledged: true, Confirmed: true, Enabled: true,
|
||||
Shelving: AlarmShelvingKind.Unshelved, Severity: 700, Message: "HR200",
|
||||
Quality: OpcUaQuality.Bad);
|
||||
nm.WriteAlarmCondition(RawAlarmPath, badSnapshot, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||
|
||||
var condition = nm.TryGetAlarmCondition(RawAlarmPath, AddressSpaceRealm.Raw);
|
||||
condition.ShouldNotBeNull();
|
||||
StatusCode.IsBad(condition.Quality.Value).ShouldBeTrue();
|
||||
|
||||
// Recover: a Good snapshot restores Good quality.
|
||||
var goodSnapshot = badSnapshot with { Quality = OpcUaQuality.Good };
|
||||
nm.WriteAlarmCondition(RawAlarmPath, goodSnapshot, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||
StatusCode.IsGood(condition.Quality.Value).ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>#477 Layer 2 — the dedicated connectivity-quality path sets ONLY the condition's Quality
|
||||
/// (comms lost → Bad, restored → Good) and never clobbers the node's severity / message / alarm state:
|
||||
/// it is a pure annotation applied out-of-band from any alarm transition.</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task WriteAlarmQuality_sets_only_quality_without_touching_state_or_severity()
|
||||
{
|
||||
await using var host = await BootAsync();
|
||||
var nm = host.Nm;
|
||||
|
||||
nm.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
|
||||
nm.MaterialiseAlarmCondition(RawAlarmPath, RawDeviceFolder, "HR200", "OffNormalAlarm", 700,
|
||||
realm: AddressSpaceRealm.Raw, isNative: true);
|
||||
|
||||
var condition = nm.TryGetAlarmCondition(RawAlarmPath, AddressSpaceRealm.Raw);
|
||||
condition.ShouldNotBeNull();
|
||||
var severityBefore = condition.Severity!.Value;
|
||||
var messageBefore = condition.Message!.Value?.Text;
|
||||
|
||||
// Device comes online → Good.
|
||||
nm.WriteAlarmQuality(RawAlarmPath, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||
StatusCode.IsGood(condition.Quality!.Value).ShouldBeTrue();
|
||||
|
||||
// Device goes comms-lost → Bad, but severity / message / active-ack state are untouched.
|
||||
nm.WriteAlarmQuality(RawAlarmPath, OpcUaQuality.Bad, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||
StatusCode.IsBad(condition.Quality.Value).ShouldBeTrue();
|
||||
condition.Severity.Value.ShouldBe(severityBefore);
|
||||
condition.Message.Value?.Text.ShouldBe(messageBefore);
|
||||
condition.ActiveState!.Id!.Value.ShouldBeFalse(); // unchanged: annotation only
|
||||
}
|
||||
|
||||
/// <summary>#477 Layer 2 — WriteAlarmQuality on an unknown / unmaterialised node is a safe no-op (never
|
||||
/// throws), mirroring the other write paths: a mid-rebuild race must not fault a connectivity update.</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task WriteAlarmQuality_is_a_noop_for_an_unknown_condition()
|
||||
{
|
||||
await using var host = await BootAsync();
|
||||
var nm = host.Nm;
|
||||
|
||||
Should.NotThrow(() =>
|
||||
nm.WriteAlarmQuality("no/such/node", OpcUaQuality.Bad, DateTime.UtcNow, AddressSpaceRealm.Raw));
|
||||
}
|
||||
|
||||
/// <summary>A booted server + its node manager, disposed via <c>await using</c> so an assertion failure
|
||||
/// cannot leak a live server (and its bound port) into the rest of the test run.</summary>
|
||||
private sealed class BootedServer(OpcUaApplicationHost host, OtOpcUaNodeManager nm) : IAsyncDisposable
|
||||
|
||||
@@ -523,7 +523,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
{
|
||||
var baseState = new OtOpcUaNodeManager.AlarmConditionDelta(
|
||||
Active: true, Acknowledged: false, Confirmed: true, Enabled: true,
|
||||
Shelving: AlarmShelvingKind.Unshelved, MappedSeverity: 100, Message: "m");
|
||||
Shelving: AlarmShelvingKind.Unshelved, MappedSeverity: 100, Message: "m",
|
||||
Quality: StatusCodes.Good);
|
||||
|
||||
// Equal ⇒ suppress (this is the inbound double-emit case in pure form).
|
||||
OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState).ShouldBeFalse();
|
||||
@@ -537,6 +538,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState with { Shelving = AlarmShelvingKind.Timed }).ShouldBeTrue();
|
||||
OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState with { MappedSeverity = 900 }).ShouldBeTrue();
|
||||
OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState with { Message = "other" }).ShouldBeTrue();
|
||||
// #477 — a quality-only change (e.g. source going comms-lost, no state change) is a genuine delta.
|
||||
OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState with { Quality = StatusCodes.Bad }).ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>T20 — null-vs-empty Message normalization. Both snapshot.Message = null and a live node
|
||||
@@ -552,7 +555,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
// Both null and "" collapse to string.Empty in AlarmConditionDelta — they are the same delta value.
|
||||
var withNull = new OtOpcUaNodeManager.AlarmConditionDelta(
|
||||
Active: false, Acknowledged: true, Confirmed: true, Enabled: true,
|
||||
Shelving: AlarmShelvingKind.Unshelved, MappedSeverity: 100, Message: string.Empty);
|
||||
Shelving: AlarmShelvingKind.Unshelved, MappedSeverity: 100, Message: string.Empty,
|
||||
Quality: StatusCodes.Good);
|
||||
|
||||
var withEmpty = withNull with { Message = string.Empty };
|
||||
|
||||
|
||||
Reference in New Issue
Block a user