From b61f3e0560e4b64a87f9b91affaba9ecc71aeed0 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 12 Nov 2026 12:14:48 -0500 Subject: [PATCH] fix(galaxy): AVEVA priority 1 is the most urgent: Galaxy alarm Severity follows AVEVA's priority bands, live and history alike (#887) 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 --- docs/AlarmTracking.md | 12 ++-- docs/Historian.md | 27 ++++++- .../2026-11-10-opcua2/aveva-history-801.md | 23 +++++- .../aveva-gate-sdk2-887.json.gz | Bin 0 -> 35112 bytes .../GalaxyAlarmPriority.cs | 68 ++++++++++++++---- .../GalaxyDriver.cs | 4 ++ .../Runtime/MxAccessSeverityMapper.cs | 30 ++++---- .../Mapping/EventMapper.cs | 21 ++++-- .../GalaxyDriverAlarmSourceTests.cs | 2 + .../Runtime/GatewayGalaxyAlarmFeedTests.cs | 5 +- .../Runtime/MxAccessSeverityMapperTests.cs | 64 +++++++++++------ .../Mapping/EventMapperTests.cs | 40 ++++++++--- .../GalaxyEquipmentEventHistoryWireTests.cs | 14 ++-- .../NativeAlarmRestatementEndToEndTests.cs | 41 ++++++++++- 14 files changed, 268 insertions(+), 83 deletions(-) create mode 100644 docs/plans/evidence/2026-11-10-opcua2/aveva-history-801/aveva-gate-sdk2-887.json.gz 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 0000000000000000000000000000000000000000..777ccd8434ff27eedc4c15790a8f1e4d7b8052ef GIT binary patch literal 35112 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 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),