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:
Joseph Doherty
2026-11-12 13:58:28 -05:00
parent e36cd6055e
commit dd4f1c2aa3
39 changed files with 686 additions and 120 deletions
@@ -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));
}