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
@@ -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()