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
This commit is contained in:
@@ -32,11 +32,12 @@ public class AlarmSummaryRenderTests : BunitContext
|
||||
/// <summary>Initializes the test class and configures the faked services and the bUnit context.</summary>
|
||||
public AlarmSummaryRenderTests()
|
||||
{
|
||||
// Two rows whose default sort (severity DESC) orders Zeta (900) before
|
||||
// Alpha (500); an ascending sort by instance name flips them.
|
||||
// Two computed rows whose default sort (severity DESC) orders Zeta before Alpha:
|
||||
// priority 100 bands to Severity 1000, priority 500 to 750 (Gitea #129 — a lower
|
||||
// priority is more urgent). An ascending sort by instance name flips them.
|
||||
var rows = new List<AlarmSummaryRow>
|
||||
{
|
||||
new("Zeta", new AlarmStateChanged("Zeta", "Z-alarm", AlarmState.Active, 900, DateTimeOffset.UtcNow)),
|
||||
new("Zeta", new AlarmStateChanged("Zeta", "Z-alarm", AlarmState.Active, 100, DateTimeOffset.UtcNow)),
|
||||
new("Alpha", new AlarmStateChanged("Alpha", "A-alarm", AlarmState.Active, 500, DateTimeOffset.UtcNow)),
|
||||
};
|
||||
|
||||
@@ -267,7 +268,7 @@ public class AlarmSummaryRenderTests : BunitContext
|
||||
.Returns(ci => Task.FromResult(new AlarmSummaryResult(
|
||||
new List<AlarmSummaryRow>
|
||||
{
|
||||
new("Zeta", new AlarmStateChanged("Zeta", "Z-alarm", AlarmState.Active, 900, DateTimeOffset.UtcNow)),
|
||||
new("Zeta", new AlarmStateChanged("Zeta", "Z-alarm", AlarmState.Active, 100, DateTimeOffset.UtcNow)),
|
||||
},
|
||||
new[] { "OfflineInstance" })));
|
||||
|
||||
|
||||
@@ -140,7 +140,7 @@ public class AlarmSummaryServiceTests
|
||||
new("inst-b", NativeAlarm("inst-b", "MtrTrip", AlarmState.Active, 300, active: true, acked: false, kind: AlarmKind.NativeMxAccess)),
|
||||
// computed normal (not active) → ignored by active/unacked, severity 200 not counted
|
||||
new("inst-a", ComputedAlarm("inst-a", "PumpFault", AlarmState.Normal, 200)),
|
||||
// computed active sev 999 — active, but computed so NOT unacked
|
||||
// computed active priority 999 = AVEVA Low → Severity 250 (#129) — active, but computed so NOT unacked
|
||||
new("inst-c", ComputedAlarm("inst-c", "HiHi", AlarmState.Active, 999)),
|
||||
};
|
||||
|
||||
@@ -148,8 +148,8 @@ public class AlarmSummaryServiceTests
|
||||
|
||||
// Active = the 3 native actives + the active computed = 4
|
||||
Assert.Equal(4, rollup.TotalActive);
|
||||
// Worst severity among active = 999 (the active computed)
|
||||
Assert.Equal(999, rollup.WorstSeverity);
|
||||
// Worst severity among active = 800 (TankHi); the computed priority 999 is the LEAST urgent
|
||||
Assert.Equal(800, rollup.WorstSeverity);
|
||||
// Unacked = active && !acked && kind != Computed = TankHi + MtrTrip = 2
|
||||
Assert.Equal(2, rollup.UnackedCount);
|
||||
// Kind counts: NativeOpcUa 2, NativeMxAccess 1, Computed 2
|
||||
@@ -158,6 +158,36 @@ public class AlarmSummaryServiceTests
|
||||
Assert.Equal(2, rollup.CountsByKind[AlarmKind.Computed]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #129: "Worst Severity" is the most URGENT alarm across every kind. AVEVA priority 1 (computed or
|
||||
/// MxAccess) bands to 1000 and outranks priority 999 (250) and an OPC UA Severity of 900.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ComputeRollup_WorstSeverity_IsTheMostUrgentPriority()
|
||||
{
|
||||
var mx = new AlarmStateChanged("inst-a", "MtrTrip", AlarmState.Active, 1, Now)
|
||||
{
|
||||
Kind = AlarmKind.NativeMxAccess,
|
||||
Condition = new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false,
|
||||
AlarmPriorityBands.ToSeverity(1), Priority: 1),
|
||||
};
|
||||
var rows = new List<AlarmSummaryRow>
|
||||
{
|
||||
new("inst-a", ComputedAlarm("inst-a", "Minor", AlarmState.Active, 999)),
|
||||
new("inst-b", NativeAlarm("inst-b", "TankHi", AlarmState.Active, 900, active: true, acked: false)),
|
||||
new("inst-a", mx),
|
||||
};
|
||||
|
||||
Assert.Equal(1000, CreateSut().ComputeRollup(rows).WorstSeverity);
|
||||
|
||||
var computedOnly = new List<AlarmSummaryRow>
|
||||
{
|
||||
new("inst-a", ComputedAlarm("inst-a", "Minor", AlarmState.Active, 999)),
|
||||
new("inst-a", ComputedAlarm("inst-a", "Major", AlarmState.Active, 1)),
|
||||
};
|
||||
Assert.Equal(1000, CreateSut().ComputeRollup(computedOnly).WorstSeverity);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a rollup with no active rows has a worst severity of zero.</summary>
|
||||
[Fact]
|
||||
public void ComputeRollup_NoActiveRows_WorstSeverityZero()
|
||||
|
||||
+4
-2
@@ -13,7 +13,8 @@ public class AlarmStateChangedEnrichmentTests
|
||||
var m = new AlarmStateChanged("inst", "HiAlarm", AlarmState.Active, 700, DateTimeOffset.UnixEpoch);
|
||||
Assert.Equal(AlarmKind.Computed, m.Kind);
|
||||
Assert.True(m.Condition.Acknowledged); // computed = auto-acked
|
||||
Assert.Equal(700, m.Condition.Severity); // severity defaults to Priority
|
||||
Assert.Equal(500, m.Condition.Severity); // priority 700 banded: Medium → 500 (#129)
|
||||
Assert.Equal(700, m.Condition.Priority); // raw priority kept verbatim
|
||||
Assert.True(m.Condition.Active); // derived from State
|
||||
Assert.Equal("", m.SourceReference);
|
||||
}
|
||||
@@ -25,7 +26,8 @@ public class AlarmStateChangedEnrichmentTests
|
||||
var c = AlarmConditionStateFactory.ForComputed(AlarmState.Normal, priority: 250);
|
||||
Assert.False(c.Active);
|
||||
Assert.True(c.Acknowledged);
|
||||
Assert.Equal(250, c.Severity);
|
||||
Assert.Equal(1000, c.Severity); // priority 250 is AVEVA Critical (#129)
|
||||
Assert.Equal(250, c.Priority);
|
||||
}
|
||||
|
||||
// ── MES alarm-status API §6.4: additive AckTime ──
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types.Alarms;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the AVEVA priority → unified Severity table (Gitea #129). It must equal OtOpcUa's
|
||||
/// <c>GalaxyAlarmPriority.Map</c> (lmxopcua#887, <c>b61f3e05</c>) value for value — the edge rows below
|
||||
/// are copied from OtOpcUa's <c>MxAccessSeverityMapperTests</c> — so a Galaxy alarm reads the same
|
||||
/// Severity through the MxGateway adapter as through OtOpcUa.
|
||||
/// </summary>
|
||||
public sealed class AlarmPriorityBandsTests
|
||||
{
|
||||
/// <summary>The band table at every edge, plus the out-of-range clamps.</summary>
|
||||
[Theory]
|
||||
[InlineData(1, 1000)] // AVEVA Critical 1–250
|
||||
[InlineData(250, 1000)]
|
||||
[InlineData(251, 750)] // AVEVA High 251–500
|
||||
[InlineData(500, 750)]
|
||||
[InlineData(501, 500)] // AVEVA Medium 501–750
|
||||
[InlineData(750, 500)]
|
||||
[InlineData(751, 250)] // AVEVA Low 751–999
|
||||
[InlineData(999, 250)]
|
||||
[InlineData(0, 1000)] // below range: clamped to priority 1
|
||||
[InlineData(-100, 1000)]
|
||||
[InlineData(int.MinValue, 1000)]
|
||||
[InlineData(1000, 250)] // above range: clamped to priority 999
|
||||
[InlineData(int.MaxValue, 250)]
|
||||
public void ToSeverity_assigns_the_AVEVA_priority_band(int priority, int expectedSeverity) =>
|
||||
Assert.Equal(expectedSeverity, AlarmPriorityBands.ToSeverity(priority));
|
||||
|
||||
/// <summary>
|
||||
/// A more urgent (lower) priority never yields a lower Severity, and the ends differ — so neither the
|
||||
/// pre-#129 identity mapping (which rises with priority) nor a constant mapping passes.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ToSeverity_never_ranks_a_less_urgent_priority_above_a_more_urgent_one()
|
||||
{
|
||||
var previous = AlarmPriorityBands.ToSeverity(1);
|
||||
for (var priority = 2; priority <= 999; priority++)
|
||||
{
|
||||
var current = AlarmPriorityBands.ToSeverity(priority);
|
||||
Assert.True(current <= previous, $"priority {priority} must not be more severe than {priority - 1}");
|
||||
previous = current;
|
||||
}
|
||||
|
||||
Assert.True(AlarmPriorityBands.ToSeverity(1) > AlarmPriorityBands.ToSeverity(999));
|
||||
}
|
||||
|
||||
/// <summary>The constants are the OtOpcUa values, so a drift on either side is caught here.</summary>
|
||||
[Fact]
|
||||
public void Constants_match_OtOpcUa_GalaxyAlarmPriority()
|
||||
{
|
||||
Assert.Equal(250, AlarmPriorityBands.CriticalMaxPriority);
|
||||
Assert.Equal(500, AlarmPriorityBands.HighMaxPriority);
|
||||
Assert.Equal(750, AlarmPriorityBands.MediumMaxPriority);
|
||||
Assert.Equal(1000, AlarmPriorityBands.SeverityCritical);
|
||||
Assert.Equal(750, AlarmPriorityBands.SeverityHigh);
|
||||
Assert.Equal(500, AlarmPriorityBands.SeverityMedium);
|
||||
Assert.Equal(250, AlarmPriorityBands.SeverityLow);
|
||||
}
|
||||
|
||||
/// <summary>Computed alarms band their PriorityLevel onto Severity and keep it verbatim on Priority.</summary>
|
||||
[Theory]
|
||||
[InlineData(1, 1000)]
|
||||
[InlineData(300, 750)]
|
||||
[InlineData(700, 500)]
|
||||
[InlineData(900, 250)]
|
||||
public void ForComputed_bands_Severity_and_keeps_the_raw_Priority(int priority, int expectedSeverity)
|
||||
{
|
||||
var c = AlarmConditionStateFactory.ForComputed(AlarmState.Active, priority);
|
||||
Assert.Equal(expectedSeverity, c.Severity);
|
||||
Assert.Equal(priority, c.Priority);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Central derives Severity from the wire's raw priority: banded for MxAccess and computed rows,
|
||||
/// verbatim (clamped) for OPC UA, whose wire value is already Part 9 Severity.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(AlarmKind.NativeMxAccess, 1, 1000)]
|
||||
[InlineData(AlarmKind.NativeMxAccess, 950, 250)]
|
||||
[InlineData(AlarmKind.Computed, 400, 750)]
|
||||
[InlineData(AlarmKind.Computed, 600, 500)]
|
||||
[InlineData(AlarmKind.NativeOpcUa, 950, 950)]
|
||||
[InlineData(AlarmKind.NativeOpcUa, 1, 1)]
|
||||
[InlineData(AlarmKind.NativeOpcUa, 1500, 1000)]
|
||||
public void SeverityFromPriority_bands_all_but_OPC_UA(AlarmKind kind, int priority, int expectedSeverity) =>
|
||||
Assert.Equal(expectedSeverity, AlarmConditionStateFactory.SeverityFromPriority(kind, priority));
|
||||
}
|
||||
@@ -96,7 +96,8 @@ public class SiteStreamGrpcClientTests
|
||||
Assert.False(alarm.Condition.Acknowledged);
|
||||
Assert.Equal(AlarmShelveState.TimedShelved, alarm.Condition.Shelve);
|
||||
Assert.True(alarm.Condition.Suppressed);
|
||||
Assert.Equal(850, alarm.Condition.Severity);
|
||||
Assert.Equal(850, alarm.Condition.Severity); // OPC UA: wire value is Part 9 Severity already
|
||||
Assert.Equal(850, alarm.Condition.Priority);
|
||||
Assert.Equal("T01.Hi", alarm.SourceReference);
|
||||
Assert.Equal("AnalogLimit.Hi", alarm.AlarmTypeName);
|
||||
Assert.Equal("Process", alarm.Category);
|
||||
@@ -107,6 +108,41 @@ public class SiteStreamGrpcClientTests
|
||||
Assert.Equal("100", alarm.LimitValue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #129: the wire carries the raw priority. MxAccess and computed rows band it onto a
|
||||
/// higher-is-more-urgent Severity (AVEVA priority 1 → 1000); the raw value stays on Priority.
|
||||
/// The wire meaning is unchanged, so this is also right for a site on an older build.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("NativeMxAccess", 1, 1000)]
|
||||
[InlineData("NativeMxAccess", 400, 750)]
|
||||
[InlineData("NativeMxAccess", 950, 250)]
|
||||
[InlineData("Computed", 100, 1000)]
|
||||
[InlineData("Computed", 700, 500)]
|
||||
[InlineData("NativeOpcUa", 950, 950)]
|
||||
public void ConvertToDomainEvent_AlarmChanged_SeverityIsOneDirection(string kind, int wirePriority, int expectedSeverity)
|
||||
{
|
||||
var evt = new SiteStreamEvent
|
||||
{
|
||||
AlarmChanged = new AlarmStateUpdate
|
||||
{
|
||||
InstanceUniqueName = "Site1.Motor01",
|
||||
AlarmName = "A",
|
||||
State = AlarmStateEnum.AlarmStateActive,
|
||||
Priority = wirePriority,
|
||||
Timestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UnixEpoch),
|
||||
Kind = kind,
|
||||
Active = true
|
||||
}
|
||||
};
|
||||
|
||||
var alarm = Assert.IsType<AlarmStateChanged>(SiteStreamGrpcClient.ConvertToDomainEvent(evt));
|
||||
|
||||
Assert.Equal(expectedSeverity, alarm.Condition.Severity);
|
||||
Assert.Equal(wirePriority, alarm.Condition.Priority);
|
||||
Assert.Equal(wirePriority, alarm.Priority);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an unknown stream event converts to null.</summary>
|
||||
[Fact]
|
||||
public void ConvertToDomainEvent_UnknownEvent_ReturnsNull()
|
||||
|
||||
@@ -436,7 +436,8 @@ public class SiteCommandDtoMapperGoldenTests
|
||||
[Fact]
|
||||
public void NativeAlarm_KeepsItsExplicitCondition()
|
||||
{
|
||||
var condition = new AlarmConditionState(true, false, true, AlarmShelveState.PermanentShelved, true, 999);
|
||||
// Priority equals the row's own Priority, as NativeAlarmActor always emits it (Gitea #129).
|
||||
var condition = new AlarmConditionState(true, false, true, AlarmShelveState.PermanentShelved, true, 999, Priority: 500);
|
||||
var original = new AlarmStateChanged("inst", "alarm", AlarmState.Active, 500, DateTimeOffset.UtcNow)
|
||||
{
|
||||
Kind = AlarmKind.NativeMxAccess,
|
||||
@@ -450,6 +451,25 @@ public class SiteCommandDtoMapperGoldenTests
|
||||
Assert.Equal(condition, restored.Condition);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The debug snapshot's condition DTO carries Severity only; the raw priority (Gitea #129) is
|
||||
/// restored from the row's Priority so the MES band keeps reading it on central too.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void NativeAlarm_RestoresTheRawPriorityOnItsCondition()
|
||||
{
|
||||
var original = new AlarmStateChanged("inst", "alarm", AlarmState.Active, 950, DateTimeOffset.UtcNow)
|
||||
{
|
||||
Kind = AlarmKind.NativeMxAccess,
|
||||
Condition = new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 250, Priority: 950)
|
||||
};
|
||||
|
||||
var restored = SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(original));
|
||||
|
||||
Assert.Equal(250, restored.Condition.Severity);
|
||||
Assert.Equal(950, restored.Condition.Priority);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Group classification + rejection
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -301,7 +301,7 @@ internal static class SiteCommandSamples
|
||||
Level = AlarmLevel.None,
|
||||
Message = "Level high",
|
||||
Kind = AlarmKind.NativeOpcUa,
|
||||
Condition = new AlarmConditionState(true, false, false, AlarmShelveState.OneShotShelved, true, 850),
|
||||
Condition = new AlarmConditionState(true, false, false, AlarmShelveState.OneShotShelved, true, 850, Priority: 850),
|
||||
SourceReference = "Tank01.Level.HiHi",
|
||||
AlarmTypeName = "AnalogLimitAlarm.HiHi",
|
||||
Category = "Process",
|
||||
|
||||
@@ -33,7 +33,8 @@ public class MxGatewayAlarmMapperTests
|
||||
Assert.Equal(CommonsTransitionKind.Acknowledge, t.Kind);
|
||||
Assert.True(t.Condition.Active);
|
||||
Assert.True(t.Condition.Acknowledged);
|
||||
Assert.Equal(600, t.Condition.Severity);
|
||||
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);
|
||||
}
|
||||
@@ -45,7 +46,8 @@ public class MxGatewayAlarmMapperTests
|
||||
var c = MxGatewayAlarmMapper.MapConditionState(ProtoConditionState.ActiveAcked, severity: 600);
|
||||
Assert.True(c.Active);
|
||||
Assert.True(c.Acknowledged);
|
||||
Assert.Equal(600, c.Severity);
|
||||
Assert.Equal(500, c.Severity);
|
||||
Assert.Equal(600, c.Priority);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an active, unacknowledged snapshot maps to the snapshot kind.</summary>
|
||||
@@ -58,7 +60,7 @@ public class MxGatewayAlarmMapperTests
|
||||
SourceObjectReference = "Tank01",
|
||||
AlarmTypeName = "AnalogLimitAlarm.Hi",
|
||||
CurrentState = ProtoConditionState.Active,
|
||||
Severity = 1500 // out of range — must clamp
|
||||
Severity = 1500 // out of range — Priority clamps, Severity reads AVEVA Low
|
||||
};
|
||||
|
||||
var t = MxGatewayAlarmMapper.MapSnapshot(snap);
|
||||
@@ -66,7 +68,8 @@ public class MxGatewayAlarmMapperTests
|
||||
Assert.Equal(CommonsTransitionKind.Snapshot, t.Kind);
|
||||
Assert.True(t.Condition.Active);
|
||||
Assert.False(t.Condition.Acknowledged);
|
||||
Assert.Equal(1000, t.Condition.Severity);
|
||||
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>
|
||||
@@ -334,4 +337,63 @@ public class MxGatewayAlarmMapperTests
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +100,7 @@ public class OpcUaAlarmMapperTests
|
||||
Assert.True(c.Active);
|
||||
Assert.False(c.Acknowledged);
|
||||
Assert.Equal(700, c.Severity);
|
||||
Assert.Equal(700, c.Priority); // OPC UA has no separate priority (#129)
|
||||
Assert.Equal(AlarmShelveState.Unshelved, c.Shelve);
|
||||
}
|
||||
|
||||
|
||||
@@ -121,8 +121,9 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
|
||||
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmKind.Computed, msg.Kind); // computed origin
|
||||
Assert.Equal(msg.Priority, msg.Condition.Severity); // severity tracks priority
|
||||
Assert.Equal(700, msg.Condition.Severity);
|
||||
Assert.Equal(700, msg.Priority); // raw PriorityLevel on the record
|
||||
Assert.Equal(700, msg.Condition.Priority); // ...and on the condition (#129)
|
||||
Assert.Equal(500, msg.Condition.Severity); // banded: 501–750 = Medium → 500
|
||||
Assert.True(msg.Condition.Active); // active alarm → condition active
|
||||
Assert.True(msg.Condition.Acknowledged); // computed alarms are auto-acked
|
||||
Assert.Equal(string.Empty, msg.SourceReference); // no native source reference
|
||||
@@ -935,7 +936,7 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
CanonicalName = "HighTemp",
|
||||
TriggerType = "ValueMatch",
|
||||
TriggerConfiguration = "{\"attributeName\":\"Status\",\"matchValue\":\"Critical\"}",
|
||||
PriorityLevel = 800
|
||||
PriorityLevel = 100
|
||||
};
|
||||
|
||||
var instanceProbe = CreateTestProbe();
|
||||
@@ -954,7 +955,7 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
var rows = siteLog.OfType("alarm");
|
||||
Assert.Single(rows);
|
||||
var row = rows[0];
|
||||
Assert.Equal("Error", row.Severity); // priority 800 → Error
|
||||
Assert.Equal("Error", row.Severity); // priority 100 = AVEVA Critical → Error (#129)
|
||||
Assert.Equal("Pump1", row.InstanceId);
|
||||
Assert.Equal("AlarmActor:HighTemp", row.Source);
|
||||
}, TimeSpan.FromSeconds(2));
|
||||
@@ -972,7 +973,7 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
CanonicalName = "MinorTemp",
|
||||
TriggerType = "ValueMatch",
|
||||
TriggerConfiguration = "{\"attributeName\":\"Status\",\"matchValue\":\"Warn\"}",
|
||||
PriorityLevel = 100
|
||||
PriorityLevel = 800
|
||||
};
|
||||
|
||||
var instanceProbe = CreateTestProbe();
|
||||
@@ -989,7 +990,7 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
{
|
||||
var rows = siteLog.OfType("alarm");
|
||||
Assert.Single(rows);
|
||||
Assert.Equal("Warning", rows[0].Severity); // priority 100 → Warning
|
||||
Assert.Equal("Warning", rows[0].Severity); // priority 800 = AVEVA Low → Warning (#129)
|
||||
}, TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
@@ -1005,7 +1006,7 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
CanonicalName = "HighTemp",
|
||||
TriggerType = "ValueMatch",
|
||||
TriggerConfiguration = "{\"attributeName\":\"Status\",\"matchValue\":\"Critical\"}",
|
||||
PriorityLevel = 800
|
||||
PriorityLevel = 100
|
||||
};
|
||||
|
||||
var instanceProbe = CreateTestProbe();
|
||||
@@ -1030,6 +1031,22 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
}, TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #129: a computed raise's event-log severity follows the priority banded onto the unified
|
||||
/// Severity (lower priority = more urgent), with the same 700 threshold NativeAlarmActor uses.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(0, "Error")]
|
||||
[InlineData(1, "Error")]
|
||||
[InlineData(250, "Error")]
|
||||
[InlineData(500, "Error")]
|
||||
[InlineData(501, "Warning")]
|
||||
[InlineData(700, "Warning")]
|
||||
[InlineData(999, "Warning")]
|
||||
[InlineData(1000, "Warning")]
|
||||
public void RaiseSeverity_FollowsTheBandedPriority(int priority, string expected) =>
|
||||
Assert.Equal(expected, AlarmActor.RaiseSeverity(priority));
|
||||
|
||||
/// <summary>Verifies that a malformed trigger configuration does not crash the alarm actor.</summary>
|
||||
[Fact]
|
||||
public void AlarmActor_MalformedTriggerConfig_DoesNotCrash()
|
||||
|
||||
@@ -668,6 +668,80 @@ public class NativeAlarmActorTests : TestKit, IDisposable
|
||||
Assert.Null(emitted.AckTime); // …with no ack time invented
|
||||
}
|
||||
|
||||
// ── Gitea #129: one Severity direction, raw priority kept ──
|
||||
|
||||
private IActorRef SpawnKind(IActorRef instanceActor, IActorRef dclManager, AlarmKind kind,
|
||||
IServiceProvider? serviceProvider = null) =>
|
||||
ActorOf(Props.Create(() => new NativeAlarmActor(
|
||||
Source(), "inst", instanceActor, dclManager, _storage, _options,
|
||||
NullLogger<NativeAlarmActor>.Instance, kind, serviceProvider)));
|
||||
|
||||
/// <summary>
|
||||
/// An MxAccess condition as the DCL now builds it (AVEVA priority banded onto Severity, raw kept on
|
||||
/// Priority): the record's Priority is the raw priority — the value the MES band and the stream's
|
||||
/// <c>AlarmStateUpdate.priority</c> carry — and the event log ranks priority 1 as an Error, 950 as a Warning.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(1, 1000, "Error")]
|
||||
[InlineData(400, 750, "Error")]
|
||||
[InlineData(600, 500, "Warning")]
|
||||
[InlineData(950, 250, "Warning")]
|
||||
public void MxAccessRaise_CarriesRawPriority_AndLogsByBandedSeverity(int priority, int severity, string logSeverity)
|
||||
{
|
||||
var siteLog = new FakeSiteEventLogger();
|
||||
var instance = CreateTestProbe();
|
||||
var dcl = CreateTestProbe();
|
||||
var actor = SpawnKind(instance.Ref, dcl.Ref, AlarmKind.NativeMxAccess, new SingleServiceProvider(siteLog));
|
||||
dcl.ExpectMsg<SubscribeAlarmsRequest>();
|
||||
|
||||
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
|
||||
"T01.Hi", AlarmTransitionKind.Raise,
|
||||
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, severity, Priority: priority))));
|
||||
var emitted = instance.ExpectMsg<AlarmStateChanged>(m => m.State == AlarmState.Active);
|
||||
|
||||
Assert.Equal(priority, emitted.Priority);
|
||||
Assert.Equal(priority, emitted.Condition.Priority);
|
||||
Assert.Equal(severity, emitted.Condition.Severity);
|
||||
AwaitAssert(() => Assert.Equal(logSeverity, Assert.Single(siteLog.OfType("alarm")).Severity),
|
||||
TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A row persisted by a pre-#129 build holds the raw number in Severity and no Priority. Rehydration
|
||||
/// moves it to Priority and re-derives Severity: banded for an MxAccess source, kept for OPC UA.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(AlarmKind.NativeMxAccess, 950, 250)]
|
||||
[InlineData(AlarmKind.NativeMxAccess, 1, 1000)]
|
||||
[InlineData(AlarmKind.NativeOpcUa, 800, 800)]
|
||||
public async Task Rehydration_OfAPre129Row_MovesTheRawValueToPriority(AlarmKind kind, int legacySeverity, int expectedSeverity)
|
||||
{
|
||||
var legacyJson = JsonSerializer.Serialize(new
|
||||
{
|
||||
Active = true, Acknowledged = false, Confirmed = (bool?)null,
|
||||
Shelve = AlarmShelveState.Unshelved, Suppressed = false, Severity = legacySeverity
|
||||
});
|
||||
await _storage.UpsertNativeAlarmAsync(
|
||||
"inst", Source().CanonicalName, "ref-129", legacyJson, DateTimeOffset.UtcNow, null);
|
||||
|
||||
var instance = CreateTestProbe();
|
||||
SpawnKind(instance.Ref, CreateTestProbe().Ref, kind);
|
||||
|
||||
var emitted = instance.FishForMessage<AlarmStateChanged>(
|
||||
m => m.SourceReference == "ref-129", TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(expectedSeverity, emitted.Condition.Severity);
|
||||
Assert.Equal(legacySeverity, emitted.Condition.Priority);
|
||||
Assert.Equal(legacySeverity, emitted.Priority);
|
||||
}
|
||||
|
||||
/// <summary>A post-#129 row (Priority present) is left exactly as persisted.</summary>
|
||||
[Fact]
|
||||
public void UpgradeLegacyCondition_LeavesACurrentRowAlone()
|
||||
{
|
||||
var current = new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 250, Priority: 950);
|
||||
Assert.Same(current, NativeAlarmActor.UpgradeLegacyCondition(current, AlarmKind.NativeMxAccess));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a lost subscribe response causes the subscribe to be resent.</summary>
|
||||
[Fact]
|
||||
public void LostSubscribeResponse_ResendsSubscribe()
|
||||
|
||||
@@ -42,7 +42,8 @@ public class AlarmsAccessorTests : TestKit, IDisposable
|
||||
Kind = AlarmKind.NativeMxAccess,
|
||||
Condition = new AlarmConditionState(
|
||||
Active: true, Acknowledged: true, Confirmed: null,
|
||||
Shelve: AlarmShelveState.TimedShelved, Suppressed: true, Severity: 950),
|
||||
// AVEVA priority 950 (inside the MES 900-999 band) banded to Low = 250 (#129).
|
||||
Shelve: AlarmShelveState.TimedShelved, Suppressed: true, Severity: 250, Priority: 950),
|
||||
SourceReference = "Z28061.HeartbeatTimeoutAlarm",
|
||||
NativeSourceCanonicalName = "ReactorAlarms",
|
||||
AlarmTypeName = "DiscreteAlarm",
|
||||
@@ -108,7 +109,8 @@ public class AlarmsAccessorTests : TestKit, IDisposable
|
||||
Assert.Null(alarm.Confirmed);
|
||||
Assert.True(alarm.Shelved); // TimedShelved collapses to a single boolean
|
||||
Assert.True(alarm.Suppressed);
|
||||
Assert.Equal(950, alarm.Severity);
|
||||
Assert.Equal(250, alarm.Severity);
|
||||
Assert.Equal(950, alarm.Priority); // the MES band keys off the raw priority (#129)
|
||||
Assert.Equal("NativeMxAccess", alarm.Kind);
|
||||
Assert.Equal("Heartbeat lost", alarm.Message);
|
||||
Assert.Equal("DiscreteAlarm", alarm.AlarmTypeName);
|
||||
@@ -166,7 +168,8 @@ public class AlarmsAccessorTests : TestKit, IDisposable
|
||||
Assert.True(alarm.Active);
|
||||
Assert.True(alarm.Acknowledged); // computed alarms are auto-acked…
|
||||
Assert.Null(alarm.AckTime); // …but carry no ack instant
|
||||
Assert.Equal(700, alarm.Severity);
|
||||
Assert.Equal(500, alarm.Severity); // PriorityLevel 700 banded: Medium → 500 (#129)
|
||||
Assert.Equal(700, alarm.Priority);
|
||||
Assert.Equal(string.Empty, alarm.NativeSourceCanonicalName);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user