Galaxy event history: notifier folders never name a Galaxy object, so AVEVA A&E history is unreachable in production (+ AVEVA severity/condition mapping) #886

Closed
opened 2026-11-12 10:34:13 -05:00 by dohertj2 · 1 comment
Owner

Found while fixing #882.

Gap. A notifier folder's historian event source is its NodeId identifier (EnsureFolderIsEventNotifier: folder.NodeId.IdentifierAsString). That is a RawPath (<Folder>/<Driver>/<Device>/<TagGroup>) for a Raw folder, or an EquipmentId for a UNS equipment folder. It is never a Galaxy object's tag_name. The gateway filters a per-source ReadEvents on AVEVA's Source_Object, which is the Galaxy tag_name (for example DevWinPlatform). So in production, AVEVA's own A&E history (object events like System.Stop/System.Start, and attribute alarms reported as <Object>.<Attribute>) reaches a folder only when an EquipmentId happens to equal the Galaxy tag_name. The #801/#882 live gate passes because its harness names the folder after the object.

Live delivery does not have this gap. A native Galaxy condition (ConditionId = RawPath, dialled from the attributeRef tag_name.Attr) fires on its Raw parent folder and on every referencing UNS equipment folder (WireAlarmNotifiers).

Options.

  • A. Register extra historian source names per notifier folder: the Galaxy object(s) behind the native conditions wired to it, taken from each condition's dial reference (source_conditionvariable / Source_Object = the part before the first .). The folder then reads each object with the #882 boundary match. This is the closest fit to live delivery. It needs the dial ref plumbed from RawTagPlan into the sink and multiple sources per notifier (several gateway reads, merged in (EventTime, EventId) order for paging).
  • B. Per-condition matching: a folder serves the AVEVA rows whose source_conditionvariable names one of its wired conditions' dial refs. This is narrower and drops object-level system events.

Related mapping gaps on AVEVA rows (EventMapper, same root: AVEVA property keys are lower-case):

  • Severity reads Severity/Priority. AVEVA sends priority (500), so every AVEVA event projects Severity 1.
  • AlarmId/ConditionName read AlarmId/AlarmName. AVEVA sends alarm_id (a GUID-like id) and source_conditionvariable, so ConditionName is null and SourceNode can never resolve. Resolving SourceNode needs the dial-ref → condition map from option A/B.

