dd4f1c2aa3
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
400 lines
16 KiB
C#
400 lines
16 KiB
C#
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);
|
||
}
|
||
}
|
||
}
|