Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/MxGatewayAlarmMapperTests.cs
T
Joseph Doherty dd4f1c2aa3 fix(alarms): Severity runs one direction, higher = more urgent; raw priority kept (#129)
AlarmConditionState.Severity mixed two conventions: OPC UA rows carried Part 9
Severity (higher = more severe) while MxGateway native alarms and computed
alarms carried a raw AVEVA-style priority (1 = most urgent). Every consumer
ranks higher as worse, so Worst Severity, the severity filter/sort and the
site event-log Error threshold picked the LEAST urgent MxGateway/computed
alarms.

- AlarmPriorityBands: the OtOpcUa GalaxyAlarmPriority table (lmxopcua#887,
  b61f3e05), value for value: 1-250 -> 1000, 251-500 -> 750, 501-750 -> 500,
  751-999 -> 250; <= 0 clamps to Critical, > 999 to Low.
- MxGatewayAlarmMapper bands the gateway severity (= AVEVA priority);
  AlarmConditionStateFactory.ForComputed bands PriorityLevel. OPC UA unchanged.
- AlarmConditionState gains trailing Priority (int?): the raw source number,
  exactly the pre-#129 Severity. AlarmStateChanged.Priority and the wire
  AlarmStateUpdate.priority keep that meaning (no proto change); central derives
  Severity by kind (SeverityFromPriority), so an older site decodes correctly.
- ScriptAlarm gains trailing Priority. The native MES 900-999 band and the MES
  API Severity/MinSeverity/MaxSeverity key off Priority; rig seed scripts moved
  from a.Severity to a.Priority, so MES output is unchanged.
- AlarmActor raise logs Error when the banded severity >= 700 (same threshold
  as NativeAlarmActor): priority 1-500 Error, 501+ Warning (was raw >= 700).
- NativeAlarmActor upgrades pre-#129 native_alarm_state rows on rehydrate.
- Debug snapshot decode restores Condition.Priority from the row's priority.
- Docs: Commons, DCL, SiteRuntime, Communication, CentralUI, InboundAPI,
  HighLevelReqs, MES alarm-status plan, CLAUDE.md.

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
2026-11-12 14:02:54 -05:00

400 lines
16 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Google.Protobuf.WellKnownTypes;
using ZB.MOM.WW.MxGateway.Client;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
using CommonsTransitionKind = ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AlarmTransitionKind;
using ProtoConditionState = ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmConditionState;
using ProtoTransitionKind = ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmTransitionKind;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests;
/// <summary>Pure MxGateway alarm-feed proto → NativeAlarmTransition mapping.</summary>
public class MxGatewayAlarmMapperTests
{
/// <summary>Verifies that an acknowledge transition is mapped as acknowledged with the operator.</summary>
[Fact]
public void MapTransition_AckTransition_IsAcknowledgedWithOperator()
{
var ev = new OnAlarmTransitionEvent
{
AlarmFullReference = "Tank01.Level.HiHi",
SourceObjectReference = "Tank01",
AlarmTypeName = "AnalogLimitAlarm.HiHi",
TransitionKind = ProtoTransitionKind.Acknowledge,
Severity = 600,
OperatorUser = "operator1",
OperatorComment = "ack",
Category = "Process",
Description = "hi"
};
var t = MxGatewayAlarmMapper.MapTransition(ev);
Assert.Equal(CommonsTransitionKind.Acknowledge, t.Kind);
Assert.True(t.Condition.Active);
Assert.True(t.Condition.Acknowledged);
Assert.Equal(500, t.Condition.Severity); // AVEVA priority 600 = Medium → 500 (#129)
Assert.Equal(600, t.Condition.Priority); // raw priority kept
Assert.Equal("operator1", t.OperatorUser);
Assert.Equal("Tank01", t.SourceObjectReference);
}
/// <summary>Verifies that an active, acknowledged condition state maps to active true and acknowledged true.</summary>
[Fact]
public void MapConditionState_ActiveAcked_To_ActiveTrue_AckTrue()
{
var c = MxGatewayAlarmMapper.MapConditionState(ProtoConditionState.ActiveAcked, severity: 600);
Assert.True(c.Active);
Assert.True(c.Acknowledged);
Assert.Equal(500, c.Severity);
Assert.Equal(600, c.Priority);
}
/// <summary>Verifies that an active, unacknowledged snapshot maps to the snapshot kind.</summary>
[Fact]
public void MapSnapshot_ActiveUnacked_IsSnapshotKind()
{
var snap = new ActiveAlarmSnapshot
{
AlarmFullReference = "Tank01.Level.Hi",
SourceObjectReference = "Tank01",
AlarmTypeName = "AnalogLimitAlarm.Hi",
CurrentState = ProtoConditionState.Active,
Severity = 1500 // out of range — Priority clamps, Severity reads AVEVA Low
};
var t = MxGatewayAlarmMapper.MapSnapshot(snap);
Assert.Equal(CommonsTransitionKind.Snapshot, t.Kind);
Assert.True(t.Condition.Active);
Assert.False(t.Condition.Acknowledged);
Assert.Equal(250, t.Condition.Severity);
Assert.Equal(1000, t.Condition.Priority);
}
/// <summary>Verifies that the source reference is object-relative rather than the full provider reference.</summary>
[Fact]
public void SourceReference_IsObjectRelative_NotFullProviderReference()
{
// The condition identity surfaced upward is the object-relative reference
// (e.g. "Z28061.HeartbeatTimeoutAlarm"), not the gateway's full provider
// reference ("Galaxy!<area>.<object>.<alarm>"). Area lives in Category.
var snap = new ActiveAlarmSnapshot
{
AlarmFullReference = "Galaxy!CVDAisle_1.Z28061.HeartbeatTimeoutAlarm",
SourceObjectReference = "Z28061.HeartbeatTimeoutAlarm",
AlarmTypeName = "Syst",
Category = "CVDAisle_1",
CurrentState = ProtoConditionState.Active,
Severity = 400
};
var ev = new OnAlarmTransitionEvent
{
AlarmFullReference = "Galaxy!CVDAisle_1.Z28061.HeartbeatTimeoutAlarm",
SourceObjectReference = "Z28061.HeartbeatTimeoutAlarm",
AlarmTypeName = "Syst",
TransitionKind = ProtoTransitionKind.Raise,
Severity = 400
};
var snapT = MxGatewayAlarmMapper.MapSnapshot(snap);
var liveT = MxGatewayAlarmMapper.MapTransition(ev);
Assert.Equal("Z28061.HeartbeatTimeoutAlarm", snapT.SourceReference);
Assert.Equal("Z28061.HeartbeatTimeoutAlarm", liveT.SourceReference);
Assert.Equal("CVDAisle_1", snapT.Category);
}
/// <summary>Verifies that the source reference falls back to the full reference when the object reference is empty.</summary>
[Fact]
public void SourceReference_FallsBackToFullReference_WhenObjectReferenceEmpty()
{
var snap = new ActiveAlarmSnapshot
{
AlarmFullReference = "Galaxy!Area.Obj.Alarm",
SourceObjectReference = "",
CurrentState = ProtoConditionState.Active,
Severity = 100
};
var t = MxGatewayAlarmMapper.MapSnapshot(snap);
Assert.Equal("Galaxy!Area.Obj.Alarm", t.SourceReference);
}
// ── CurrentValue / LimitValue ──────────────────────────────
/// <summary>Verifies that the current and limit values of a transition are populated from the proto.</summary>
[Fact]
public void MapTransition_CurrentAndLimitValue_PopulatedFromProto()
{
// The gateway proto OnAlarmTransitionEvent carries current_value and
// limit_value as MxValue union fields. Verify both are mapped through
// MxValueToString into the neutral NativeAlarmTransition strings.
var ev = new OnAlarmTransitionEvent
{
AlarmFullReference = "Tank01.Level.HiHi",
SourceObjectReference = "Tank01",
AlarmTypeName = "AnalogLimitAlarm.HiHi",
TransitionKind = ProtoTransitionKind.Raise,
Severity = 800,
CurrentValue = 95.3.ToMxValue(),
LimitValue = 90.0.ToMxValue()
};
var t = MxGatewayAlarmMapper.MapTransition(ev);
Assert.Equal("95.3", t.CurrentValue);
Assert.Equal("90", t.LimitValue);
}
/// <summary>Verifies that a transition with no current or limit value yields empty values.</summary>
[Fact]
public void MapTransition_AbsentCurrentAndLimitValue_YieldsEmpty()
{
// When the gateway sends events without current/limit value fields (optional),
// the resulting transition must have empty strings — never null.
var ev = new OnAlarmTransitionEvent
{
AlarmFullReference = "Tank01.Level.Hi",
SourceObjectReference = "Tank01",
AlarmTypeName = "AnalogLimitAlarm.Hi",
TransitionKind = ProtoTransitionKind.Raise,
Severity = 600
// CurrentValue and LimitValue not set → proto default (null reference)
};
var t = MxGatewayAlarmMapper.MapTransition(ev);
Assert.Equal("", t.CurrentValue);
Assert.Equal("", t.LimitValue);
}
/// <summary>Verifies that the current and limit values of a snapshot are populated from the proto.</summary>
[Fact]
public void MapSnapshot_CurrentAndLimitValue_PopulatedFromProto()
{
// ActiveAlarmSnapshot also carries current_value and limit_value.
var snap = new ActiveAlarmSnapshot
{
AlarmFullReference = "Pump01.Vibration.HiHi",
SourceObjectReference = "Pump01",
AlarmTypeName = "AnalogLimitAlarm.HiHi",
CurrentState = ProtoConditionState.Active,
Severity = 900,
CurrentValue = 12.7.ToMxValue(),
LimitValue = 10.0.ToMxValue()
};
var t = MxGatewayAlarmMapper.MapSnapshot(snap);
Assert.Equal("12.7", t.CurrentValue);
Assert.Equal("10", t.LimitValue);
}
/// <summary>Verifies that a string MX value produces a string current value.</summary>
[Fact]
public void MapSnapshot_StringMxValue_ProducesStringCurrentValue()
{
// MxValue can carry string values (e.g. for discrete/string-type tags).
var snap = new ActiveAlarmSnapshot
{
AlarmFullReference = "Mode.Alarm",
SourceObjectReference = "Mode",
AlarmTypeName = "DiscreteAlarm",
CurrentState = ProtoConditionState.Active,
Severity = 500,
CurrentValue = "FAULT".ToMxValue()
};
var t = MxGatewayAlarmMapper.MapSnapshot(snap);
Assert.Equal("FAULT", t.CurrentValue);
Assert.Equal("", t.LimitValue); // not set
}
// ── MES alarm-status API §6.4: AckTime stamping ──
private static readonly DateTimeOffset GatewayTime =
new(2026, 8, 1, 9, 30, 0, TimeSpan.Zero);
/// <summary>Verifies that an acknowledge transition stamps the observed transition time as the ack time.</summary>
[Fact]
public void MapTransition_Acknowledge_StampsTheObservedTransitionTimeAsAckTime()
{
// The gateway feed carries NO dedicated ack timestamp, so the ack transition's own
// time is what gets stamped — accurate to when the system saw the ack.
var ev = new OnAlarmTransitionEvent
{
AlarmFullReference = "Tank01.Level.HiHi",
SourceObjectReference = "Tank01",
AlarmTypeName = "AnalogLimitAlarm.HiHi",
TransitionKind = ProtoTransitionKind.Acknowledge,
Severity = 600,
TransitionTimestamp = Timestamp.FromDateTimeOffset(GatewayTime)
};
var t = MxGatewayAlarmMapper.MapTransition(ev);
Assert.Equal(GatewayTime, t.AckTime);
Assert.Equal(t.TransitionTime, t.AckTime);
}
/// <summary>Verifies that a raise transition has no ack time.</summary>
[Fact]
public void MapTransition_Raise_HasNoAckTime()
{
// Null while unacknowledged.
var ev = new OnAlarmTransitionEvent
{
AlarmFullReference = "Tank01.Level.HiHi",
SourceObjectReference = "Tank01",
TransitionKind = ProtoTransitionKind.Raise,
Severity = 600,
TransitionTimestamp = Timestamp.FromDateTimeOffset(GatewayTime)
};
Assert.Null(MxGatewayAlarmMapper.MapTransition(ev).AckTime);
}
/// <summary>Verifies that a retrigger transition clears the ack time.</summary>
[Fact]
public void MapTransition_Retrigger_ClearsTheAckTime()
{
// "Cleared on re-raise": a Retrigger arrives unacknowledged, so no ack time is
// reported even though the condition was acked a moment earlier.
var ev = new OnAlarmTransitionEvent
{
AlarmFullReference = "Tank01.Level.HiHi",
SourceObjectReference = "Tank01",
TransitionKind = ProtoTransitionKind.Retrigger,
Severity = 600,
TransitionTimestamp = Timestamp.FromDateTimeOffset(GatewayTime)
};
Assert.Null(MxGatewayAlarmMapper.MapTransition(ev).AckTime);
}
/// <summary>Verifies that a clear transition has no ack time despite the inactive state mapping to acknowledged.</summary>
[Fact]
public void MapTransition_Clear_HasNoAckTime_DespiteInactiveMappingToAcked()
{
// The gateway maps INACTIVE to acked = true. Without the active check every
// return-to-normal would claim an ack the system never observed.
var ev = new OnAlarmTransitionEvent
{
AlarmFullReference = "Tank01.Level.HiHi",
SourceObjectReference = "Tank01",
TransitionKind = ProtoTransitionKind.Clear,
Severity = 600,
TransitionTimestamp = Timestamp.FromDateTimeOffset(GatewayTime)
};
var t = MxGatewayAlarmMapper.MapTransition(ev);
Assert.True(t.Condition.Acknowledged); // the gateway's own mapping
Assert.Null(t.AckTime); // …but no ack was observed
}
/// <summary>Verifies that an active, acknowledged snapshot restores an ack time from the last transition.</summary>
[Fact]
public void MapSnapshot_ActiveAcked_RestoresAnAckTimeFromTheLastTransition()
{
// A (re)subscribe snapshot must not silently drop the ack instant of an already
// acknowledged alarm — the last transition time is the best the feed supplies.
var snap = new ActiveAlarmSnapshot
{
AlarmFullReference = "Tank01.Level.HiHi",
SourceObjectReference = "Tank01",
CurrentState = ProtoConditionState.ActiveAcked,
Severity = 600,
LastTransitionTimestamp = Timestamp.FromDateTimeOffset(GatewayTime)
};
Assert.Equal(GatewayTime, MxGatewayAlarmMapper.MapSnapshot(snap).AckTime);
}
/// <summary>Verifies that an active, unacknowledged snapshot has no ack time.</summary>
[Fact]
public void MapSnapshot_ActiveUnacked_HasNoAckTime()
{
var snap = new ActiveAlarmSnapshot
{
AlarmFullReference = "Tank01.Level.HiHi",
SourceObjectReference = "Tank01",
CurrentState = ProtoConditionState.Active,
Severity = 600,
LastTransitionTimestamp = Timestamp.FromDateTimeOffset(GatewayTime)
};
Assert.Null(MxGatewayAlarmMapper.MapSnapshot(snap).AckTime);
}
/// <summary>Verifies that the snapshot-complete sentinel carries no ack time.</summary>
[Fact]
public void SnapshotComplete_Sentinel_CarriesNoAckTime()
{
// The end-of-snapshot sentinel has no condition payload at all.
Assert.Null(MxGatewayAlarmMapper.SnapshotComplete().AckTime);
}
// ── Gitea #129: one Severity direction, raw priority kept ──
/// <summary>
/// The gateway's <c>severity</c> field is the AVEVA priority (1 = most urgent). Severity uses OtOpcUa's
/// band table (lmxopcua#887), so priority 1 is the MOST severe — on a live transition and a snapshot alike.
/// </summary>
[Theory]
[InlineData(1, 1000)]
[InlineData(250, 1000)]
[InlineData(251, 750)]
[InlineData(500, 750)]
[InlineData(501, 500)]
[InlineData(750, 500)]
[InlineData(751, 250)]
[InlineData(999, 250)]
[InlineData(0, 1000)]
[InlineData(-100, 1000)]
[InlineData(1000, 250)]
[InlineData(int.MaxValue, 250)]
public void Severity_follows_the_AVEVA_priority_bands(int rawPriority, int expectedSeverity)
{
var live = MxGatewayAlarmMapper.MapTransition(new OnAlarmTransitionEvent
{
AlarmFullReference = "Galaxy!Area.Tank01.Hi", SourceObjectReference = "Tank01.Hi",
TransitionKind = ProtoTransitionKind.Raise, Severity = rawPriority
});
var snap = MxGatewayAlarmMapper.MapSnapshot(new ActiveAlarmSnapshot
{
AlarmFullReference = "Galaxy!Area.Tank01.Hi", SourceObjectReference = "Tank01.Hi",
CurrentState = ProtoConditionState.Active, Severity = rawPriority
});
Assert.Equal(expectedSeverity, live.Condition.Severity);
Assert.Equal(expectedSeverity, snap.Condition.Severity);
}
/// <summary>
/// The MES 900–999 band must select exactly the alarms it selected before #129, which tested the old
/// Severity — the gateway value clamped to 0–1000. Priority carries exactly that value for every input.
/// </summary>
[Fact]
public void Priority_is_the_pre_129_Severity_so_the_MES_band_is_unchanged()
{
foreach (var raw in new[] { int.MinValue, -1, 0, 1, 250, 251, 750, 751, 899, 900, 950, 999, 1000, 1001, 1500, int.MaxValue })
{
var t = MxGatewayAlarmMapper.MapTransition(new OnAlarmTransitionEvent
{
AlarmFullReference = "A", SourceObjectReference = "A",
TransitionKind = ProtoTransitionKind.Raise, Severity = raw
});
var preFixSeverity = Math.Clamp(raw, 0, 1000);
Assert.Equal(preFixSeverity, t.Condition.Priority);
var inBandBefore = preFixSeverity is >= 900 and <= 999;
var inBandNow = t.Condition.Priority is >= 900 and <= 999;
Assert.Equal(inBandBefore, inBandNow);
}
}
}