Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectConditionAlarmMappingTests.cs
T
Joseph Doherty 653d138637 feat(mtconnect): CONDITION data items as native Part 9 alarms via IAlarmSource (#626)
The parser keeps a condition's text, nativeCode, nativeSeverity, qualifier and type. A new
MTConnectConditionTracker applies MTConnect's multi-condition semantics (per-nativeCode
activation/clearing across /sample deltas, /current baseline reset, Unavailable), and
MTConnectConditionAlarmMapper turns tracker state into Part 9 transitions: Warning=500,
Fault=800, Normal=clear, Unavailable=inactive + BadNoCommunication; escalation or a new
code retriggers. The driver implements IAlarmSource keyed by the alarm tag's RawPath,
diffs against what the server was actually told (only when a handler is attached), so a
reconnect/re-baseline never re-raises an unchanged condition and a missed transition is
delivered on the next subscribe. Ack is a no-op success. Existing CONDITION String tags
are unchanged; a tag becomes an alarm only via the standard TagConfig 'alarm' object.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 15:01:39 -04:00

280 lines
11 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
/// <summary>
/// Gitea #626 — the pure halves of the CONDITION → Part 9 path: <see cref="MTConnectConditionTracker"/>
/// (MTConnect's multi-condition semantics) and <see cref="MTConnectConditionAlarmMapper"/> (state mapping and
/// transition sequencing). The driver wiring is covered by <c>MTConnectAlarmSourceTests</c>.
/// </summary>
public sealed class MTConnectConditionAlarmMappingTests
{
private const string Id = "dev1_system_cond";
private const string RawPath = "Plant/MT/dev1/SystemCond";
private const uint Good = 0x00000000u;
private const uint BadNoCommunication = 0x80310000u;
private static readonly DateTime T0 = new(2026, 10, 8, 12, 0, 0, DateTimeKind.Utc);
private static readonly IAlarmSubscriptionHandle Handle = new MTConnectAlarmSubscriptionHandle(1);
// ---- tracker ----
[Fact]
public void A_never_observed_item_has_no_state()
{
new MTConnectConditionTracker().Get(Id).ShouldBeNull();
}
[Fact]
public void Samples_and_events_never_reach_the_tracker()
{
var tracker = new MTConnectConditionTracker();
var touched = tracker.Apply(Doc(new MTConnectObservation(Id, "Fault", T0)), baseline: false);
touched.ShouldBeEmpty("an observation without Condition detail did not come from a <Condition>");
tracker.Get(Id).ShouldBeNull();
}
[Fact]
public void The_most_severe_of_several_simultaneous_conditions_wins()
{
var tracker = new MTConnectConditionTracker();
tracker.Apply(Doc(Cond("Warning", "W1", at: 0), Cond("Fault", "F1", at: 1), Cond("Warning", "W2", at: 2)), baseline: false);
var state = tracker.Get(Id).ShouldNotBeNull();
state.Level.ShouldBe(MTConnectConditionLevel.Fault);
state.Detail.NativeCode.ShouldBe("F1");
state.ActiveCount.ShouldBe(3);
}
[Fact]
public void A_normal_for_one_native_code_in_a_later_sample_clears_only_that_code()
{
// The index's per-document reconcile would read this chunk as "Normal"; the alarm path must not.
var tracker = new MTConnectConditionTracker();
tracker.Apply(Doc(Cond("Fault", "A", at: 0)), baseline: false);
tracker.Apply(Doc(Cond("Warning", "B", at: 1)), baseline: false);
tracker.Apply(Doc(Cond("Normal", "A", at: 2)), baseline: false);
var state = tracker.Get(Id).ShouldNotBeNull();
state.Level.ShouldBe(MTConnectConditionLevel.Warning);
state.Detail.NativeCode.ShouldBe("B");
}
[Fact]
public void A_normal_without_a_native_code_clears_every_code()
{
var tracker = new MTConnectConditionTracker();
tracker.Apply(Doc(Cond("Fault", "A", at: 0), Cond("Warning", "B", at: 1)), baseline: false);
tracker.Apply(Doc(Cond("Normal", null, at: 2)), baseline: false);
tracker.Get(Id).ShouldNotBeNull().Level.ShouldBe(MTConnectConditionLevel.Normal);
}
[Fact]
public void Unavailable_clears_every_code_until_any_other_level_arrives()
{
var tracker = new MTConnectConditionTracker();
tracker.Apply(Doc(Cond("Fault", "A", at: 0)), baseline: false);
tracker.Apply(Doc(Cond("UNAVAILABLE", null, at: 1)), baseline: false);
tracker.Get(Id).ShouldNotBeNull().Level.ShouldBe(MTConnectConditionLevel.Unavailable);
tracker.Apply(Doc(Cond("Normal", null, at: 2)), baseline: false);
tracker.Get(Id).ShouldNotBeNull().Level.ShouldBe(MTConnectConditionLevel.Normal);
}
[Fact]
public void A_baseline_drops_a_code_the_current_document_no_longer_lists()
{
// A code cleared while the driver was not listening must not survive a /current re-baseline.
var tracker = new MTConnectConditionTracker();
tracker.Apply(Doc(Cond("Fault", "A", at: 0), Cond("Warning", "B", at: 1)), baseline: false);
tracker.Apply(Doc(Cond("Warning", "B", at: 2)), baseline: true);
var state = tracker.Get(Id).ShouldNotBeNull();
state.Level.ShouldBe(MTConnectConditionLevel.Warning);
state.ActiveCount.ShouldBe(1);
}
[Fact]
public void An_unrecognised_vendor_state_is_surfaced_as_a_warning()
{
MTConnectConditionTracker.LevelOf("Alarm").ShouldBe(MTConnectConditionLevel.Warning);
MTConnectConditionTracker.LevelOf("UNAVAILABLE").ShouldBe(MTConnectConditionLevel.Unavailable);
MTConnectConditionTracker.LevelOf("fault").ShouldBe(MTConnectConditionLevel.Fault);
}
// ---- mapper: state mapping ----
[Fact]
public void Fault_raises_active_at_severity_800_with_the_text_as_message()
{
var evt = Map(null, State(MTConnectConditionLevel.Fault, "Spindle overtemperature", "E17", "2", "HIGH"))
.ShouldNotBeNull();
evt.Kind.ShouldBe(AlarmTransitionKind.Raise);
evt.ConditionId.ShouldBe(RawPath, "the host routes on ConditionId == RawPath");
evt.SourceNodeId.ShouldBe(Id);
evt.OpcUaSeverity.ShouldBe(800);
evt.Severity.ShouldBe(AlarmSeverity.High);
evt.QualityStatusCode.ShouldBe(Good);
evt.Message.ShouldBe("Spindle overtemperature [nativeCode=E17, nativeSeverity=2, qualifier=HIGH]");
evt.AlarmType.ShouldBe("SYSTEM");
evt.SourceTimestampUtc.ShouldBe(T0);
}
[Fact]
public void Warning_raises_active_at_severity_500()
{
var evt = Map(null, State(MTConnectConditionLevel.Warning)).ShouldNotBeNull();
evt.Kind.ShouldBe(AlarmTransitionKind.Raise);
evt.OpcUaSeverity.ShouldBe(500);
evt.Severity.ShouldBe(AlarmSeverity.Medium);
}
[Fact]
public void A_condition_without_text_falls_back_to_its_type_level_and_native_code()
{
MTConnectConditionAlarmMapper.MessageOf(State(MTConnectConditionLevel.Fault, text: null, nativeCode: "1001"))
.ShouldBe("SYSTEM Fault [nativeCode=1001]");
}
// ---- mapper: transition sequencing ----
[Fact]
public void Normal_then_fault_then_normal_is_raise_then_clear()
{
var normal = State(MTConnectConditionLevel.Normal);
var fault = State(MTConnectConditionLevel.Fault, "overtemp", "E17");
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();
clear.Kind.ShouldBe(AlarmTransitionKind.Clear);
clear.QualityStatusCode.ShouldBe(Good);
clear.OpcUaSeverity.ShouldBe(800, "a clear keeps the severity of the alarm it clears");
}
[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();
evt.Kind.ShouldBe(AlarmTransitionKind.Retrigger);
evt.OpcUaSeverity.ShouldBe(800);
evt.Message.ShouldStartWith("coolant empty");
}
[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);
}
[Fact]
public void An_unchanged_active_statement_emits_nothing_even_with_a_new_timestamp_or_count()
{
// The reconnect replay: /current re-reports the same Fault under a new sequence. Re-raising it would
// un-acknowledge an operator's ack.
var before = State(MTConnectConditionLevel.Fault, "overtemp", "E17");
var replayed = before with { TimestampUtc = T0.AddMinutes(5), ActiveCount = 2 };
Map(before, replayed).ShouldBeNull();
Map(before, replayed, resync: true).ShouldBeNull();
}
[Fact]
public void A_repeated_normal_with_different_text_does_not_re_clear()
{
Map(State(MTConnectConditionLevel.Normal, "ok"), State(MTConnectConditionLevel.Normal, "still ok")).ShouldBeNull();
}
[Fact]
public void An_active_condition_going_unavailable_clears_with_bad_quality()
{
var evt = Map(State(MTConnectConditionLevel.Fault, "x", "E1"), State(MTConnectConditionLevel.Unavailable))
.ShouldNotBeNull();
evt.Kind.ShouldBe(AlarmTransitionKind.Clear);
evt.QualityStatusCode.ShouldBe(BadNoCommunication);
evt.Message.ShouldBe("SYSTEM UNAVAILABLE");
}
[Fact]
public void An_inactive_condition_going_unavailable_and_back_is_quality_only()
{
var normal = State(MTConnectConditionLevel.Normal);
var unavailable = State(MTConnectConditionLevel.Unavailable);
var lost = Map(normal, unavailable).ShouldNotBeNull();
lost.IsQualityOnly.ShouldBeTrue();
lost.QualityStatusCode.ShouldBe(BadNoCommunication);
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");
}
[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 unavailable = State(MTConnectConditionLevel.Unavailable);
Map(unavailable, unavailable).ShouldBeNull();
Map(unavailable, unavailable, resync: true).ShouldNotBeNull().IsQualityOnly.ShouldBeTrue();
}
[Fact]
public void Unavailable_recovering_straight_to_fault_raises_with_good_quality()
{
var evt = Map(State(MTConnectConditionLevel.Unavailable), State(MTConnectConditionLevel.Fault, "x", "E1"))
.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);
private static MTConnectConditionState State(
MTConnectConditionLevel level,
string? text = null,
string? nativeCode = null,
string? nativeSeverity = null,
string? qualifier = null) =>
new(level, new MTConnectConditionDetail(text, nativeCode, nativeSeverity, qualifier, "SYSTEM"), T0,
level is MTConnectConditionLevel.Warning or MTConnectConditionLevel.Fault ? 1 : 0);
private static MTConnectObservation Cond(string state, string? nativeCode, int at) =>
new(Id, state, T0.AddSeconds(at),
Condition: new MTConnectConditionDetail($"{state} {nativeCode}", nativeCode, null, null, "SYSTEM"));
private static MTConnectStreamsResult Doc(params MTConnectObservation[] observations) =>
new(1, 2, 1, observations);
}