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:
Joseph Doherty
2026-10-08 14:55:46 -04:00
parent e6028678b3
commit cb85a455c9
5 changed files with 140 additions and 6 deletions
@@ -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,