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