feat(alarms): exact OPC UA severity + per-condition quality on native alarm events (#626)
AlarmEventArgs gains optional OpcUaSeverity (supersedes the four-bucket mapping, clamped 1..1000) and QualityStatusCode (projected onto the condition's Quality). An Unspecified-kind event carrying a quality is a quality-only annotation: the host updates the condition but publishes no alerts row. Both default null, so every existing native source is unchanged. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -3,7 +3,8 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
/// <summary>
|
||||
/// Driver capability for alarm events. Optional — only drivers whose backends expose
|
||||
/// alarm conditions implement this. Currently: Galaxy (MxAccess alarms), FOCAS
|
||||
/// (CNC alarms), OPC UA Client (A&C events from upstream server).
|
||||
/// (CNC alarms), OPC UA Client (A&C events from upstream server), MTConnect
|
||||
/// (CONDITION data items, Gitea #626).
|
||||
/// </summary>
|
||||
public interface IAlarmSource
|
||||
{
|
||||
@@ -85,6 +86,22 @@ public sealed record AlarmAcknowledgeRequest(
|
||||
/// other fields. <see cref="AlarmTransitionKind.Unspecified"/> when the upstream
|
||||
/// path doesn't surface a distinct kind.
|
||||
/// </param>
|
||||
/// <param name="OpcUaSeverity">
|
||||
/// Optional exact OPC UA severity (1..1000) for a source whose own scale is finer than the four
|
||||
/// <see cref="AlarmSeverity"/> buckets (Gitea #626: MTConnect Warning = 500, Fault = 800). When set it
|
||||
/// supersedes the bucket on the projected Part 9 condition (clamped to 1..1000); <c>null</c> — every
|
||||
/// pre-#626 source — keeps the bucket mapping. <see cref="Severity"/> must still carry the nearest
|
||||
/// bucket for consumers that only read the bucket.
|
||||
/// </param>
|
||||
/// <param name="QualityStatusCode">
|
||||
/// Optional status code of the condition's SOURCE data (Part 9 <c>ConditionType.Quality</c>), for a
|
||||
/// source that can report "no value for this condition" per alarm rather than per connection (Gitea
|
||||
/// #626: MTConnect <c>Unavailable</c>). <c>null</c> keeps the pre-#626 behaviour (projected Good;
|
||||
/// driver-wide comms loss is annotated separately by the host). An event whose <see cref="Kind"/> is
|
||||
/// <see cref="AlarmTransitionKind.Unspecified"/> and which carries a quality is a
|
||||
/// <b>quality-only annotation</b> (<see cref="IsQualityOnly"/>): the host updates the condition's
|
||||
/// Quality without touching its Active/Acked state and publishes no <c>alerts</c> row for it.
|
||||
/// </param>
|
||||
public sealed record AlarmEventArgs(
|
||||
IAlarmSubscriptionHandle SubscriptionHandle,
|
||||
string SourceNodeId,
|
||||
@@ -96,7 +113,17 @@ public sealed record AlarmEventArgs(
|
||||
string? OperatorComment = null,
|
||||
DateTime? OriginalRaiseTimestampUtc = null,
|
||||
string? AlarmCategory = null,
|
||||
AlarmTransitionKind Kind = AlarmTransitionKind.Unspecified);
|
||||
AlarmTransitionKind Kind = AlarmTransitionKind.Unspecified,
|
||||
int? OpcUaSeverity = null,
|
||||
uint? QualityStatusCode = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// True when this event only re-annotates the condition's source-data quality — a
|
||||
/// <see cref="QualityStatusCode"/> with an <see cref="AlarmTransitionKind.Unspecified"/> kind. No
|
||||
/// alarm-state change happened, so the host must not publish it as an alarm transition.
|
||||
/// </summary>
|
||||
public bool IsQualityOnly => Kind == AlarmTransitionKind.Unspecified && QualityStatusCode is not null;
|
||||
}
|
||||
|
||||
/// <summary>Mirrors the <c>NodePermissions</c> alarm-severity enum in <c>docs/v2/acl-design.md</c>.</summary>
|
||||
public enum AlarmSeverity { Low, Medium, High, Critical }
|
||||
|
||||
@@ -1811,8 +1811,11 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
_opcUaPublishActor.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.AlarmStateUpdate(
|
||||
nodeId, snapshot, msg.Args.SourceTimestampUtc, n.Realm));
|
||||
|
||||
// Only the Primary publishes the cluster-wide alerts transition (see the gate above).
|
||||
if (!serviceAlertsAsPrimary) continue;
|
||||
// Only the Primary publishes the cluster-wide alerts transition (see the gate above). A quality-only
|
||||
// annotation (#626: e.g. an MTConnect condition gone Unavailable while already Normal) is not an
|
||||
// alarm transition at all — the condition write above carries it; an alerts row would read as
|
||||
// "Activated" for something that never activated.
|
||||
if (!serviceAlertsAsPrimary || msg.Args.IsQualityOnly) continue;
|
||||
|
||||
var meta = _alarmMetaByNodeId.TryGetValue(nodeId, out var m)
|
||||
? m : (EquipmentId: nodeId, Name: nodeId, AlarmType: "AlarmCondition", HistorizeToAveva: (bool?)null,
|
||||
|
||||
@@ -21,7 +21,8 @@ public sealed class NativeAlarmProjector
|
||||
public AlarmConditionSnapshot Project(string nodeId, AlarmEventArgs e)
|
||||
{
|
||||
var prev = _prior.TryGetValue(nodeId, out var p) ? p : (Active: false, Acked: true);
|
||||
var sev = MapSeverity(e.Severity);
|
||||
// #626: a source with a finer scale than the four buckets supplies the exact Part 9 severity.
|
||||
var sev = e.OpcUaSeverity is { } exact ? (ushort)Math.Clamp(exact, 1, 1000) : MapSeverity(e.Severity);
|
||||
var (active, acked) = e.Kind switch
|
||||
{
|
||||
AlarmTransitionKind.Raise or AlarmTransitionKind.Retrigger => (true, false),
|
||||
@@ -32,7 +33,11 @@ public sealed class NativeAlarmProjector
|
||||
_prior[nodeId] = (active, acked);
|
||||
return new AlarmConditionSnapshot(
|
||||
Active: active, Acknowledged: acked, Confirmed: true, Enabled: true,
|
||||
Shelving: AlarmShelvingKind.Unshelved, Severity: sev, Message: e.Message);
|
||||
Shelving: AlarmShelvingKind.Unshelved, Severity: sev, Message: e.Message,
|
||||
// #626: a per-condition source quality (MTConnect Unavailable) when the driver reports one. Absent
|
||||
// (every pre-#626 source) ⇒ Good, as before — driver-wide comms loss stays the host's
|
||||
// out-of-band WriteAlarmQuality annotation (#477), not something the projector infers.
|
||||
StatusCode: e.QualityStatusCode ?? OpcUaStatus.Good);
|
||||
}
|
||||
|
||||
/// <summary>Clears all tracked per-node state (call on address-space rebuild).</summary>
|
||||
|
||||
+56
@@ -271,6 +271,62 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
||||
evt.User.ShouldBe("device");
|
||||
}
|
||||
|
||||
/// <summary>Gitea #626 — a QUALITY-ONLY native event (an <see cref="AlarmTransitionKind.Unspecified"/> kind
|
||||
/// carrying <see cref="AlarmEventArgs.QualityStatusCode"/>, e.g. an MTConnect condition gone Unavailable while
|
||||
/// already Normal) re-annotates the condition's Quality through the ordinary state update, and publishes NO
|
||||
/// <c>alerts</c> row — it is not an alarm transition. The positive control is the Raise that follows: it
|
||||
/// MUST publish, and it must be the FIRST alerts message, so the absence is proven rather than timed.</summary>
|
||||
[Fact]
|
||||
public void Quality_only_native_event_updates_the_condition_but_publishes_no_alerts_row()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
|
||||
|
||||
var alerts = CreateTestProbe();
|
||||
SubscribeToAlerts(alerts);
|
||||
|
||||
var (actor, publish) = SpawnHostAndApply(db, deploymentId);
|
||||
|
||||
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
|
||||
new StubAlarmHandle(),
|
||||
SourceNodeId: "Temp",
|
||||
ConditionId: AlarmRawPath,
|
||||
AlarmType: "SYSTEM",
|
||||
Message: "SYSTEM UNAVAILABLE",
|
||||
Severity: AlarmSeverity.Low,
|
||||
SourceTimestampUtc: Ts,
|
||||
Kind: AlarmTransitionKind.Unspecified,
|
||||
QualityStatusCode: OpcUaStatus.BadNoCommunication)));
|
||||
|
||||
var annotated = publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(TimeSpan.FromSeconds(5));
|
||||
annotated.AlarmNodeId.ShouldBe(AlarmRawPath);
|
||||
annotated.State.Active.ShouldBeFalse();
|
||||
annotated.State.StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
|
||||
|
||||
// Positive control: a real transition after it DOES publish, and is the first thing on the topic.
|
||||
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
|
||||
new StubAlarmHandle(),
|
||||
SourceNodeId: "Temp",
|
||||
ConditionId: AlarmRawPath,
|
||||
AlarmType: "SYSTEM",
|
||||
Message: "spindle fault",
|
||||
Severity: AlarmSeverity.High,
|
||||
SourceTimestampUtc: Ts,
|
||||
Kind: AlarmTransitionKind.Raise,
|
||||
OpcUaSeverity: 800,
|
||||
QualityStatusCode: OpcUaStatus.Good)));
|
||||
|
||||
var raised = publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(TimeSpan.FromSeconds(5));
|
||||
raised.State.Active.ShouldBeTrue();
|
||||
raised.State.Severity.ShouldBe((ushort)800);
|
||||
raised.State.StatusCode.ShouldBe(OpcUaStatus.Good);
|
||||
|
||||
var evt = alerts.ExpectMsg<AlarmTransitionEvent>(TimeSpan.FromSeconds(5));
|
||||
evt.Message.ShouldBe("spindle fault", "the quality-only annotation must not have published an alerts row");
|
||||
evt.TransitionKind.ShouldBe("Activated");
|
||||
evt.Severity.ShouldBe(800);
|
||||
}
|
||||
|
||||
/// <summary>#477 Layer 2 — a driver <see cref="DriverInstanceActor.ConnectivityChanged"/> annotates the
|
||||
/// Quality of the driver's native conditions via <see cref="OpcUaPublishActor.AlarmQualityUpdate"/>: comms
|
||||
/// lost → Bad, restored → Good, at the raw condition NodeId + realm. No alarm transition is involved — this
|
||||
|
||||
@@ -166,6 +166,49 @@ public class NativeAlarmProjectorTests
|
||||
snap.StatusCode.ShouldBe(OpcUaStatus.Good);
|
||||
}
|
||||
|
||||
// Gitea #626 — a source with a finer severity scale than the four buckets (MTConnect Fault = 800) sets
|
||||
// OpcUaSeverity, which supersedes the bucket on the projected condition. Clamped to the Part 9 1..1000.
|
||||
[Theory]
|
||||
[InlineData(800, 800)]
|
||||
[InlineData(500, 500)]
|
||||
[InlineData(0, 1)]
|
||||
[InlineData(5000, 1000)]
|
||||
public void Explicit_opcua_severity_supersedes_the_bucket(int requested, int projected)
|
||||
{
|
||||
var sut = new NativeAlarmProjector();
|
||||
|
||||
var snap = sut.Project("n1", Evt(AlarmTransitionKind.Raise, AlarmSeverity.High) with { OpcUaSeverity = requested });
|
||||
|
||||
snap.Severity.ShouldBe((ushort)projected);
|
||||
}
|
||||
|
||||
// Gitea #626 — a per-condition source quality (MTConnect Unavailable) rides the snapshot's StatusCode, the
|
||||
// same Quality child #477's connectivity annotation writes.
|
||||
[Fact]
|
||||
public void Supplied_quality_is_projected_onto_the_snapshot()
|
||||
{
|
||||
var sut = new NativeAlarmProjector();
|
||||
|
||||
var snap = sut.Project("n1", Evt(AlarmTransitionKind.Clear) with { QualityStatusCode = OpcUaStatus.BadNoCommunication });
|
||||
|
||||
snap.StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
|
||||
}
|
||||
|
||||
// Gitea #626 — a quality-only annotation (Unspecified kind + quality) must not move Active/Acked: an
|
||||
// active, unacknowledged condition stays exactly that while only its Quality changes.
|
||||
[Fact]
|
||||
public void Quality_only_annotation_keeps_the_prior_alarm_state()
|
||||
{
|
||||
var sut = new NativeAlarmProjector();
|
||||
sut.Project("n1", Evt(AlarmTransitionKind.Raise));
|
||||
|
||||
var snap = sut.Project("n1", Evt(AlarmTransitionKind.Unspecified) with { QualityStatusCode = OpcUaStatus.BadNoCommunication });
|
||||
|
||||
snap.Active.ShouldBeTrue();
|
||||
snap.Acknowledged.ShouldBeFalse();
|
||||
snap.StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
|
||||
}
|
||||
|
||||
private static AlarmEventArgs Evt(
|
||||
AlarmTransitionKind kind,
|
||||
AlarmSeverity sev = AlarmSeverity.High,
|
||||
|
||||
Reference in New Issue
Block a user