diff --git a/docs/AlarmTracking.md b/docs/AlarmTracking.md
index 6837ac6e3..7b8743be3 100644
--- a/docs/AlarmTracking.md
+++ b/docs/AlarmTracking.md
@@ -387,10 +387,14 @@ The proto contract carries the rich payload — alarm full reference,
source-object reference, alarm-type-name, transition kind (Raise /
Acknowledge / Clear / Retrigger), severity (raw MxAccess scale),
original raise timestamp, transition timestamp, operator user,
-operator comment, alarm category, description. `MxAccessSeverityMapper`
-(PR B.1) translates the raw severity onto the four-bucket
-`AlarmSeverity` ladder — boundaries match v1's `GalaxyAlarmTracker`
-so customers see no surprise re-classification.
+operator comment, alarm category, description. The raw severity is the
+AVEVA alarm **priority** (1 = most urgent … 999). `MxAccessSeverityMapper`
+maps it through `GalaxyAlarmPriority`, AVEVA's default ranges (1–250 →
+Critical 1000, 251–500 → High 750, 501–750 → Medium 500, 751–999 → Low 250;
+lmxopcua#887, which reversed the earlier higher-is-worse ladder), and the
+driver passes that exact value as `AlarmEventArgs.OpcUaSeverity`, so a live
+condition and its AVEVA history row (`docs/Historian.md`) carry the same
+Severity.
The richer fields surface on `Core.Abstractions.AlarmEventArgs` via
the optional properties added in PR E.7 (`OperatorComment`,
diff --git a/docs/Historian.md b/docs/Historian.md
index 78df197e9..8083caf25 100644
--- a/docs/Historian.md
+++ b/docs/Historian.md
@@ -698,13 +698,38 @@ source is read (fail closed).
| Field | Source | Notes |
|---|---|---|
-| `Severity` | `priority` (AVEVA native rows) through `GalaxyAlarmPriority`, the live Galaxy mapping (`< 250` → 250, `< 500` → 500, `< 750` → 750, else 1000), else `severity` clamped to 1–1000 (OtOpcUa's own rows), else 1 | AVEVA's own `severity` (1–4, or 0 for a system event) is not on the OPC UA scale and is never used for a row that has `priority`. ⚠️ AVEVA ranks priority 1 as the most urgent and 999 as the least; the live mapping treats a higher number as more severe. History follows live so the two agree (lmxopcua#887 tracks the inversion). |
+| `Severity` | `priority` (AVEVA native alarm rows) through `GalaxyAlarmPriority`, the live Galaxy mapping (see [Galaxy alarm priority → Severity](#galaxy-alarm-priority--severity-lmxopcua887)); a row whose AVEVA `severity` is 0 (an event that is not an alarm: `System.*`, `User.Write`) reads 1; else `severity` clamped to 1–1000 (OtOpcUa's own rows), else 1 | AVEVA's own `severity` (1–4) is not on the OPC UA scale and is never the value of an alarm row: the live feed carries only the priority, so using it would let history and live disagree. History and live use the same table. |
| `ConditionName` | The live condition's name when the row's `source_conditionvariable` names a native condition that fires on this folder; else `alarmname` (OtOpcUa's own rows); else the attribute part of `source_conditionvariable` (`TempHiAlarm`) | Live parity: a resolved row shows the same ConditionName as the live condition. |
| `SourceNode` | The native condition whose `attributeRef` equals the row's `source_conditionvariable` (case-insensitive, as the Galaxy driver matches it), among the conditions that fire on this folder; else the condition `alarmid` names (OtOpcUa's own rows) | A condition that does not fire on this folder is never named, so history discloses no node outside the folder's live event set. Object-level system events have none. |
| `Message` | `message` (OtOpcUa's own rows), else the event type (`Alarm.Set`, `System.Stop`) | Unchanged apart from case. |
| `AlarmId` (internal) | `alarmid`, else `alarm_id` | Used only to resolve `SourceNode`. AVEVA's `alarm_id` identifies one alarm occurrence (shared by its Set and Clear), not a condition, so it is never a ConditionName. |
+##### Galaxy alarm priority → Severity (lmxopcua#887)
+
+AVEVA alarm priority runs 1–999 and **1 is the most urgent**. AVEVA groups priorities into four severities; OtOpcUa uses
+AVEVA's default ranges, for live Galaxy alarms (`MxAccessSeverityMapper`, which reads the priority mxaccessgw sends as the
+alarm's `severity`) and for AVEVA history rows (`EventMapper`) alike, through the one table `GalaxyAlarmPriority`:
+
+| 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 |
+| any (history only; AVEVA `severity` 0: `System.*`, `User.Write`) | 0 (not an alarm) | – | 1 |
+
+- Out-of-range priorities clamp: 0 or below reads Critical, above 999 reads Low.
+- The live condition carries the table's exact value (the Galaxy driver sets `AlarmEventArgs.OpcUaSeverity`); before #887 it
+ carried only the bucket, which the host re-mapped through its generic 200/500/700/900 ladder.
+- Evidence (vd03 `Runtime.dbo.Events`, read-only SQL, 2026-11-12): alarms at priority 1 and 100 carry AVEVA severity 1,
+ 400 and 500 severity 2, 600 and 700 severity 3, and every priority-999 `System.*` / `User.*` row severity 0 with
+ `IsAlarm` 0. vd03 has no alarm in 751–999, so the Low edge rests on AVEVA's documented default (751–999).
+- A Galaxy can reconfigure AVEVA's ranges. The live feed carries no AVEVA severity, so OtOpcUa assumes the defaults on both
+ paths: live and history always agree, but on a reconfigured Galaxy the bucket can differ from AVEVA's own.
+- **Before #887 the table ran the other way** (`< 250` → 250 … `≥ 750` → 1000): priority 1 read Low, 999 read Critical, and
+ #886's history rows followed it. Every OPC UA client that ranks or filters Galaxy alarms by Severity sees the change.
+
Event-field projection supports the standard
`BaseEventType` select clauses — `EventId`, `SourceName`, `Time`, `ReceiveTime`, `Message`,
`Severity` — plus (client-visible: these used to be null):
diff --git a/docs/plans/evidence/2026-11-10-opcua2/aveva-history-801.md b/docs/plans/evidence/2026-11-10-opcua2/aveva-history-801.md
index d5d90dd39..267d2b5b1 100644
--- a/docs/plans/evidence/2026-11-10-opcua2/aveva-history-801.md
+++ b/docs/plans/evidence/2026-11-10-opcua2/aveva-history-801.md
@@ -108,7 +108,7 @@ each under its own raw device folder.
| Paging | 1-event pages (92 pages) and 7-event pages (14 pages) equal the whole read, row for row. |
| Reverse | Whole and 5-event pages (19 pages): newest first, ties by EventId descending, equal to the expected order. |
| SourceNode / ConditionName | **73 rows** resolve to their live condition: 70 `CPULoadAvg` alarm rows → `Plant/Galaxy/DevWinPlatform/CPULoadHiAlarm` / `CPULoadHiAlarm`, 3 `FRR_Warning` rows → `Plant/Galaxy/Left_001/FRR_Warning` / `FRR_Warning`. Every other row (system and user events) has a null SourceNode. |
-| Severity | The wire equals the backend's mapping for every row. Alarm rows (`priority` 500 / 700) read **750**; priority-999 system and user events read **1000** (live Galaxy table; see lmxopcua#887 on its direction). Before: 1 for every row. |
+| Severity | The wire equals the backend's mapping for every row. Alarm rows (`priority` 500 / 700) read **750**; priority-999 system and user events read **1000** (live Galaxy table, then still inverted; lmxopcua#887 reversed it, see "#887 re-run" below). Before: 1 for every row. |
`diff_runs.py` against `aveva-gate-sdk2-882.json.gz`: 226 shared cases, **221 identical**. The 5 that differ are expected:
the 4 `DevWinPlatform` event cases (same 72 events, ids, times, sources and messages; only Severity changed from 1 to
@@ -118,6 +118,27 @@ the 4 `DevWinPlatform` event cases (same 72 events, ids, times, sources and mess
Report: [`aveva-history-801/aveva-gate-sdk2-886.json.gz`](aveva-history-801/aveva-gate-sdk2-886.json.gz), sha256 of the
JSON `6ca314ee…c890` (label `sdk2-886-da948e5f-wt`: the #886 working tree on top of `da948e5f`).
+## #887 re-run (2026-11-12)
+
+The same harness after lmxopcua#887 reversed the Galaxy priority table (AVEVA priority 1 = most urgent: 1–250 → 1000,
+251–500 → 750, 501–750 → 500, 751–999 → 250; AVEVA severity 0, an event that is not an alarm → 1). Same vd03
+HistorianGateway `ea9f21c`, same window, read-only, network-only, **no WinRM**. The API key was read over SMB from the
+`OtOpcUaHost.reg` export in vd03's `OtOpcUa-backups\pre-20261112-otopcua-5fb4b4db`, kept in a 0600 file, passed by
+environment and never printed; the local copies were deleted afterwards.
+
+`diff_runs.py` against `aveva-gate-sdk2-886.json.gz`: 233 cases, **224 identical**, harness failures 0. The 9 that differ
+are the 4 `DevWinPlatform` event cases and the 5 `events886.*` wire cases, and **only Severity changed**: same events,
+ids, times, sources and messages in every case. On `EQ-886-ZipsenLeft` (92 events):
+
+| Rows | Severity #886 → #887 |
+|---|---|
+| 70 `DevWinPlatform.CPULoadAvg` alarm rows (priority 500, AVEVA High) | 750 → 750 |
+| 3 `Left_001` `FRR_Warning` alarm rows (priority 700, AVEVA Medium) | 750 → **500** |
+| 19 system and user events (`System.Start/Stop/Deploy/Undeploy`, `User.Write[.Secured]`; priority 999, AVEVA severity 0) | 1000 → **1** |
+
+Report: [`aveva-history-801/aveva-gate-sdk2-887.json.gz`](aveva-history-801/aveva-gate-sdk2-887.json.gz), sha256 of the
+JSON `232f3ee6…5545` (label `sdk2-887-wt`: the #887 working tree on top of `3a312ffd`).
+
## Files
- [`aveva-history-801/aveva-gate-sdk2-d46044f5.json.gz`](aveva-history-801/aveva-gate-sdk2-d46044f5.json.gz), sha256 of the JSON `00b5ecb6…fc27`.
diff --git a/docs/plans/evidence/2026-11-10-opcua2/aveva-history-801/aveva-gate-sdk2-887.json.gz b/docs/plans/evidence/2026-11-10-opcua2/aveva-history-801/aveva-gate-sdk2-887.json.gz
new file mode 100644
index 000000000..777ccd843
Binary files /dev/null and b/docs/plans/evidence/2026-11-10-opcua2/aveva-history-801/aveva-gate-sdk2-887.json.gz differ
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/GalaxyAlarmPriority.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/GalaxyAlarmPriority.cs
index b475e14dd..e0ac80a0f 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/GalaxyAlarmPriority.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/GalaxyAlarmPriority.cs
@@ -5,33 +5,71 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// MxAccessSeverityMapper) and AVEVA alarm history (priority, Gitea #886) so the two agree.
///
///
-/// The four-bucket ladder (250 / 500 / 750 / 1000 — Low / Medium / High / Critical) is the one v1's
-/// GalaxyAlarmTracker exposed; out-of-range inputs clamp into the nearest bucket. ⚠️ It treats a higher number
-/// as more severe, while AVEVA ranks priority 1 as the most urgent: lmxopcua#887 tracks that. Change it here only, so
-/// live and history move together.
+///
+/// AVEVA System Platform alarm priority runs 1–999 and 1 is the most urgent. AVEVA groups priorities into
+/// four severities with these default ranges, and OtOpcUa uses the same boundaries (lmxopcua#887):
+///
+///
+/// AVEVA priorityAVEVA severity → bucket, OPC UA Severity
+/// - 1–2501 Critical → , 1000
+/// - 251–5002 High → , 750
+/// - 501–7503 Medium → , 500
+/// - 751–9994 Low → , 250
+///
+///
+/// The vd03 A&E history agrees: alarms at priority 1/100 carry AVEVA severity 1, 400/500 severity 2, 600/700
+/// severity 3 (docs/Historian.md, "Galaxy alarm priority → Severity").
+/// A priority outside 1–999 is clamped into it first, so 0 or a negative reads as Critical and anything above 999
+/// as Low. The mapping is monotonic: a lower priority number never yields a lower OPC UA Severity.
+///
+///
+/// AVEVA lets a Galaxy reconfigure the four ranges. The live alarm feed carries only the priority, never AVEVA's
+/// severity, so this table assumes the defaults on both paths; live and history therefore always agree, even on a
+/// Galaxy whose ranges were changed.
+///
+///
+/// Before lmxopcua#887 the table ran the other way (a higher number was more severe), so a priority-1 alarm read
+/// 250 and a priority-999 one 1000. Change the table here only, so live and history move together.
+///
///
public static class GalaxyAlarmPriority
{
- /// OPC UA severity of the Low bucket (raw < 250).
+ /// The last priority of AVEVA's default Critical range (1–250).
+ public const int CriticalMaxPriority = 250;
+
+ /// The last priority of AVEVA's default High range (251–500).
+ public const int HighMaxPriority = 500;
+
+ /// The last priority of AVEVA's default Medium range (501–750); 751–999 is Low.
+ public const int MediumMaxPriority = 750;
+
+ /// OPC UA severity of the Low bucket (priority 751–999).
public const int OpcUaSeverityLow = 250;
- /// OPC UA severity of the Medium bucket (raw 250–499).
+ /// OPC UA severity of the Medium bucket (priority 501–750).
public const int OpcUaSeverityMedium = 500;
- /// OPC UA severity of the High bucket (raw 500–749).
+ /// OPC UA severity of the High bucket (priority 251–500).
public const int OpcUaSeverityHigh = 750;
- /// OPC UA severity of the Critical bucket (raw 750 and above).
+ /// OPC UA severity of the Critical bucket (priority 1–250).
public const int OpcUaSeverityCritical = 1000;
- /// Maps a raw Galaxy priority onto the bucket and its OPC UA severity.
- /// The raw value (0–999; clamped).
- /// The bucket and its OPC UA Part 9 numeric severity.
+ ///
+ /// OPC UA severity of an AVEVA event that is not an alarm (System.Start/Stop/Deploy, User.Write, …: priority 999,
+ /// AVEVA severity 0). It sits below every alarm bucket. Only history has such rows; the live feed carries alarms.
+ ///
+ public const int OpcUaSeverityNotAnAlarm = 1;
+
+ /// Maps a Galaxy alarm priority onto the bucket and its OPC UA severity.
+ /// The AVEVA priority (1 = most urgent … 999 = least; clamped into 1–999).
+ /// The bucket and its OPC UA Part 9 numeric severity (1000 = most severe).
public static (AlarmSeverity Bucket, int OpcUaSeverity) Map(int rawPriority) => rawPriority switch
{
- < 250 => (AlarmSeverity.Low, OpcUaSeverityLow),
- < 500 => (AlarmSeverity.Medium, OpcUaSeverityMedium),
- < 750 => (AlarmSeverity.High, OpcUaSeverityHigh),
- _ => (AlarmSeverity.Critical, OpcUaSeverityCritical),
+ // The first arm also takes 0 and negatives, the last anything above 999: the clamp into 1–999.
+ <= CriticalMaxPriority => (AlarmSeverity.Critical, OpcUaSeverityCritical),
+ <= HighMaxPriority => (AlarmSeverity.High, OpcUaSeverityHigh),
+ <= MediumMaxPriority => (AlarmSeverity.Medium, OpcUaSeverityMedium),
+ _ => (AlarmSeverity.Low, OpcUaSeverityLow),
};
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs
index 277f73a0e..6c4756ded 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs
@@ -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,
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/MxAccessSeverityMapper.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/MxAccessSeverityMapper.cs
index ace46a375..b1714b292 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/MxAccessSeverityMapper.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/MxAccessSeverityMapper.cs
@@ -3,40 +3,38 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
///
-/// Maps a raw MXAccess alarm severity (0-999, MXAccess scale) onto the
+/// Maps the raw value the gateway sends as an alarm's severity onto the
/// ladder + an OPC UA Part 9 numeric severity (1-1000).
///
///
///
-/// The four-bucket OPC UA ladder (250 / 500 / 750 / 1000 — Low / Medium / High /
-/// Critical) is the same ladder v1's GalaxyAlarmTracker exposed (per
-/// docs/v1/AlarmTracking.md). 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 priority (1-999, 1 = most urgent), not from
+/// AVEVA's own 1-4 severity, which the live feed never carries. The table is
+/// , 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
+/// priority through (Gitea #886), so a condition's live event and its history row agree.
///
///
-/// Out-of-range inputs (negative or >= 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.
///
///
internal static class MxAccessSeverityMapper
{
- /// OPC UA Part 9 numeric severity for the Low bucket (0-249 MxAccess).
+ /// OPC UA Part 9 numeric severity for the Low bucket (priority 751-999).
public const int OpcUaSeverityLow = GalaxyAlarmPriority.OpcUaSeverityLow;
- /// OPC UA Part 9 numeric severity for the Medium bucket (250-499 MxAccess).
+ /// OPC UA Part 9 numeric severity for the Medium bucket (priority 501-750).
public const int OpcUaSeverityMedium = GalaxyAlarmPriority.OpcUaSeverityMedium;
- /// OPC UA Part 9 numeric severity for the High bucket (500-749 MxAccess).
+ /// OPC UA Part 9 numeric severity for the High bucket (priority 251-500).
public const int OpcUaSeverityHigh = GalaxyAlarmPriority.OpcUaSeverityHigh;
- /// OPC UA Part 9 numeric severity for the Critical bucket (750+ MxAccess).
+ /// OPC UA Part 9 numeric severity for the Critical bucket (priority 1-250).
public const int OpcUaSeverityCritical = GalaxyAlarmPriority.OpcUaSeverityCritical;
///
- /// Translate a raw MXAccess severity into the four-bucket
+ /// Translate a raw gateway severity (the AVEVA priority) into the four-bucket
/// + OPC UA Part 9 numeric severity tuple.
///
- /// The raw MXAccess severity value (0-999 range, clamped if out of range).
+ /// The gateway's raw value: the AVEVA priority (1-999, clamped if out of range).
/// The mapped four-bucket and its OPC UA Part 9 numeric severity.
// 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);
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/EventMapper.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/EventMapper.cs
index c9840ac7d..f19014661 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/EventMapper.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/EventMapper.cs
@@ -16,10 +16,13 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Mapping;
/// alarmname / alarmid / severity / message.
///
///
-/// Severity: an AVEVA row's priority maps through , the table live Galaxy alarms
-/// use, so a row and its live event agree. AVEVA's own severity 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 priority (OtOpcUa's own) takes severity, clamped to
-/// 1–1000.
+/// Severity: an AVEVA alarm row's priority (1 = most urgent) maps through , 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 severity 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: severity 0 marks an AVEVA event that is not an
+/// alarm (System.Start/Stop/Deploy, User.Write, …, all priority 999), which reads
+/// (1), below every alarm. A row with no priority
+/// (OtOpcUa's own) takes severity, clamped to 1–1000.
///
///
internal static class EventMapper
@@ -89,13 +92,19 @@ internal static class EventMapper
}
///
- /// The OPC UA severity: an AVEVA row's priority through the live Galaxy table, else the Severity property
- /// clamped to [1, 1000]. Missing or unparseable values default to the minimum severity (1).
+ /// The OPC UA severity: an AVEVA row's priority through the live Galaxy table (or 1 when AVEVA's own
+ /// severity is 0, an event that is not an alarm), else the Severity property clamped to
+ /// [1, 1000]. Missing or unparseable values default to the minimum severity (1).
///
private static ushort ParseSeverity(IDictionary 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);
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverAlarmSourceTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverAlarmSourceTests.cs
index eaa571519..c466d4c28 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverAlarmSourceTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverAlarmSourceTests.cs
@@ -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);
}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GatewayGalaxyAlarmFeedTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GatewayGalaxyAlarmFeedTests.cs
index ede471356..428266909 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GatewayGalaxyAlarmFeedTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GatewayGalaxyAlarmFeedTests.cs
@@ -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)
}
/// Verifies that the feed reopens the stream after a transport fault.
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/MxAccessSeverityMapperTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/MxAccessSeverityMapperTests.cs
index 33d6cd4ad..581743527 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/MxAccessSeverityMapperTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/MxAccessSeverityMapperTests.cs
@@ -6,43 +6,61 @@ using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
///
-/// 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
-/// GalaxyAlarmTracker per docs/v1/AlarmTracking.md.
+/// 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.
///
public sealed class MxAccessSeverityMapperTests
{
- /// Verifies that Map assigns the expected severity bucket and OPC UA severity value.
- /// The raw MxAccess severity integer to map.
+ /// Verifies the band table at every edge, plus the out-of-range clamps.
+ /// The raw value the gateway sends: the AVEVA priority.
/// The expected AlarmSeverity bucket.
/// The expected OPC UA numeric severity value.
[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);
}
- /// Verifies that Map clamps negative severities into the low bucket.
+ ///
+ /// 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).
+ ///
[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);
+ }
+
+ /// Verifies the live mapper is the shared table history uses (Gitea #886), value for value.
+ [Fact]
+ public void Map_is_the_shared_GalaxyAlarmPriority_table()
+ {
+ for (var priority = -2; priority <= 1001; priority++)
+ MxAccessSeverityMapper.Map(priority).ShouldBe(GalaxyAlarmPriority.Map(priority));
}
}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/EventMapperTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/EventMapperTests.cs
index 7b46bcd59..0bd15331f 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/EventMapperTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/EventMapperTests.cs
@@ -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.
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/GalaxyEquipmentEventHistoryWireTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/GalaxyEquipmentEventHistoryWireTests.cs
index 410efef9a..9f62d72de 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/GalaxyEquipmentEventHistoryWireTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/GalaxyEquipmentEventHistoryWireTests.cs
@@ -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;
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmRestatementEndToEndTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmRestatementEndToEndTests.cs
index b611ac6b6..71f1c6de5 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmRestatementEndToEndTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmRestatementEndToEndTests.cs
@@ -150,6 +150,43 @@ public sealed class NativeAlarmRestatementEndToEndTests : RuntimeActorTestBase
}
}
+ ///
+ /// 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 's exact value, the
+ /// same table AVEVA history rows map their priority through (EventMapperTests 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).
+ ///
+ /// The AVEVA alarm priority (1 = most urgent).
+ /// The OPC UA Severity the live condition must carry.
+ [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),