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
@@ -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);
}
}
}