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
@@ -2793,6 +2793,10 @@ public sealed class GalaxyDriver
AlarmType: transition.AlarmTypeName,
Message: transition.Description,
Severity: transition.SeverityBucket,
// lmxopcua#887: the exact Part 9 severity from the priority table, so the live condition reads the same
// number as its AVEVA history row (EventMapper → GalaxyAlarmPriority). Without it the host would re-map the
// bucket through its generic 200/500/700/900 ladder and live and history would disagree.
OpcUaSeverity: transition.OpcUaSeverity,
SourceTimestampUtc: transition.TransitionTimestampUtc,
OperatorComment: string.IsNullOrEmpty(transition.OperatorComment) ? null : transition.OperatorComment,
OriginalRaiseTimestampUtc: transition.OriginalRaiseTimestampUtc,
@@ -3,40 +3,38 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
/// <summary>
/// Maps a raw MXAccess alarm severity (0-999, MXAccess scale) onto the
/// Maps the raw value the gateway sends as an alarm's <c>severity</c> onto the
/// <see cref="AlarmSeverity"/> ladder + an OPC UA Part 9 numeric severity (1-1000).
/// </summary>
/// <remarks>
/// <para>
/// The four-bucket OPC UA ladder (250 / 500 / 750 / 1000 — Low / Medium / High /
/// Critical) is the same ladder v1's <c>GalaxyAlarmTracker</c> exposed (per
/// <c>docs/v1/AlarmTracking.md</c>). Galaxy templates assign severity values
/// 0-999; the bucket boundaries below match v1 so customers see no
/// surprise re-classification when the v2 path takes over.
/// mxaccessgw fills that field from the alarm record's AVEVA <b>priority</b> (1-999, 1 = most urgent), not from
/// AVEVA's own 1-4 severity, which the live feed never carries. The table is
/// <see cref="GalaxyAlarmPriority"/>, AVEVA's default priority ranges (1-250 Critical → 1000, 251-500 High → 750,
/// 501-750 Medium → 500, 751-999 Low → 250; lmxopcua#887), the same one AVEVA alarm history maps its
/// <c>priority</c> through (Gitea #886), so a condition's live event and its history row agree.
/// </para>
/// <para>
/// Out-of-range inputs (negative or &gt;= 1000) are clamped into the nearest
/// bucket rather than rejected. MXAccess occasionally surfaces slightly
/// out-of-range severities for legacy alarm types and we want them to flow
/// through the alarm path rather than disappear at the mapper.
/// Out-of-range inputs are clamped into 1-999 rather than rejected, so a legacy alarm type with a slightly
/// out-of-range value still flows through the alarm path.
/// </para>
/// </remarks>
internal static class MxAccessSeverityMapper
{
/// <summary>OPC UA Part 9 numeric severity for the Low bucket (0-249 MxAccess).</summary>
/// <summary>OPC UA Part 9 numeric severity for the Low bucket (priority 751-999).</summary>
public const int OpcUaSeverityLow = GalaxyAlarmPriority.OpcUaSeverityLow;
/// <summary>OPC UA Part 9 numeric severity for the Medium bucket (250-499 MxAccess).</summary>
/// <summary>OPC UA Part 9 numeric severity for the Medium bucket (priority 501-750).</summary>
public const int OpcUaSeverityMedium = GalaxyAlarmPriority.OpcUaSeverityMedium;
/// <summary>OPC UA Part 9 numeric severity for the High bucket (500-749 MxAccess).</summary>
/// <summary>OPC UA Part 9 numeric severity for the High bucket (priority 251-500).</summary>
public const int OpcUaSeverityHigh = GalaxyAlarmPriority.OpcUaSeverityHigh;
/// <summary>OPC UA Part 9 numeric severity for the Critical bucket (750+ MxAccess).</summary>
/// <summary>OPC UA Part 9 numeric severity for the Critical bucket (priority 1-250).</summary>
public const int OpcUaSeverityCritical = GalaxyAlarmPriority.OpcUaSeverityCritical;
/// <summary>
/// Translate a raw MXAccess severity into the four-bucket
/// Translate a raw gateway severity (the AVEVA priority) into the four-bucket
/// <see cref="AlarmSeverity"/> + OPC UA Part 9 numeric severity tuple.
/// </summary>
/// <param name="rawMxAccessSeverity">The raw MXAccess severity value (0-999 range, clamped if out of range).</param>
/// <param name="rawMxAccessSeverity">The gateway's raw value: the AVEVA priority (1-999, clamped if out of range).</param>
/// <returns>The mapped four-bucket <see cref="AlarmSeverity"/> and its OPC UA Part 9 numeric severity.</returns>
// One table for live and history (Gitea #886): GalaxyAlarmPriority, which AVEVA history rows map their priority through.
public static (AlarmSeverity Bucket, int OpcUaSeverity) Map(int rawMxAccessSeverity) => GalaxyAlarmPriority.Map(rawMxAccessSeverity);
@@ -16,10 +16,13 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Mapping;
/// <c>alarmname</c> / <c>alarmid</c> / <c>severity</c> / <c>message</c>.
/// </para>
/// <para>
/// Severity: an AVEVA row's <c>priority</c> maps through <see cref="GalaxyAlarmPriority"/>, the table live Galaxy alarms
/// use, so a row and its live event agree. AVEVA's own <c>severity</c> on such a row (1–4, 0 for a system event) is not on
/// the OPC UA scale and is ignored. A row with no <c>priority</c> (OtOpcUa's own) takes <c>severity</c>, clamped to
/// 1–1000.
/// Severity: an AVEVA alarm row's <c>priority</c> (1 = most urgent) maps through <see cref="GalaxyAlarmPriority"/>, the
/// table live Galaxy alarms use, so a row and its live event agree (1–250 → 1000, 251–500 → 750, 501–750 → 500,
/// 751–999 → 250; lmxopcua#887). AVEVA's own <c>severity</c> on such a row (1–4) is not on the OPC UA scale and is not used
/// for the value, since the live feed cannot see it. Its one use: <c>severity</c> 0 marks an AVEVA event that is not an
/// alarm (System.Start/Stop/Deploy, User.Write, …, all priority 999), which reads
/// <see cref="GalaxyAlarmPriority.OpcUaSeverityNotAnAlarm"/> (1), below every alarm. A row with no <c>priority</c>
/// (OtOpcUa's own) takes <c>severity</c>, clamped to 1–1000.
/// </para>
/// </remarks>
internal static class EventMapper
@@ -89,13 +92,19 @@ internal static class EventMapper
}
/// <summary>
/// The OPC UA severity: an AVEVA row's <c>priority</c> through the live Galaxy table, else the <c>Severity</c> property
/// clamped to <c>[1, 1000]</c>. Missing or unparseable values default to the minimum severity (1).
/// The OPC UA severity: an AVEVA row's <c>priority</c> through the live Galaxy table (or 1 when AVEVA's own
/// <c>severity</c> is 0, an event that is not an alarm), else the <c>Severity</c> property clamped to
/// <c>[1, 1000]</c>. Missing or unparseable values default to the minimum severity (1).
/// </summary>
private static ushort ParseSeverity(IDictionary<string, string> properties)
{
if (TryParseInt(Property(properties, "Priority"), out var priority))
{
// AVEVA severity 0 = not an alarm (System.*, User.Write): no live counterpart, lowest severity.
if (TryParseInt(Property(properties, "Severity"), out var avevaSeverity) && avevaSeverity == 0)
return (ushort)GalaxyAlarmPriority.OpcUaSeverityNotAnAlarm;
return (ushort)GalaxyAlarmPriority.Map(priority).OpcUaSeverity;
}
if (TryParseInt(Property(properties, "Severity"), out var value))
return (ushort)Math.Clamp(value, MinSeverity, MaxSeverity);