fix(galaxy): AVEVA priority 1 is the most urgent: Galaxy alarm Severity follows AVEVA's priority bands, live and history alike (#887)
v2-ci / unit-tests (push) Has been cancelled

GalaxyAlarmPriority, the one table live Galaxy alarms (MxAccessSeverityMapper)
and AVEVA A&E history (EventMapper, #886) map the AVEVA priority through, ranked
a higher number as more severe. AVEVA ranks priority 1 as the most urgent, so a
priority-1 alarm read 250 and a priority-999 system event 1000.

It now uses AVEVA's default priority ranges, most urgent first:

  AVEVA priority  AVEVA severity  bucket    OPC UA Severity
  1-250           1 Critical      Critical  1000
  251-500         2 High          High       750
  501-750         3 Medium        Medium     500
  751-999         4 Low           Low        250
  (history only)  0 not an alarm  -            1

Out-of-range priorities clamp (<= 0 Critical, > 999 Low). Evidence, vd03
Runtime.dbo.Events read-only: priority 1/100 alarms carry AVEVA severity 1,
400/500 severity 2, 600/700 severity 3, and every priority-999 System.* /
User.* row severity 0 with IsAlarm 0.

- Live and history now use the same number. GalaxyDriver passes the table's
  exact value as AlarmEventArgs.OpcUaSeverity. Before, it passed only the bucket,
  which NativeAlarmProjector re-mapped through its generic 200/500/700/900
  ladder, so live and history disagreed even before the direction fix.
- History: a row whose AVEVA severity is 0 (System.Start/Stop/Deploy,
  User.Write...) is not an alarm and reads Severity 1, below every alarm band.
  For alarms AVEVA's 1-4 severity is still never the value: the live feed
  carries only the priority, so it cannot use it, and history must agree.
- Docs: docs/Historian.md gains "Galaxy alarm priority -> Severity
  (lmxopcua#887)"; docs/AlarmTracking.md updated.
- Live re-run of the #801/#886 harness against vd03 HistorianGateway
  ea9f21c (read-only, network-only): 224 of 233 cases identical to the #886
  run, the 9 others differ only in Severity (priority-700 alarm rows 750 ->
  500, 19 system/user events 1000 -> 1). Record: aveva-history-801.md
  "#887 re-run", report aveva-gate-sdk2-887.json.gz.

Client-visible: every OPC UA client that ranks or filters Galaxy alarms by
Severity sees the order reversed (live and HistoryReadEvents). ScadaBridge has
no code that depends on the old values; its own mixed severity directions are
ScadaBridge#129.

Tests updated because they pinned the inverted table:
- Driver.Galaxy.Tests MxAccessSeverityMapperTests (rewritten: edges 1/250/251/
  500/501/750/751/999, 0/-100/int.MinValue/1000/int.MaxValue clamps,
  monotonic-decreasing sweep, equals GalaxyAlarmPriority for -2..1001)
- Driver.Galaxy.Tests GatewayGalaxyAlarmFeedTests (750 and 600 -> Medium)
- Driver.Historian.Gateway.Tests EventMapperTests (band table, priority 999
  -> 250, 1 -> 1000; system events -> 1)
- OpcUaServer.IntegrationTests GalaxyEquipmentEventHistoryWireTests (700 ->
  500, 100 -> 1000, System.Stop -> 1; the fake now stores AVEVA's real 0-4
  severity)

New tests:
- Runtime.Tests NativeAlarmRestatementEndToEndTests
  .Galaxy_live_condition_severity_is_the_AVEVA_priority_band (8 rows: the real
  feed -> real GalaxyDriver -> host -> projector; the same rows EventMapperTests
  pins on the history path)
- EventMapperTests.Aveva_severity_zero_marks_an_event_that_is_not_an_alarm
  (5 rows, incl. the priority-999 alarm positive control)
- GalaxyDriverAlarmSourceTests: OpcUaSeverity passes through

Mutation check, 8 mutants, all killed: the pre-#887 inverted table; the
driver dropping OpcUaSeverity; no not-an-alarm rule; each of the three band
edges off by one; history ignoring priority; a missing AVEVA severity read
as 0.

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
This commit is contained in:
Joseph Doherty
2026-11-12 12:14:48 -05:00
parent fa811feb3d
commit b61f3e0560
14 changed files with 268 additions and 83 deletions
@@ -39,6 +39,8 @@ public sealed class GalaxyDriverAlarmSourceTests
observed[0].SourceNodeId.ShouldBe("Tank01");
observed[0].AlarmType.ShouldBe("AnalogLimitAlarm.HiHi");
observed[0].Severity.ShouldBe(AlarmSeverity.Critical);
// lmxopcua#887: the exact priority-table severity rides along, so the host doesn't re-map the bucket.
observed[0].OpcUaSeverity.ShouldBe(800);
observed[0].SubscriptionHandle.ShouldBe(handle);
}
@@ -61,7 +61,8 @@ public sealed class GatewayGalaxyAlarmFeedTests
// Active (unacked) snapshot entry → Retrigger (#647 review: forces the upstream's unack on the host).
observed[0].AlarmFullReference.ShouldBe("Tank01.Level.HiHi");
observed[0].TransitionKind.ShouldBe(GalaxyAlarmTransitionKind.Retrigger);
observed[0].SeverityBucket.ShouldBe(AlarmSeverity.Critical);
observed[0].SeverityBucket.ShouldBe(AlarmSeverity.Medium); // priority 750: AVEVA Medium 501–750 (#887)
observed[0].OpcUaSeverity.ShouldBe(500);
observed[0].RawMxAccessSeverity.ShouldBe(750);
// Acknowledged snapshot entry → ActiveAcknowledged (#656 — an Acknowledge kept the host's prior Active, so a
@@ -152,7 +153,7 @@ public sealed class GatewayGalaxyAlarmFeedTests
// Only the well-formed Raise survives; the Unspecified + empty messages drop.
observed.ShouldHaveSingleItem();
observed[0].TransitionKind.ShouldBe(GalaxyAlarmTransitionKind.Raise);
observed[0].SeverityBucket.ShouldBe(AlarmSeverity.High);
observed[0].SeverityBucket.ShouldBe(AlarmSeverity.Medium); // priority 600: AVEVA Medium 501–750 (#887)
}
/// <summary>Verifies that the feed reopens the stream after a transport fault.</summary>
@@ -6,43 +6,61 @@ using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
/// <summary>
/// Pins the four-bucket MxAccess severity → (AlarmSeverity, OPC UA numeric) ladder.
/// Customers see no surprise re-classification when the v2 path takes over from
/// v1's sub-attribute synthesis: the bucket boundaries match v1's
/// <c>GalaxyAlarmTracker</c> per <c>docs/v1/AlarmTracking.md</c>.
/// Pins the AVEVA priority → (AlarmSeverity, OPC UA numeric) table the live Galaxy alarm feed uses (lmxopcua#887).
/// mxaccessgw sends the AVEVA priority (1 = most urgent … 999 = least) as the alarm's raw severity; the table is
/// AVEVA's default four ranges, most urgent first.
/// </summary>
public sealed class MxAccessSeverityMapperTests
{
/// <summary>Verifies that Map assigns the expected severity bucket and OPC UA severity value.</summary>
/// <param name="rawMxAccessSeverity">The raw MxAccess severity integer to map.</param>
/// <summary>Verifies the band table at every edge, plus the out-of-range clamps.</summary>
/// <param name="rawPriority">The raw value the gateway sends: the AVEVA priority.</param>
/// <param name="expectedBucket">The expected AlarmSeverity bucket.</param>
/// <param name="expectedOpcUaSeverity">The expected OPC UA numeric severity value.</param>
[Theory]
[InlineData(0, AlarmSeverity.Low, MxAccessSeverityMapper.OpcUaSeverityLow)]
[InlineData(1, AlarmSeverity.Low, MxAccessSeverityMapper.OpcUaSeverityLow)]
[InlineData(249, AlarmSeverity.Low, MxAccessSeverityMapper.OpcUaSeverityLow)]
[InlineData(250, AlarmSeverity.Medium, MxAccessSeverityMapper.OpcUaSeverityMedium)]
[InlineData(499, AlarmSeverity.Medium, MxAccessSeverityMapper.OpcUaSeverityMedium)]
[InlineData(500, AlarmSeverity.High, MxAccessSeverityMapper.OpcUaSeverityHigh)]
[InlineData(749, AlarmSeverity.High, MxAccessSeverityMapper.OpcUaSeverityHigh)]
[InlineData(750, AlarmSeverity.Critical, MxAccessSeverityMapper.OpcUaSeverityCritical)]
[InlineData(999, AlarmSeverity.Critical, MxAccessSeverityMapper.OpcUaSeverityCritical)]
[InlineData(int.MaxValue, AlarmSeverity.Critical, MxAccessSeverityMapper.OpcUaSeverityCritical)]
public void Map_assigns_expected_bucket(int rawMxAccessSeverity, AlarmSeverity expectedBucket, int expectedOpcUaSeverity)
[InlineData(1, AlarmSeverity.Critical, 1000)] // AVEVA Critical 1–250
[InlineData(250, AlarmSeverity.Critical, 1000)]
[InlineData(251, AlarmSeverity.High, 750)] // AVEVA High 251–500
[InlineData(500, AlarmSeverity.High, 750)]
[InlineData(501, AlarmSeverity.Medium, 500)] // AVEVA Medium 501–750
[InlineData(750, AlarmSeverity.Medium, 500)]
[InlineData(751, AlarmSeverity.Low, 250)] // AVEVA Low 751–999
[InlineData(999, AlarmSeverity.Low, 250)]
[InlineData(0, AlarmSeverity.Critical, 1000)] // below range: clamped to priority 1
[InlineData(-100, AlarmSeverity.Critical, 1000)]
[InlineData(int.MinValue, AlarmSeverity.Critical, 1000)]
[InlineData(1000, AlarmSeverity.Low, 250)] // above range: clamped to priority 999
[InlineData(int.MaxValue, AlarmSeverity.Low, 250)]
public void Map_assigns_the_AVEVA_priority_band(int rawPriority, AlarmSeverity expectedBucket, int expectedOpcUaSeverity)
{
var (bucket, opcUa) = MxAccessSeverityMapper.Map(rawMxAccessSeverity);
var (bucket, opcUa) = MxAccessSeverityMapper.Map(rawPriority);
bucket.ShouldBe(expectedBucket);
opcUa.ShouldBe(expectedOpcUaSeverity);
}
/// <summary>Verifies that Map clamps negative severities into the low bucket.</summary>
/// <summary>
/// Verifies the mapping is monotonic decreasing over the whole AVEVA range: a more urgent (lower) priority
/// never yields a lower OPC UA severity, and the two ends differ (so a constant mapping fails too).
/// </summary>
[Fact]
public void Map_clamps_negative_severities_into_low_bucket()
public void Map_never_ranks_a_less_urgent_priority_above_a_more_urgent_one()
{
var (bucket, opcUa) = MxAccessSeverityMapper.Map(-100);
var previous = MxAccessSeverityMapper.Map(1).OpcUaSeverity;
for (var priority = 2; priority <= 999; priority++)
{
var current = MxAccessSeverityMapper.Map(priority).OpcUaSeverity;
current.ShouldBeLessThanOrEqualTo(previous, $"priority {priority} must not be more severe than {priority - 1}");
previous = current;
}
bucket.ShouldBe(AlarmSeverity.Low);
opcUa.ShouldBe(MxAccessSeverityMapper.OpcUaSeverityLow);
MxAccessSeverityMapper.Map(1).OpcUaSeverity.ShouldBeGreaterThan(MxAccessSeverityMapper.Map(999).OpcUaSeverity);
}
/// <summary>Verifies the live mapper is the shared table history uses (Gitea #886), value for value.</summary>
[Fact]
public void Map_is_the_shared_GalaxyAlarmPriority_table()
{
for (var priority = -2; priority <= 1001; priority++)
MxAccessSeverityMapper.Map(priority).ShouldBe(GalaxyAlarmPriority.Map(priority));
}
}
@@ -43,7 +43,8 @@ public sealed class EventMapperTests
}
[Theory]
[InlineData("Priority", "999", 1000)] // a Galaxy priority maps through the live table (#886), not clamped as a severity
[InlineData("Priority", "999", 250)] // a Galaxy priority maps through the live table (#886/#887: 999 = least urgent), not clamped as a severity
[InlineData("Priority", "1", 1000)] // AVEVA priority 1 is the most urgent (#887)
[InlineData("Severity", "0", 1)] // clamp to OPC UA min 1
[InlineData("Severity", "5000", 1000)] // clamp to OPC UA max 1000
[InlineData(null, null, 1)] // missing → default min severity
@@ -88,15 +89,20 @@ public sealed class EventMapperTests
Assert.Equal("Oven_007.Temp", h.SourceName);
}
// lmxopcua#887: AVEVA's default priority bands, most urgent first. NativeAlarmRestatementEndToEndTests
// (Galaxy_live_condition_severity_is_the_AVEVA_priority_band) pins the same rows on the live path.
[Theory]
[InlineData("1", 250)]
[InlineData("249", 250)]
[InlineData("250", 500)]
[InlineData("499", 500)]
[InlineData("1", 1000)] // Critical 1–250
[InlineData("250", 1000)]
[InlineData("251", 750)] // High 251–500 (vd03: priority 500 rows carry AVEVA severity 2)
[InlineData("500", 750)]
[InlineData("749", 750)]
[InlineData("750", 1000)]
[InlineData("999", 1000)] // a System.Stop/Start row (priority 999, AVEVA severity 0) — lmxopcua#887 tracks the direction
[InlineData("501", 500)] // Medium 501–750
[InlineData("750", 500)]
[InlineData("751", 250)] // Low 751–999
[InlineData("999", 250)] // a priority-999 ALARM (AVEVA severity 4); a system event at 999 is the next test
[InlineData("0", 1000)] // out of range: clamped to 1
[InlineData("-5", 1000)]
[InlineData("1000", 250)] // out of range: clamped to 999
public void An_aveva_priority_maps_through_the_live_galaxy_table(string priority, int expected)
{
var h = EventMapper.ToHistoricalEvent(AvevaAlarmRow(priority));
@@ -141,6 +147,24 @@ public sealed class EventMapperTests
Assert.Null(h.ConditionReference);
Assert.Null(h.AlarmId);
Assert.Equal("System.Stop", h.Message);
// lmxopcua#887: AVEVA severity 0 = not an alarm — the lowest OPC UA Severity, below every alarm band.
Assert.Equal((ushort)1, h.Severity);
}
[Theory]
[InlineData("System.Start", "999", "0", 1)] // not an alarm (vd03: System.*, User.Write rows are priority 999 / severity 0)
[InlineData("User.Write", "999", "0", 1)]
[InlineData("Alarm.Set", "999", "4", 250)] // positive control: a priority-999 ALARM is Low, not "not an alarm"
[InlineData("Alarm.Set", "999", null, 250)] // no AVEVA severity: the priority alone decides
[InlineData("Alarm.Set", "100", "1", 1000)] // vd03: TableAlarms_*.CommunicationAlarm, priority 100 / severity 1
public void Aveva_severity_zero_marks_an_event_that_is_not_an_alarm(string type, string priority, string? avevaSeverity, int expected)
{
var e = new HistorianEvent { Id = "S", Type = type, SourceName = "Oven_007",
EventTime = Ts(2026, 11, 10, 11, 32, 35), ReceivedTime = Ts(2026, 11, 10, 11, 32, 35) };
e.Properties["priority"] = priority;
if (avevaSeverity is not null) e.Properties["severity"] = avevaSeverity;
Assert.Equal((ushort)expected, EventMapper.ToHistoricalEvent(e).Severity);
}
// Ts(...) builds a Google.Protobuf.WellKnownTypes.Timestamp from UTC parts.
@@ -100,11 +100,12 @@ public sealed class GalaxyEquipmentEventHistoryWireTests
// A system event has no condition at all.
byId["o-stop"].SourceNode.ShouldBeNull();
byId["o-stop"].ConditionName.ShouldBeNull();
// Severity follows the live Galaxy table (priority 500 → 750, 700 → 750, 100 → 250, 999 → 1000), not Severity 1.
// Severity follows the live Galaxy table, AVEVA priority 1 = most urgent (lmxopcua#887): 500 → 750 (High),
// 700 → 500 (Medium), 100 → 1000 (Critical); a system event (priority 999, AVEVA severity 0) is not an alarm → 1.
byId["o-temp-set"].Severity.ShouldBe((ushort)750);
byId["d-set"].Severity.ShouldBe((ushort)750);
byId["o-press-set"].Severity.ShouldBe((ushort)250);
byId["o-stop"].Severity.ShouldBe((ushort)1000);
byId["d-set"].Severity.ShouldBe((ushort)500);
byId["o-press-set"].Severity.ShouldBe((ushort)1000);
byId["o-stop"].Severity.ShouldBe((ushort)1);
// The raw device folder the conditions hang under reads the same objects (live: a condition fires on its parent).
var raw = await ReadAsync(session, Raw(session, OvenFolder), forward: true, page: 0, ct);
@@ -292,7 +293,10 @@ public sealed class GalaxyEquipmentEventHistoryWireTests
e.Properties["source_object"] = s.Object;
e.Properties["source_processvariable"] = s.Source;
e.Properties["priority"] = s.Priority.ToString(System.Globalization.CultureInfo.InvariantCulture);
e.Properties["severity"] = s.ConditionVariable is null ? "0" : "2"; // AVEVA's own scale: never used
// AVEVA's own scale, as vd03 stores it: 0 for an event that is not an alarm, else the priority's 1–4 band.
// Only the 0 is read (not an alarm → Severity 1); an alarm's value comes from its priority.
e.Properties["severity"] = s.ConditionVariable is null ? "0"
: (s.Priority <= 250 ? "1" : s.Priority <= 500 ? "2" : s.Priority <= 750 ? "3" : "4");
if (s.ConditionVariable is not null)
{
e.Properties["source_conditionvariable"] = s.ConditionVariable;
@@ -150,6 +150,43 @@ public sealed class NativeAlarmRestatementEndToEndTests : RuntimeActorTestBase
}
}
/// <summary>
/// lmxopcua#887 — the live Galaxy condition's OPC UA Severity is AVEVA's priority band, most urgent first: the
/// REAL feed → REAL driver → host → projector path carries <see cref="GalaxyAlarmPriority"/>'s exact value, the
/// same table AVEVA history rows map their <c>priority</c> through (<c>EventMapperTests</c> pins the same rows),
/// so a condition's live event and its history row agree. Before the fix priority 1 read 200 and 999 read 900
/// (inverted, and re-mapped through the host's generic bucket ladder).
/// </summary>
/// <param name="priority">The AVEVA alarm priority (1 = most urgent).</param>
/// <param name="expected">The OPC UA Severity the live condition must carry.</param>
[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)]
public async Task Galaxy_live_condition_severity_is_the_AVEVA_priority_band(int priority, int expected)
{
var rig = await GalaxyRig.StartAsync(this);
try
{
rig.Send(Live(Proto.AlarmTransitionKind.Raise, new DateTime(2026, 11, 12, 9, 0, 0, DateTimeKind.Utc), priority));
var update = NextAlarmUpdate(rig.Publish);
update.State.Active.ShouldBeTrue();
update.State.Severity.ShouldBe((ushort)expected);
update.State.Severity.ShouldBe((ushort)GalaxyAlarmPriority.Map(priority).OpcUaSeverity,
"live must use the table history uses");
}
finally
{
await rig.DisposeAsync();
}
}
private static Proto.AlarmFeedMessage Snapshot(
Proto.AlarmConditionState state, DateTime raisedAt, DateTime lastTransition, string user = "", string comment = "")
=> new()
@@ -170,7 +207,7 @@ public sealed class NativeAlarmRestatementEndToEndTests : RuntimeActorTestBase
},
};
private static Proto.AlarmFeedMessage Live(Proto.AlarmTransitionKind kind, DateTime at)
private static Proto.AlarmFeedMessage Live(Proto.AlarmTransitionKind kind, DateTime at, int priority = 750)
=> new()
{
Transition = new Proto.OnAlarmTransitionEvent
@@ -179,7 +216,7 @@ public sealed class NativeAlarmRestatementEndToEndTests : RuntimeActorTestBase
SourceObjectReference = "Tank01",
AlarmTypeName = "AnalogLimitAlarm.HiHi",
TransitionKind = kind,
Severity = 750,
Severity = priority, // mxaccessgw sends the AVEVA priority in this field
Category = "Process",
Description = "Tank 01 high-high level",
TransitionTimestamp = Timestamp.FromDateTime(at),