Files
lmxopcua/docs/plans/2026-06-14-galaxy-phase-c-historian-design.md
T
2026-06-14 18:46:43 -04:00

16 KiB

Galaxy Phase C — Server-side OPC UA HistoryRead — Design

Date: 2026-06-14 Status: Approved (brainstorming) — ready for implementation planning Branch: feat/galaxy-phase-c-historian off master c9a0f627 Parent design: docs/plans/2026-06-12-galaxy-standard-driver-design.md (workstream 3 / Phase C). Follow-up: pending.md open item #2 ("Phase C — server-side HistoryRead backend over the Wonderware reader").

Goal

Make the OtOpcUa server answer OPC UA HistoryRead for an authored equipment tag flagged historized — driver-agnostically — by dispatching to the already-built, already-hardened server-side IHistorianDataSource (the Wonderware TCP client). No mxaccessgw history RPC; no EF migration. A historized tag's raw / processed / at-time samples and an equipment folder's event-history come back to any OPC UA client (e.g. Client.CLI historyread).

What already exists (grounded in code, 2026-06-14)

Phase C is mostly wiring — the read surface is built:

  • IHistorianDataSource (src/Core/.../Core.Abstractions/Historian/IHistorianDataSource.cs) — the server-side read seam, with required ReadRawAsync / ReadProcessedAsync / ReadAtTimeAsync / ReadEventsAsync / GetHealthSnapshot. Returns HistoryReadResult(IReadOnlyList<DataValueSnapshot> Samples, byte[]? ContinuationPoint) and HistoricalEventsResult(IReadOnlyList<HistoricalEvent> Events, byte[]? ContinuationPoint) (both in IHistoryProvider.cs). DataValueSnapshot(object? Value, uint StatusCode, DateTime? SourceTimestampUtc, DateTime ServerTimestampUtc) mirrors Opc.Ua.DataValue.
  • WonderwareHistorianClient (Driver.Historian.Wonderware.Client, .NET 10) implements IHistorianDataSource over a hardened TCP/TLS/shared-secret channel. The Host is the only project that references it (same as the alarm-historian writer).
  • DriverAttributeInfo.IsHistorized already exists; Galaxy + OpcUaClient drivers populate it (informational — the equipment-tag path does not consume DriverAttributeInfo, it carries TagConfig; see below).
  • The Phase B alarm-object-in-TagConfig carrierEquipmentTagPlan.Alarm parsed by Phase7Composer.ExtractTagAlarm and the byte-parity DeploymentArtifact.ExtractTagAlarm. This is the exact precedent for carrying isHistorized / historianTagname.
  • The wiring pattern — the node manager exposes settable props (AlarmCommandRouter, NodeWriteGateway); OtOpcUaSdkServer.Set* methods set them; the Host's OtOpcUaServerHostedService.StartAsync wires them post-start.
  • The DI patternAddAlarmHistorian(config, writerFactory) (Null default via AddOtOpcUaRuntime's TryAddSingleton, config-gated real impl), Host supplies the Wonderware client factory in Program.cs.
  • EnsureFolderIsEventNotifier already promotes alarm-owning equipment folders to EventNotifiers.SubscribeToEvents + AddRootNotifier (guarded by _notifierFolders).

What's missing (the Phase C work)

  1. No HistoryRead override. OtOpcUaNodeManager : CustomNodeManager2 overrides only CreateAddressSpace. Historizing is hardcoded false in EnsureVariable (:846) and CreateVariable (:977); no AccessLevels.HistoryRead bit, no event-notifier HistoryRead bit.
  2. No carrier for isHistorized / historianTagname through EquipmentTagPlan → artifact → materialization.
  3. No IHistorianDataSource registration / Null default / node-manager wiring / config section.

Locked decisions (this Phase C brainstorming)

Decision Choice
Read variants All four — Raw, Processed, AtTime over historized variable nodes; Events over historized equipment-folder event-notifier nodes. (User-selected.)
Authoring Raw TagConfig JSON"isHistorized": true (+ optional "historianTagname") rides in the existing TagConfig blob, mirroring the Phase B alarm object. No UI control, no EF migration. Galaxy already uses the raw-JSON tag editor. (User-selected.)
Tagname mapping Default = the tag's driver FullName (already on EquipmentTagPlan); optional explicit override via TagConfig.historianTagname.
Async bridge The override is synchronous and blocks on the async data source with its own call-timeout. No actor — HistoryRead is read-only and is not invoked under the node-manager Lock (unlike OnWriteValue), so blocking is safe and won't freeze the address space.
Redundancy / auth No Primary gate (reads are safe from any node). Auth is the standard NodePermissions.HistoryRead enforced by the SDK via the AccessLevels.HistoryRead bit we set at materialization.
Graceful degrade A NullHistorianDataSource (returns empty Samples / Events) is the default. A historized node with the Null source returns GoodNoData (empty), not an error spike (parent-design invariant). A non-historized node returns BadHistoryOperationUnsupported.
Continuation points Single-shot read honoring the client's NumValuesPerNode; results[i].ContinuationPoint = null. releaseContinuationPoints == trueGood (we never issue one). Server-managed paging is an explicit follow-up.
Config New ServerHistorian appsettings section (Host/Port/SharedSecret/UseTls/ServerCertThumbprint/Enabled) + AddServerHistorian(config, factory), mirroring AlarmHistorian. Independent of the alarm-write path (its own enable + its own client instance to the same sidecar).

Rejected / out of scope

  • Server-object fleet-wide event history. The Server object belongs to the SDK's CoreNodeManager, not ours; the MasterNodeManager routes HistoryRead by node ownership. We expose event history only on our equipment-folder event-notifiers (per-equipment sourceName). Clean ownership boundary.
  • An historian read actor (mirroring HistorianAdapterActor). The write path needs an actor for fire-and-forget + redundancy gating; reads are synchronous, ungated, and ownerless — a direct blocking bridge is simpler and correct.
  • Continuation-point paging / HistoryUpdate / live UI authoring control. Follow-ups.

Carrier seam (mirrors Phase B alarm exactly)

Tag.TagConfig JSON  { "FullName":"…", "isHistorized":true, "historianTagname":"…"? , "alarm":{…}? }
   → Phase7Composer.ExtractTagHistorize(tagConfig)  →  (bool IsHistorized, string? HistorianTagname)
   → EquipmentTagPlan { …, IsHistorized, HistorianTagname }            (+2 fields)
   → ConfigComposer snapshot → artifact JSON
   → DeploymentArtifact.ExtractTagHistorize(tagConfig)   ← BYTE-PARITY copy of the composer parse
   → Phase7Applier.MaterialiseEquipmentTags
        historianTagname := tag.IsHistorized ? (tag.HistorianTagname ?? tag.FullName) : null
        → IOpcUaAddressSpaceSink.EnsureVariable(…, string? historianTagname)   (+1 nullable param)
   → OtOpcUaNodeManager.EnsureVariable
        if historianTagname is not null:
            Historizing = true
            AccessLevel |= AccessLevels.HistoryRead   (Read + History; ReadWrite + History)
            _historizedTagnames[nodeId] = historianTagname

Single nullable param. The sink seam gains exactly one string? historianTagname (null ⇒ not historized) across IOpcUaAddressSpaceSink / NullOpcUaAddressSpaceSink / DeferredAddressSpaceSink / SdkAddressSpaceSink / Phase7Applier.SafeEnsureVariable. The applier resolves default-vs-override so the node manager just consumes the final tagname.

HistoryRead dispatch (the new override)

OtOpcUaNodeManager gains a settable public IHistorianDataSource HistorianDataSource { get; set; } (defaults to NullHistorianDataSource.Instance; volatile-backed like NodeWriteGateway) and a ConcurrentDictionary<string,string> _historizedTagnames (NodeId-string → historian tagname).

Override the CustomNodeManager2 HistoryRead surface. The exact override point is version-sensitive — the implementer confirms it against the installed OPCFoundation.NetStandard.Opc.Ua.Server (use the DeepWiki MCP OPCFoundation/UA-.NETStandard, per CLAUDE.md). Recommended: override the per-details protected virtuals (HistoryReadRawModified / HistoryReadProcessed / HistoryReadAtTime / HistoryReadEvents); fallback: override the single HistoryRead and switch on details. Per nodesToRead[i]:

  • Resolve. NodeId.Identifier (string) → _historizedTagnames. Miss ⇒ errors[i] = BadHistoryOperationUnsupported, Processed = true. (Reading _historizedTagnames needs no Lock — it's a concurrent dict.)
  • Data (variable nodes): switch on the details type → HistorianDataSource.ReadRawAsync(tagname, start, end, NumValuesPerNode, ct) / ReadProcessedAsync(…interval, MapAggregate(aggregateId)…) / ReadAtTimeAsync(tagname, reqTimes, ct). Block with the data source's timeout (.GetAwaiter().GetResult() inside try). Map each DataValueSnapshotOpc.Ua.DataValue(new Variant(Value), new StatusCode(StatusCode), SourceTs, ServerTs); wrap the list in HistoryData { DataValues = … }; set results[i].HistoryData = new ExtensionObject(historyData), results[i].StatusCode = samples.Count == 0 ? GoodNoData : Good, ContinuationPoint = null, Processed = true.
  • Events (equipment-folder notifier nodes): ReadEventDetails target folder → _eventNotifierSources[folderNodeId] (sourceName, default = the folder's string id = equipment id, registered when EnsureFolderIsEventNotifier promotes it) → ReadEventsAsync(sourceName, start, end, maxEvents, ct). Project each HistoricalEvent into a HistoryEventFieldList per the request's ReadEventDetails.Filter SelectClauses — support the standard BaseEventType operands (EventId, SourceName, Time, Message, Severity); an unsupported operand yields a null field (spec-conformant). Wrap in HistoryEvent { Events = … }.
  • Errors never escape. A backend throw / timeout → errors[i] = BadHistoryOperationUnsupported (or BadUnexpectedError) + log; one node's failure never faults the batch.

MapAggregate(NodeId aggregateId) maps the OPC UA Part 13 aggregate NodeIds (ObjectIds.AggregateFunction_Average/Minimum/Maximum/Total/Count) → HistoryAggregateType; an unsupported aggregate ⇒ BadAggregateNotSupported.

Materialization: Historizing + access bits

  • Variable nodesEnsureVariable sets Historizing = historianTagname is not null and ORs AccessLevels.HistoryRead into both AccessLevel and UserAccessLevel when historized; registers _historizedTagnames. CreateVariable (lazy, never historized) stays Historizing = false.
  • Event-notifier foldersEnsureFolderIsEventNotifier ORs EventNotifiers.HistoryRead into the promoted folder's EventNotifier when a non-Null historian is wired, and registers _eventNotifierSources[folder.NodeId-string] = folder.NodeId.Identifier (the equipment id). On RebuildAddressSpace, clear _historizedTagnames + _eventNotifierSources alongside the existing guards.

Wiring + DI

  • NullHistorianDataSource (Core.Abstractions/Historian) — empty Samples/Events, disabled health snapshot. The node-manager prop default + the DI default.
  • ServerHistorianOptions (Runtime/Historian, SectionName = "ServerHistorian") + AddServerHistorian(this IServiceCollection, IConfiguration, Func<ServerHistorianOptions, IServiceProvider, IHistorianDataSource> factory)Enabled=false ⇒ no-op (Null default from AddOtOpcUaRuntime's new TryAddSingleton<IHistorianDataSource>(NullHistorianDataSource.Instance)); Enabled=trueAddSingleton the factory result; Validate() warnings mirror AlarmHistorian.
  • OtOpcUaSdkServer.SetHistorianDataSource(IHistorianDataSource?) — mirrors SetNodeWriteGateway (null-coalesces to the Null default; returns false if NodeManager not up yet).
  • HostProgram.cs adds AddServerHistorian(config, (opts,sp) => new WonderwareHistorianClient( new WonderwareHistorianClientOptions(opts.Host, opts.Port, opts.SharedSecret){ UseTls=…, ServerCertThumbprint=… }, sp.GetService<ILogger<WonderwareHistorianClient>>())); OtOpcUaServerHostedService.StartAsync resolves IHistorianDataSource from DI and calls _server.SetHistorianDataSource(...) (right after SetNodeWriteGateway); StopAsync clears it.

Error handling / edge cases

  • Byte-parity invariantPhase7Composer.ExtractTagHistorize and DeploymentArtifact.ExtractTagHistorize must parse identically; a parity round-trip test guards it (precedent: DeploymentArtifactAliasParityTests).
  • Non-historized HistoryReadBadHistoryOperationUnsupported (node not in _historizedTagnames); the SDK's own AccessLevel check already blocks most before we see them.
  • Null / unconfigured historianGoodNoData empty (not an error).
  • Backend timeout / throw → per-node BadHistoryOperationUnsupported + log; batch survives.
  • RedeployRebuildAddressSpace clears the two new maps; re-materialization re-registers.

Testing (no bUnit, offline xUnit + Shouldly)

  • Composer ExtractTagHistorize — true / false / absent / override / malformed-JSON (never throws).
  • Artifact parity — composer plan == artifact plan for a historized tag (incl. override) round-trip.
  • EnsureVariable — historized ⇒ Historizing==true + HistoryRead access bit + registry entry; non-historized ⇒ unchanged; default-vs-override tagname resolution in the applier.
  • HistoryRead override (data) — fake IHistorianDataSource; assert Raw / Processed / AtTime dispatch, tagname passed (default + override), DataValueSnapshot→DataValue mapping (value / status / timestamps), GoodNoData on empty, BadHistoryOperationUnsupported on non-historized, BadAggregateNotSupported on an unknown aggregate.
  • HistoryRead override (events) — fake source; assert folder→sourceName resolution + event-field projection for the standard select clauses + empty/Null.
  • DIAddServerHistorian leaves Null when disabled, swaps the real source when enabled; NullHistorianDataSource returns empty.

Live docker-dev /run (user-driven; the agent does NOT sign in) — DEFERRED. Requires the Wonderware sidecar + AVEVA Historian (the WW Historian VM 10.100.0.48), which is not part of the local docker-dev rig, so this gate is inherently operator-driven. When run: author a historized Galaxy equipment tag, set ServerHistorian:Enabled=true pointed at the sidecar, deploy, then Client.CLI historyread → samples return.

Hard rules (carried into implementation)

  • Stage by path; never git add .. Never stage sql_login.txt, src/Server/ZB.MOM.WW.OtOpcUa.Host/pki/, pending.md, current.md, or docker-dev/docker-compose.yml. Never echo the gateway API key or the historian SharedSecret into a tracked file. No force-push, no --no-verify.
  • NO EF/Configuration entity or migration change — the flag rides in the existing TagConfig blob (the Phase A NamespaceKind migration was a one-off already shipped; Phase C adds none).
  • Build on feat/galaxy-phase-c-historian off master c9a0f627.

Authoritative touched-code list

  • src/Server/.../OpcUaServer/Phase7Composer.csEquipmentTagPlan +2 fields; ExtractTagHistorize.
  • src/Server/.../Runtime/Drivers/DeploymentArtifact.cs — byte-parity ExtractTagHistorize + thread.
  • src/Core/.../Commons/OpcUa/IOpcUaAddressSpaceSink.cs (+ NullOpcUaAddressSpaceSink, DeferredAddressSpaceSink) — EnsureVariable gains string? historianTagname.
  • src/Server/.../OpcUaServer/SdkAddressSpaceSink.cs — pass-through.
  • src/Server/.../OpcUaServer/Phase7Applier.cs — resolve + thread the tagname.
  • src/Server/.../OpcUaServer/OtOpcUaNodeManager.csHistorianDataSource prop, _historizedTagnames, _eventNotifierSources, EnsureVariable/EnsureFolderIsEventNotifier/RebuildAddressSpace edits, the HistoryRead override + mapping helpers.
  • src/Server/.../OpcUaServer/OtOpcUaSdkServer.csSetHistorianDataSource.
  • src/Core/.../Core.Abstractions/Historian/NullHistorianDataSource.cs (new).
  • src/Server/.../Runtime/Historian/ServerHistorianOptions.cs (new) + src/Server/.../Runtime/ServiceCollectionExtensions.csAddServerHistorian + Null default in AddOtOpcUaRuntime.
  • src/Server/.../Host/Program.cs + Host/OpcUa/OtOpcUaServerHostedService.cs — wire the data source.
  • Docs: new docs/Historian.md (or a Galaxy-driver note) + a CLAUDE.md pointer; appsettings.json ServerHistorian block (disabled).