The per-folder ACL (#826) must keep governing whatever a folder serves (#882 holds this: attribute events ride the folder's own HistoryRead verdict).

Found while fixing #882. **Gap.** A notifier folder's historian event source is its NodeId identifier (`EnsureFolderIsEventNotifier`: `folder.NodeId.IdentifierAsString`). That is a RawPath (`<Folder>/<Driver>/<Device>/<TagGroup>`) for a Raw folder, or an EquipmentId for a UNS equipment folder. It is never a Galaxy object's `tag_name`. The gateway filters a per-source `ReadEvents` on AVEVA's `Source_Object`, which is the Galaxy `tag_name` (for example `DevWinPlatform`). So in production, AVEVA's own A&E history (object events like `System.Stop`/`System.Start`, and attribute alarms reported as `<Object>.<Attribute>`) reaches a folder only when an EquipmentId happens to equal the Galaxy tag_name. The #801/#882 live gate passes because its harness names the folder after the object. Live delivery does not have this gap. A native Galaxy condition (ConditionId = RawPath, dialled from the attributeRef `tag_name.Attr`) fires on its Raw parent folder and on every referencing UNS equipment folder (`WireAlarmNotifiers`). **Options.** - A. Register extra historian source names per notifier folder: the Galaxy object(s) behind the native conditions wired to it, taken from each condition's dial reference (`source_conditionvariable` / `Source_Object` = the part before the first `.`). The folder then reads each object with the #882 boundary match. This is the closest fit to live delivery. It needs the dial ref plumbed from `RawTagPlan` into the sink and multiple sources per notifier (several gateway reads, merged in `(EventTime, EventId)` order for paging). - B. Per-condition matching: a folder serves the AVEVA rows whose `source_conditionvariable` names one of its wired conditions' dial refs. This is narrower and drops object-level system events. **Related mapping gaps on AVEVA rows** (EventMapper, same root: AVEVA property keys are lower-case): - Severity reads `Severity`/`Priority`. AVEVA sends `priority` (500), so every AVEVA event projects Severity 1. - AlarmId/ConditionName read `AlarmId`/`AlarmName`. AVEVA sends `alarm_id` (a GUID-like id) and `source_conditionvariable`, so ConditionName is null and SourceNode can never resolve. Resolving SourceNode needs the dial-ref → condition map from option A/B. The per-folder ACL (#826) must keep governing whatever a folder serves (#882 holds this: attribute events ride the folder's own HistoryRead verdict).
dohertj2 added the priority:p2cross-repo labels 2026-11-12 10:34:13 -05:00
Author
Owner

Fixed on master 3a312ffd, OtOpcUa only. No HistorianGateway wire change. Design: docs/Historian.md § "Galaxy native event history on real folders (Gitea #886)".

Design. Option A, with live delivery as the rule. A folder's event history now reads its own source, as before, plus the Galaxy object of every native condition that fires on it live. Those are the conditions parented under the folder (the Raw device folder) and the ones WireAlarmNotifiers wired to it (each referencing UNS equipment folder), so live and history use the same membership.

  • Object name. It comes from the condition's raw tag attributeRef (tag_name.AlarmAttribute, the address the driver dials), taken up to the first ..
    • The composer reads attributeRef with the driver's own parser (GalaxyAttributeRef, now shared with GalaxyDriver). It does this only for a GalaxyMxGateway tag that carries an alarm, and stores the result in RawTagPlan.NativeAlarmReference, which is part of plan equality.
    • The applier binds it to the condition through a new sink call, BindNativeAlarmHistory, which the deferred sink forwards.
    • A rebuild or the condition's removal forgets it.
  • Matching. Each object is read with the #882 rule: its own events and <Object>.<Attribute> rows, never a prefix sibling.
  • Fan-out. historiangw's ReadEvents filter is a single predicate, so there is no multi-source request: the server makes one request per source, sequentially within the folder, each with the page cap. EventSourceFanOut merges the results into one source:
    • Order is (time, source position, arrival), and a reverse read is the forward merge backwards. Ties are never re-sorted by EventId, which keeps the boundary skip stable on the Gateway.
    • Duplicates by EventId are dropped, and the merge is cut to the cap.
    • When a source is truncated, the page is bounded at the time that source stopped.
    • Paging, continuation points (bound to the folder's whole source set), the walked-back reverse read and the tie-cluster over-fetch are unchanged. A folder with one source reads exactly as before.
  • ACL. Every source is read inside the folder's own HistoryRead, after AuthorizeHistoryRead (#826). A denied folder reads nothing: BadUserAccessDenied, and no backend call is made (fail closed).
  • Mapping. Property names are now matched case-insensitively. AVEVA lower-cases OtOpcUa's own keys too (alarmname, alarmid, severity, message), as seen on vd03.
    • Severity: priority maps through GalaxyAlarmPriority, the table live Galaxy alarms use (MxAccessSeverityMapper now delegates to it), so history matches live: < 250 → 250, < 500 → 500, < 750 → 750, otherwise 1000. A row without priority (OtOpcUa's own) takes severity, clamped. AVEVA's own severity (1–4, or 0 for system events) is ignored. ⚠️ AVEVA ranks priority 1 as most urgent and the live table ranks higher numbers as more severe; I filed that as #887 rather than change live severity here.
    • SourceNode / ConditionName: a row whose source_conditionvariable names a native condition that fires on this folder (case-insensitive, as the driver matches) takes that condition's SourceNode and live ConditionName. Otherwise ConditionName is alarmname, or the condition variable's attribute (CPULoadHiAlarm). A condition outside the folder is never named.
    • AlarmId: alarm_id and alarmid map to AlarmId. alarm_id identifies one alarm occurrence (Set and Clear share it), not a condition, so it is not used as the ConditionName.

Tests.

  • GalaxyEquipmentEventHistoryWireTests (4). These run over a real SDK session against the production Gateway backend and an AVEVA-shaped fake gateway. Equipment EqA is backed by objects Oven_007 and OvenDoor_07; neither name matches the folder. Coverage:

    • object and attribute events from both objects;
    • the prefix sibling Oven_0070 excluded, although it reached OtOpcUa;
    • ties across and within objects;
    • forward paging at 1–4 events a page, and reverse reads whole and paged;
    • SourceNode, ConditionName and Severity;
    • the Raw device folder;
    • ACL deny with no backend call, plus a granted-folder positive control;
    • a rebuild forgetting the bindings.
  • Other tests: EventSourceFanOutTests (8), EventMapper AVEVA cases (11), GalaxyAttributeRefTests (16), a composer test and an applier ordering test.

  • Mutation checks: 17 mutants, all killed by at least one test:

    • parent-only membership; no scope; a case-sensitive condition match; no condition rewrite;
    • no truncation bound; EventId tie order; no sort; no cap; no stop at the bound; unreversed source order for reverse reads; no dedupe;
    • no . boundary; priority ignored; case-sensitive keys;
    • no applier bind; no deferred forward; a composer that ignores the driver type.

    One redundant guard survives as belt-and-braces: the forget-on-rematerialise, which the rebuild clear already covers.

  • Suites (pre-push rebase): OpcUaServer.Tests 1275/0 failed, OpcUaServer.IntegrationTests 316/0 failed (live gate skipped without env), Runtime.Tests 1029/0, Historian.Gateway.Tests 550/0, Galaxy.Tests 580/0, Core.Abstractions.Tests 745/0, Commons.Tests 363/0. After the final rebase onto 1e1fad76 (#879/#888, unrelated files) the solution builds and the event-history wire tests pass.

Live re-run: read-only, network-only, no WinRM. It ran against vd03 HistorianGateway ea9f21c. The API key was read over SMB from vd03's OtOpcUa env backup, never printed, and the local copy was deleted.

  • The harness gained events886.*: a realistic equipment folder, EQ-886-ZipsenLeft, with conditions DevWinPlatform.CPULoadHiAlarm and Left_001.FRR_Warning wired to it.
  • Before: the folder's own source holds 0 events.
  • Now: the folder serves 92 events: DevWinPlatform 2 + DevWinPlatform.CPULoadAvg 70 + 20 Left_001/Left_001.*. That is exactly the unfiltered read's rows for both objects, in time order.
  • 1-event pages (92 pages) and 7-event pages equal the whole read. Reverse whole and 5-event pages are newest-first.
  • 73 rows resolve to their live condition: 70 → CPULoadHiAlarm, 3 → FRR_Warning.
  • Severity: 750 on the alarm rows and 1000 on priority-999 system and user events (see #887). Before, every row was 1.
  • diff_runs.py vs the #882 run: 221 of 226 cases are identical. The 5 that differ are expected: the 4 DevWinPlatform event cases (only Severity changed, from 1 to 750/1000) and the #881 capability flags.
  • Record: docs/plans/evidence/2026-11-10-opcua2/aveva-history-801.md § "#886 re-run", report aveva-gate-sdk2-886.json.gz.

Consumer impact. ScadaBridge: none (it does no HistoryRead). Generic OPC UA history clients reading a Galaxy-backed folder now get AVEVA's A&E history (more rows), a real Severity and ConditionName, and SourceNode on rows for the folder's own conditions. Not deployed.

**Fixed on master `3a312ffd`, OtOpcUa only.** No HistorianGateway wire change. Design: `docs/Historian.md` § "Galaxy native event history on real folders (Gitea #886)". **Design. Option A, with live delivery as the rule.** A folder's event history now reads its own source, as before, plus the Galaxy object of every native condition that fires on it live. Those are the conditions parented under the folder (the Raw device folder) and the ones `WireAlarmNotifiers` wired to it (each referencing UNS equipment folder), so live and history use the same membership. - **Object name.** It comes from the condition's raw tag `attributeRef` (`tag_name.AlarmAttribute`, the address the driver dials), taken up to the first `.`. - The composer reads `attributeRef` with the driver's own parser (`GalaxyAttributeRef`, now shared with `GalaxyDriver`). It does this only for a `GalaxyMxGateway` tag that carries an alarm, and stores the result in `RawTagPlan.NativeAlarmReference`, which is part of plan equality. - The applier binds it to the condition through a new sink call, `BindNativeAlarmHistory`, which the deferred sink forwards. - A rebuild or the condition's removal forgets it. - **Matching.** Each object is read with the #882 rule: its own events and `<Object>.<Attribute>` rows, never a prefix sibling. - **Fan-out.** historiangw's `ReadEvents` filter is a single predicate, so there is no multi-source request: the server makes one request per source, sequentially within the folder, each with the page cap. `EventSourceFanOut` merges the results into one source: - Order is `(time, source position, arrival)`, and a reverse read is the forward merge backwards. Ties are never re-sorted by EventId, which keeps the boundary skip stable on the Gateway. - Duplicates by EventId are dropped, and the merge is cut to the cap. - When a source is truncated, the page is bounded at the time that source stopped. - Paging, continuation points (bound to the folder's whole source set), the walked-back reverse read and the tie-cluster over-fetch are unchanged. A folder with one source reads exactly as before. - **ACL.** Every source is read inside the folder's own HistoryRead, after `AuthorizeHistoryRead` (#826). A denied folder reads nothing: `BadUserAccessDenied`, and no backend call is made (fail closed). - **Mapping.** Property names are now matched case-insensitively. AVEVA lower-cases OtOpcUa's own keys too (`alarmname`, `alarmid`, `severity`, `message`), as seen on vd03. - **Severity:** `priority` maps through `GalaxyAlarmPriority`, the table live Galaxy alarms use (`MxAccessSeverityMapper` now delegates to it), so history matches live: `< 250` → 250, `< 500` → 500, `< 750` → 750, otherwise 1000. A row without `priority` (OtOpcUa's own) takes `severity`, clamped. AVEVA's own `severity` (1–4, or 0 for system events) is ignored. ⚠️ AVEVA ranks priority 1 as most urgent and the live table ranks higher numbers as more severe; I filed that as **#887** rather than change live severity here. - **SourceNode / ConditionName:** a row whose `source_conditionvariable` names a native condition that fires on this folder (case-insensitive, as the driver matches) takes that condition's SourceNode and live ConditionName. Otherwise ConditionName is `alarmname`, or the condition variable's attribute (`CPULoadHiAlarm`). A condition outside the folder is never named. - **AlarmId:** `alarm_id` and `alarmid` map to AlarmId. `alarm_id` identifies one alarm occurrence (Set and Clear share it), not a condition, so it is not used as the ConditionName. **Tests.** - **`GalaxyEquipmentEventHistoryWireTests` (4).** These run over a real SDK session against the production Gateway backend and an AVEVA-shaped fake gateway. Equipment `EqA` is backed by objects `Oven_007` and `OvenDoor_07`; neither name matches the folder. Coverage: - object and attribute events from both objects; - the prefix sibling `Oven_0070` excluded, although it reached OtOpcUa; - ties across and within objects; - forward paging at 1–4 events a page, and reverse reads whole and paged; - SourceNode, ConditionName and Severity; - the Raw device folder; - ACL deny with no backend call, plus a granted-folder positive control; - a rebuild forgetting the bindings. - **Other tests:** `EventSourceFanOutTests` (8), EventMapper AVEVA cases (11), `GalaxyAttributeRefTests` (16), a composer test and an applier ordering test. - **Mutation checks:** 17 mutants, all killed by at least one test: - parent-only membership; no scope; a case-sensitive condition match; no condition rewrite; - no truncation bound; EventId tie order; no sort; no cap; no stop at the bound; unreversed source order for reverse reads; no dedupe; - no `.` boundary; priority ignored; case-sensitive keys; - no applier bind; no deferred forward; a composer that ignores the driver type. One redundant guard survives as belt-and-braces: the forget-on-rematerialise, which the rebuild clear already covers. - **Suites (pre-push rebase):** OpcUaServer.Tests 1275/0 failed, OpcUaServer.IntegrationTests 316/0 failed (live gate skipped without env), Runtime.Tests 1029/0, Historian.Gateway.Tests 550/0, Galaxy.Tests 580/0, Core.Abstractions.Tests 745/0, Commons.Tests 363/0. After the final rebase onto `1e1fad76` (#879/#888, unrelated files) the solution builds and the event-history wire tests pass. **Live re-run: read-only, network-only, no WinRM.** It ran against vd03 HistorianGateway `ea9f21c`. The API key was read over SMB from vd03's OtOpcUa env backup, never printed, and the local copy was deleted. - The harness gained `events886.*`: a realistic equipment folder, `EQ-886-ZipsenLeft`, with conditions `DevWinPlatform.CPULoadHiAlarm` and `Left_001.FRR_Warning` wired to it. - **Before:** the folder's own source holds **0** events. - **Now:** the folder serves **92 events**: `DevWinPlatform` 2 + `DevWinPlatform.CPULoadAvg` 70 + 20 `Left_001`/`Left_001.*`. That is exactly the unfiltered read's rows for both objects, in time order. - 1-event pages (92 pages) and 7-event pages equal the whole read. Reverse whole and 5-event pages are newest-first. - **73 rows** resolve to their live condition: 70 → `CPULoadHiAlarm`, 3 → `FRR_Warning`. - **Severity:** 750 on the alarm rows and 1000 on priority-999 system and user events (see #887). Before, every row was 1. - **`diff_runs.py` vs the #882 run:** 221 of 226 cases are identical. The 5 that differ are expected: the 4 `DevWinPlatform` event cases (only Severity changed, from 1 to 750/1000) and the #881 capability flags. - Record: `docs/plans/evidence/2026-11-10-opcua2/aveva-history-801.md` § "#886 re-run", report `aveva-gate-sdk2-886.json.gz`. **Consumer impact.** ScadaBridge: none (it does no HistoryRead). Generic OPC UA history clients reading a Galaxy-backed folder now get AVEVA's A&E history (more rows), a real Severity and ConditionName, and SourceNode on rows for the folder's own conditions. Not deployed.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: dohertj2/lmxopcua#886