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:
Joseph Doherty
2026-10-08 15:38:52 -04:00
parent 6a5f5c5a28
commit 9c8e3da5da
7 changed files with 382 additions and 112 deletions
@@ -113,6 +113,21 @@ public sealed class MTConnectConditionAlarmMappingTests
MTConnectConditionTracker.LevelOf("fault").ShouldBe(MTConnectConditionLevel.Fault);
}
[Fact]
public void Equal_level_conditions_tie_break_deterministically_regardless_of_document_order()
{
// Review MINOR 2: an Agent that lists the same two Warnings in a different order on a re-baseline must
// not change the winner, or every reconnect would retrigger (and un-ack) the condition.
var forward = new MTConnectConditionTracker();
forward.Apply(Doc(Cond("Warning", "B", at: 0), Cond("Warning", "A", at: 1)), baseline: true);
var reverse = new MTConnectConditionTracker();
reverse.Apply(Doc(Cond("Warning", "A", at: 0), Cond("Warning", "B", at: 1)), baseline: true);
forward.Get(Id).ShouldNotBeNull().Detail.NativeCode.ShouldBe("A");
reverse.Get(Id).ShouldNotBeNull().Detail.NativeCode.ShouldBe("A");
}
// ---- mapper: state mapping ----
[Fact]
@@ -154,15 +169,14 @@ public sealed class MTConnectConditionAlarmMappingTests
[Fact]
public void Normal_then_fault_then_normal_is_raise_then_clear()
{
var normal = State(MTConnectConditionLevel.Normal);
var fault = State(MTConnectConditionLevel.Fault, "overtemp", "E17");
var events = Sequence(
State(MTConnectConditionLevel.Normal),
State(MTConnectConditionLevel.Fault, "overtemp", "E17"),
State(MTConnectConditionLevel.Normal));
Map(null, normal).ShouldBeNull("a first-seen Normal has nothing to say — the node starts inactive");
var raise = Map(normal, fault).ShouldNotBeNull();
raise.Kind.ShouldBe(AlarmTransitionKind.Raise);
var clear = Map(fault, normal).ShouldNotBeNull();
events[0].ShouldBeNull("a first-seen Normal has nothing to say — the node starts inactive");
events[1].ShouldNotBeNull().Kind.ShouldBe(AlarmTransitionKind.Raise);
var clear = events[2].ShouldNotBeNull();
clear.Kind.ShouldBe(AlarmTransitionKind.Clear);
clear.QualityStatusCode.ShouldBe(Good);
clear.OpcUaSeverity.ShouldBe(800, "a clear keeps the severity of the alarm it clears");
@@ -171,11 +185,11 @@ public sealed class MTConnectConditionAlarmMappingTests
[Fact]
public void Warning_escalating_to_fault_is_a_retrigger_at_the_new_severity()
{
var warning = State(MTConnectConditionLevel.Warning, "coolant low", "W1");
var fault = State(MTConnectConditionLevel.Fault, "coolant empty", "F1");
var evt = Map(warning, fault).ShouldNotBeNull();
var events = Sequence(
State(MTConnectConditionLevel.Warning, "coolant low", "W1"),
State(MTConnectConditionLevel.Fault, "coolant empty", "F1"));
var evt = events[1].ShouldNotBeNull();
evt.Kind.ShouldBe(AlarmTransitionKind.Retrigger);
evt.OpcUaSeverity.ShouldBe(800);
evt.Message.ShouldStartWith("coolant empty");
@@ -184,10 +198,9 @@ public sealed class MTConnectConditionAlarmMappingTests
[Fact]
public void A_different_active_native_code_at_the_same_level_is_a_retrigger()
{
var evt = Map(State(MTConnectConditionLevel.Fault, "a", "A"), State(MTConnectConditionLevel.Fault, "b", "B"))
.ShouldNotBeNull();
evt.Kind.ShouldBe(AlarmTransitionKind.Retrigger);
// Includes the documented "clearing the worst code re-raises the next one down, un-acked" case.
Sequence(State(MTConnectConditionLevel.Fault, "a", "A"), State(MTConnectConditionLevel.Fault, "b", "B"))[1]
.ShouldNotBeNull().Kind.ShouldBe(AlarmTransitionKind.Retrigger);
}
[Fact]
@@ -202,64 +215,140 @@ public sealed class MTConnectConditionAlarmMappingTests
Map(before, replayed, resync: true).ShouldBeNull();
}
[Fact]
public void A_text_only_change_updates_nothing_and_the_first_text_is_kept()
{
// Review MINOR 4 / ruling 5: an adapter embedding a live value in the text must not re-raise (un-ack).
var first = State(MTConnectConditionLevel.Fault, "Spindle temp 82C", "E17");
var (evt, told) = MTConnectConditionAlarmMapper.Transition(
new MTConnectToldCondition(first, false), State(MTConnectConditionLevel.Fault, "Spindle temp 83C", "E17"),
RawPath, Id, Handle, resync: false);
evt.ShouldBeNull();
told.Statement.ShouldNotBeNull().Detail.Text.ShouldBe("Spindle temp 82C");
}
[Fact]
public void A_repeated_normal_with_different_text_does_not_re_clear()
{
Map(State(MTConnectConditionLevel.Normal, "ok"), State(MTConnectConditionLevel.Normal, "still ok")).ShouldBeNull();
}
// ---- mapper: Unavailable (comms loss one hop out) ----
[Fact]
public void An_active_condition_going_unavailable_clears_with_bad_quality()
public void An_active_condition_going_unavailable_stays_active_with_a_bad_quality_annotation()
{
var evt = Map(State(MTConnectConditionLevel.Fault, "x", "E1"), State(MTConnectConditionLevel.Unavailable))
.ShouldNotBeNull();
evt.Kind.ShouldBe(AlarmTransitionKind.Clear);
evt.IsQualityOnly.ShouldBeTrue("not a Clear: the state is unknown, not normal (#477/#481 posture)");
evt.QualityStatusCode.ShouldBe(BadNoCommunication);
evt.Message.ShouldBe("SYSTEM UNAVAILABLE");
evt.Message.ShouldBe("x [nativeCode=E1]", "the annotation describes the statement the server still holds");
}
[Fact]
public void An_ack_survives_an_unavailable_blip_when_the_same_fault_returns()
{
// Fault → UNAVAILABLE → the same Fault: two quality-only annotations, no Clear, no Raise — so the
// operator's acknowledgement is never reset.
var events = Sequence(
State(MTConnectConditionLevel.Fault, "x", "E1"),
State(MTConnectConditionLevel.Unavailable),
State(MTConnectConditionLevel.Fault, "x", "E1"));
events[0].ShouldNotBeNull().Kind.ShouldBe(AlarmTransitionKind.Raise);
events[1].ShouldNotBeNull().IsQualityOnly.ShouldBeTrue();
events[1]!.QualityStatusCode.ShouldBe(BadNoCommunication);
events[2].ShouldNotBeNull().IsQualityOnly.ShouldBeTrue();
events[2]!.QualityStatusCode.ShouldBe(Good);
}
[Fact]
public void An_active_condition_returning_normal_from_unavailable_is_a_clear()
{
var events = Sequence(
State(MTConnectConditionLevel.Fault, "x", "E1"),
State(MTConnectConditionLevel.Unavailable),
State(MTConnectConditionLevel.Normal));
var clear = events[2].ShouldNotBeNull();
clear.Kind.ShouldBe(AlarmTransitionKind.Clear);
clear.QualityStatusCode.ShouldBe(Good);
}
[Fact]
public void An_active_condition_returning_with_a_different_statement_is_a_retrigger()
{
var events = Sequence(
State(MTConnectConditionLevel.Warning, "w", "W1"),
State(MTConnectConditionLevel.Unavailable),
State(MTConnectConditionLevel.Fault, "f", "F1"));
var evt = events[2].ShouldNotBeNull();
evt.Kind.ShouldBe(AlarmTransitionKind.Retrigger);
evt.QualityStatusCode.ShouldBe(Good);
evt.OpcUaSeverity.ShouldBe(800);
}
[Fact]
public void An_inactive_condition_going_unavailable_and_back_is_quality_only()
{
var normal = State(MTConnectConditionLevel.Normal);
var unavailable = State(MTConnectConditionLevel.Unavailable);
var events = Sequence(
State(MTConnectConditionLevel.Normal),
State(MTConnectConditionLevel.Unavailable),
State(MTConnectConditionLevel.Normal));
var lost = Map(normal, unavailable).ShouldNotBeNull();
lost.IsQualityOnly.ShouldBeTrue();
lost.QualityStatusCode.ShouldBe(BadNoCommunication);
events[1].ShouldNotBeNull().IsQualityOnly.ShouldBeTrue();
events[1]!.QualityStatusCode.ShouldBe(BadNoCommunication);
events[2].ShouldNotBeNull().IsQualityOnly.ShouldBeTrue();
events[2]!.QualityStatusCode.ShouldBe(Good);
var back = Map(unavailable, normal).ShouldNotBeNull();
back.IsQualityOnly.ShouldBeTrue();
back.QualityStatusCode.ShouldBe(Good);
Map(null, unavailable).ShouldNotBeNull().IsQualityOnly.ShouldBeTrue("first-seen Unavailable must reach the node");
Map(null, State(MTConnectConditionLevel.Unavailable)).ShouldNotBeNull().IsQualityOnly
.ShouldBeTrue("first-seen Unavailable must reach the node");
}
[Fact]
public void An_unchanged_unavailable_is_reasserted_only_on_a_resync()
{
// The host re-annotates every condition Good on reconnect (#477); only the resync puts the Bad back.
var told = new MTConnectToldCondition(State(MTConnectConditionLevel.Fault, "x", "E1"), QualityBad: true);
var unavailable = State(MTConnectConditionLevel.Unavailable);
Map(unavailable, unavailable).ShouldBeNull();
Map(unavailable, unavailable, resync: true).ShouldNotBeNull().IsQualityOnly.ShouldBeTrue();
MTConnectConditionAlarmMapper.Transition(told, unavailable, RawPath, Id, Handle, resync: false).Event.ShouldBeNull();
MTConnectConditionAlarmMapper.Transition(told, unavailable, RawPath, Id, Handle, resync: true).Event
.ShouldNotBeNull().IsQualityOnly.ShouldBeTrue();
}
[Fact]
public void Unavailable_recovering_straight_to_fault_raises_with_good_quality()
public void A_first_seen_unavailable_recovering_straight_to_fault_raises_with_good_quality()
{
var evt = Map(State(MTConnectConditionLevel.Unavailable), State(MTConnectConditionLevel.Fault, "x", "E1"))
.ShouldNotBeNull();
var events = Sequence(State(MTConnectConditionLevel.Unavailable), State(MTConnectConditionLevel.Fault, "x", "E1"));
var evt = events[1].ShouldNotBeNull();
evt.Kind.ShouldBe(AlarmTransitionKind.Raise);
evt.QualityStatusCode.ShouldBe(Good);
}
// ---- helpers ----
private static AlarmEventArgs? Map(MTConnectConditionState? delivered, MTConnectConditionState current, bool resync = false) =>
MTConnectConditionAlarmMapper.Transition(delivered, current, RawPath, Id, Handle, resync);
/// <summary>Maps one step from a told state of <paramref name="statement"/> (Good quality).</summary>
private static AlarmEventArgs? Map(MTConnectConditionState? statement, MTConnectConditionState current, bool resync = false) =>
MTConnectConditionAlarmMapper.Transition(
statement is null ? null : new MTConnectToldCondition(statement, false), current, RawPath, Id, Handle, resync).Event;
/// <summary>Threads the told record through a sequence of Agent statements, returning each step's event.</summary>
private static AlarmEventArgs?[] Sequence(params MTConnectConditionState[] states)
{
MTConnectToldCondition? told = null;
var events = new AlarmEventArgs?[states.Length];
for (var i = 0; i < states.Length; i++)
{
(events[i], told) = MTConnectConditionAlarmMapper.Transition(told, states[i], RawPath, Id, Handle, resync: false);
}
return events;
}
private static MTConnectConditionState State(
MTConnectConditionLevel level,