fix(mtconnect): review rulings for CONDITION alarms (#626)
- Unavailable keeps an ACTIVE condition active: quality-only BadNoCommunication; on data return same statement => quality-only Good, Normal => Clear, different => Retrigger. - A changed alarm reference set (alarm-bearing delta = full rebuild) clears the whole last-told record so every active condition is re-asserted; the same set keeps it. - Quality-only events go through the host's AlarmQualityUpdate path, never a projected snapshot (which could ack a retained unacked condition or rewrite message/severity). - Deterministic tie-break between equal-level conditions (ordinal nativeCode, then type). - Dedup key is level+nativeCode+nativeSeverity+qualifier; a text-only change updates nothing. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
+45
-4
@@ -273,7 +273,8 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
||||
|
||||
/// <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
|
||||
/// already Normal) re-annotates the condition's Quality through the quality-only node path
|
||||
/// (<see cref="OpcUaPublishActor.AlarmQualityUpdate"/> — NOT a projected snapshot), 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]
|
||||
@@ -298,10 +299,10 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
||||
Kind: AlarmTransitionKind.Unspecified,
|
||||
QualityStatusCode: OpcUaStatus.BadNoCommunication)));
|
||||
|
||||
var annotated = publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(TimeSpan.FromSeconds(5));
|
||||
var annotated = publish.ExpectMsg<OpcUaPublishActor.AlarmQualityUpdate>(TimeSpan.FromSeconds(5));
|
||||
annotated.AlarmNodeId.ShouldBe(AlarmRawPath);
|
||||
annotated.State.Active.ShouldBeFalse();
|
||||
annotated.State.StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
|
||||
annotated.Realm.ShouldBe(AddressSpaceRealm.Raw);
|
||||
annotated.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(
|
||||
@@ -327,6 +328,46 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
||||
evt.Severity.ShouldBe(800);
|
||||
}
|
||||
|
||||
/// <summary>Gitea #626 (review MINOR 1) — after a deploy (which clears the projector's prior state), a
|
||||
/// quality-only event on a raised, UNACKNOWLEDGED condition must not reach the node as a snapshot: a projected
|
||||
/// "keep the state" would default to inactive+acked, silently acknowledging it and rewriting its message and
|
||||
/// severity. Only an <see cref="OpcUaPublishActor.AlarmQualityUpdate"/> (which writes Quality alone) may be
|
||||
/// sent. Proven by order: the next message after it is the positive control's state update.</summary>
|
||||
[Fact]
|
||||
public void Quality_only_event_never_sends_a_state_snapshot_that_could_ack_or_rewrite()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
|
||||
var (actor, publish) = SpawnHostAndApply(db, deploymentId);
|
||||
|
||||
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
|
||||
new StubAlarmHandle(), "Temp", AlarmRawPath, "SYSTEM", "spindle fault", AlarmSeverity.High, Ts,
|
||||
Kind: AlarmTransitionKind.Raise, OpcUaSeverity: 800, QualityStatusCode: OpcUaStatus.Good)));
|
||||
publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(TimeSpan.FromSeconds(5)).State.Acknowledged.ShouldBeFalse();
|
||||
|
||||
// Whatever the projector's prior state is (any apply clears it), a quality-only event must never be
|
||||
// projected — the routing below is the whole guard, so no deploy needs staging here.
|
||||
|
||||
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
|
||||
new StubAlarmHandle(), "Temp", AlarmRawPath, "SYSTEM", "spindle fault", AlarmSeverity.High, Ts,
|
||||
Kind: AlarmTransitionKind.Unspecified, OpcUaSeverity: 800,
|
||||
QualityStatusCode: OpcUaStatus.BadNoCommunication)));
|
||||
// Positive control.
|
||||
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
|
||||
new StubAlarmHandle(), "Temp", AlarmRawPath, "SYSTEM", "control", AlarmSeverity.High, Ts,
|
||||
Kind: AlarmTransitionKind.Retrigger, OpcUaSeverity: 800, QualityStatusCode: OpcUaStatus.Good)));
|
||||
|
||||
var next = publish.FishForMessage(
|
||||
m => m is OpcUaPublishActor.AlarmQualityUpdate or OpcUaPublishActor.AlarmStateUpdate,
|
||||
TimeSpan.FromSeconds(5));
|
||||
next.ShouldBeOfType<OpcUaPublishActor.AlarmQualityUpdate>().StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
|
||||
var control = publish.FishForMessage(
|
||||
m => m is OpcUaPublishActor.AlarmQualityUpdate or OpcUaPublishActor.AlarmStateUpdate,
|
||||
TimeSpan.FromSeconds(5));
|
||||
control.ShouldBeOfType<OpcUaPublishActor.AlarmStateUpdate>().State.Message.ShouldBe("control",
|
||||
"the quality-only event must produce no state snapshot ahead of the control");
|
||||
}
|
||||
|
||||
/// <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
|
||||
|
||||
Reference in New Issue
Block a user