Files
lmxopcua/scripts/validation/aveva-history-801
Joseph Doherty 3a312ffdd1
v2-ci / unit-tests (push) Has been cancelled
fix(history): a folder's event history reads the Galaxy objects behind its native conditions (#886)
A notifier folder read one event source, its NodeId identifier (a RawPath or an EquipmentId). AVEVA stores a
Galaxy object's A&E history under the object's tag_name (Source_Object), so AVEVA's own alarm and event
history reached no production folder. Its lower-case property keys were also missed, so every row read
Severity 1 with no ConditionName.

Sources. A folder's event history now reads its own source plus the Galaxy object of every native condition
that fires on it live: those parented under it and those WireAlarmNotifiers wired to it. Live and history use
the same membership. The object comes from the condition's Galaxy alarm reference, its raw tag's attributeRef,
up to the first '.':
- The composer reads it with the driver's own parser (GalaxyAttributeRef, shared with GalaxyDriver), only for
  a GalaxyMxGateway tag with an alarm (RawTagPlan.NativeAlarmReference, part of plan equality).
- The applier binds it after materialising the condition (IOpcUaAddressSpaceSink.BindNativeAlarmHistory,
  forwarded by the deferred sink). A rebuild or the condition's removal forgets it.
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 one request per source; the wire
contract is unchanged. EventSourceFanOut merges the reads as one source:
- order is (time, source position, arrival), reversed for a reverse read; ties are never re-sorted by EventId,
  so the boundary skip stays stable;
- duplicates by EventId are dropped and the merge is cut to the cap;
- a truncated source bounds the page at the time it stopped.
Paging, continuation points (bound to the whole source set), the walked-back reverse read and the tie-cluster
over-fetch are unchanged. A folder with one source is read exactly as before.

ACL. Every source is read inside the folder's own HistoryRead, after AuthorizeHistoryRead (#826). A denied
folder reads nothing and fails closed.

Mapping (EventMapper, property names now case-insensitive):
- Severity: an AVEVA row's priority goes through GalaxyAlarmPriority, the table live Galaxy alarms use
  (MxAccessSeverityMapper now delegates to it), so history matches live. Otherwise severity (OtOpcUa's own
  rows, which AVEVA stores lower-cased), clamped. AVEVA's own 1-4 severity is ignored. The table ranks a
  higher priority as more severe, which inverts AVEVA's convention: filed as #887.
- An AVEVA row whose source_conditionvariable names a native condition that fires on the folder takes that
  condition's SourceNode and live ConditionName. Otherwise ConditionName is alarmname or the condition
  variable's attribute.
- alarm_id/alarmid map to AlarmId.

Tests:
- GalaxyEquipmentEventHistoryWireTests (4, real SDK session, production Gateway backend, AVEVA-shaped fake
  gateway): equipment EqA backed by objects Oven_007 + OvenDoor_07. Covers object and attribute events from
  both objects, the prefix sibling excluded, cross-object and in-object ties, forward paging at 1-4 a page,
  reverse whole and paged, SourceNode/ConditionName/Severity, the raw device folder, ACL deny with no backend
  call plus a granted positive control, and a rebuild forgetting the bindings.
- EventSourceFanOutTests (8), EventMapper AVEVA cases (11), GalaxyAttributeRefTests (16), a composer test and
  an applier ordering test.
- Mutation-checked, 17 mutants, all killed. Covered: 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, no dedupe, no '.' boundary, priority ignored, case-sensitive keys, no
  applier bind, no deferred forward, and a composer that ignores the driver type. The forget-on-rematerialise
  guard survives as belt-and-braces: the rebuild clear covers it.
- Live, read-only, no WinRM, vd03 HistorianGateway ea9f21c: equipment folder EQ-886-ZipsenLeft (objects
  DevWinPlatform + Left_001). It read 0 events before and 92 now, equal to the unfiltered read's rows for both
  objects. 1/7-event pages and reverse whole/5-event pages agree, and 73 rows resolve to their live condition.
  diff_runs vs the #882 run: 221/226 identical. The 5 that differ are expected: DevWinPlatform Severity
  1 -> 750/1000, and #881 capabilities.

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
2026-11-12 11:45:48 -05:00
..

#801 real-AVEVA history gate (SDK 2.0.1 vs 1.5.378.182)

Harness: tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/AvevaGatewayHistoryLiveGateTests.cs. It boots the OtOpcUa server in-process (ServerEventRig), wires the production Gateway backend (HistorianBackendRegistry → GatewayHistorianDataSource) to a real HistorianGateway, and drives HistoryRead / HistoryUpdate over a real SDK client session. It skips unless HISTGW_GATEWAY_ENDPOINT and HISTGW_GATEWAY_APIKEY are set. It is read-only: the Gateway backend has no updater, and the only HistoryUpdate goes to a node bound to HISTGW_WRITE_SANDBOX_TAG and is refused by the server before any backend call.

Record of the 2026-11-12 run: docs/plans/evidence/2026-11-10-opcua2/aveva-history-801.md. The #886 cases (events886.*) wire Galaxy native conditions for OTOPCUA_AVEVA_GATE_GALAXY_REFS (default DevWinPlatform.CPULoadHiAlarm,Left_001.FRR_Warning) to one equipment folder, EQ-886-ZipsenLeft, that is named after none of their objects.

Run (SDK 2, this tree)

export HISTGW_GATEWAY_ENDPOINT=http://wonder-app-vd03.zmr.zimmer.com:5221
export HISTGW_GATEWAY_APIKEY="$(cat /path/to/key-file)"     # never on the command line
export HISTGW_WRITE_SANDBOX_TAG=HistGW.LiveTest.Sandbox
export OTOPCUA_AVEVA_GATE_OUT=/tmp/aveva-gate OTOPCUA_AVEVA_GATE_LABEL=sdk2-$(git rev-parse --short HEAD)
dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests -c Release \
  --filter "FullyQualifiedName~AvevaGatewayHistoryLiveGate"

Baseline (SDK 1.5.378.182)

git worktree add --detach ../otopcua-sdk1 efb9197a      # master's first parent before the SDK 2 merge b36e688f
cp tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/AvevaGatewayHistoryLiveGateTests.cs \
   ../otopcua-sdk1/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/
(cd ../otopcua-sdk1 && patch -p1 < "$OLDPWD/scripts/validation/aveva-history-801/sdk1-baseline-port.patch")
# then the same dotnet test command in ../otopcua-sdk1 with OTOPCUA_AVEVA_GATE_LABEL=sdk1-efb9197a

sdk1-baseline-port.patch changes only SDK API spelling: collections in place of ArrayOf, byte[] in place of ByteString, DataValue.Value in place of ClrValue(). The queries, windows and comparisons are unchanged.

Compare

python3 -I diff_runs.py  $OUT/aveva-gate-sdk2-*.json $OUT/aveva-gate-sdk1-efb9197a.json   # every case, row and event
python3 -I sql_raw_check.py $OUT/aveva-gate-sdk2-*.json      # raw rows vs Runtime.dbo.History (Full, UTC); needs
                                                            # HISTORIAN_SQL_USER / HISTORIAN_SQL_PASSWORD, read-only
python3 -I aggregate_recompute.py $OUT/aveva-gate-sdk2-*.json  # simple aggregates recomputed from the raw rows
python3 -I hgcli_raw_check.py $OUT/aveva-gate-sdk2-*.json $OUT  # vs histgw-cli read-raw output (hgcli-<tag>-<date>.jsonl)