Files
lmxopcua/docs/plans/2026-06-14-galaxy-phase-c-historian-plan.md
T

19 KiB
Raw Blame History

Galaxy Phase C — Server-side HistoryRead — Implementation Plan

For Claude: REQUIRED SUB-SKILL: use superpowers-extended-cc:subagent-driven-development (or executing-plans) to implement this plan task-by-task.

Goal: Answer OPC UA HistoryRead (Raw / Processed / AtTime / Events) for historized equipment tags by dispatching to the existing IHistorianDataSource (Wonderware TCP client). Authoring via a "isHistorized" flag in the existing TagConfig blob — no UI, no EF migration.

Architecture: Carry isHistorized/historianTagname through the Phase-B alarm-object seam (TagConfigEquipmentTagPlan → byte-parity artifact → EnsureVariable), set Historizing + AccessLevels.HistoryRead, register a NodeId→tagname map, and override CustomNodeManager2's HistoryRead surface to block-bridge onto the async data source. Config-gated DI mirrors AddAlarmHistorian; the Host supplies the Wonderware client.

Tech Stack: .NET 10, OPC Foundation Opc.Ua.Server (CustomNodeManager2), Akka-free node manager, xUnit + Shouldly.

Design: docs/plans/2026-06-14-galaxy-phase-c-historian-design.md (read for the locked decisions).


Execution notes (hard rules — every task)

  • Branch: feat/galaxy-phase-c-historian (already created off master c9a0f627). Do NOT work on master.
  • 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 historian SharedSecret into a tracked file. No force-push, no --no-verify.
  • NO EF/Configuration entity or migration change. The flag rides in TagConfig JSON.
  • NO bUnit — there is no UI in Phase C; everything here is offline-unit-testable. The live /run is deferred (Task 7).
  • Production projects are TreatWarningsAsErrors — keep the build at 0 warnings in src/.
  • Each task: write the failing test, see it fail, implement minimally, see it pass, then commit by path.

Task 0: Feature branch (already created)

Classification: trivial · Estimated implement time: ~0 min · Parallelizable with: none

Precondition met — feat/galaxy-phase-c-historian exists off master c9a0f627, design committed 5e42c1bc. No action.


Task 1: Carry isHistorized + historianTagname (composer + artifact, byte-parity)

Classification: standard · Estimated implement time: ~5 min · Parallelizable with: none

Mirror the Phase B alarm-object carrier exactly.

Files:

  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs
    • EquipmentTagPlan record (:82-91): add bool IsHistorized and string? HistorianTagname as the last two positional fields (update the doc-comment to mention they parse identically on the artifact side, like Alarm).
    • Add internal static (bool IsHistorized, string? HistorianTagname) ExtractTagHistorize(string? tagConfig) next to ExtractTagAlarm (:461): parse isHistorized (bool; absent/non-bool ⇒ false) and historianTagname (string; absent/blank/non-string ⇒ null). Never throws (catch JsonException(false, null)). Carry the byte-parity comment naming DeploymentArtifact.ExtractTagHistorize.
    • In the equipmentTags .Select(...) (:331-340): set IsHistorized / HistorianTagname from ExtractTagHistorize(t.TagConfig).
  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs
    • Add a byte-parity private static (bool, string?) ExtractTagHistorize(string? tagConfig) next to ExtractTagAlarm (:658), same parse, carrying the byte-parity comment naming the composer.
    • In BuildEquipmentTagPlans (:440-449): thread the two new fields onto the new EquipmentTagPlan(...).
  • Test: tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ExtractTagHistorizeTests.cs (new; model on ExtractTagAlarmTests.cs).
  • Test: tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactHistorizeParityTests.cs (new; model on DeploymentArtifactAliasParityTests.cs).

Steps:

  1. Failing test ExtractTagHistorizeTests: {"FullName":"T.A","isHistorized":true}(true,null); +"historianTagname":"WW.Tag"(true,"WW.Tag"); absent ⇒ (false,null); "isHistorized":false(false,null); malformed JSON / blank-tagname ⇒ (false,null) / (true,null); never throws.
  2. Run → fail (method missing). Implement ExtractTagHistorize + record fields + Select wiring. Run → pass.
  3. Failing test DeploymentArtifactHistorizeParityTests: build a snapshot with a historized tag (and one with an override tagname), run it through ConfigComposer→artifact→DeploymentArtifact decode, and assert the decoded EquipmentTagPlan.IsHistorized/HistorianTagname equal the composer's for the same input (parity). Run → fail. Implement the artifact side. Run → pass.
  4. dotnet build clean (0 warn); dotnet test the two projects green. Commit by path (Phase7Composer.cs, DeploymentArtifact.cs, the two new test files).

Task 2: Materialize historized — Historizing + HistoryRead bit + NodeId→tagname registry

Classification: standard · Estimated implement time: ~5 min · Parallelizable with: none

Files:

  • Modify: src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.csEnsureVariable signature gains a trailing string? historianTagname param (doc: null ⇒ not historized). Update the NullOpcUaAddressSpaceSink no-op impl in the same file.
  • Modify: src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs — pass the new param through.
  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs — pass-through.
  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.csSafeEnsureVariable (:294) gains the param; MaterialiseEquipmentTags (:200) computes string? historianTagname = tag.IsHistorized ? (string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.FullName : tag.HistorianTagname) : null; and passes it.
  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
    • Add private readonly ConcurrentDictionary<string,string> _historizedTagnames = new(StringComparer.Ordinal);
    • EnsureVariable (:820): take string? historianTagname; when non-null set Historizing = true, OR AccessLevels.HistoryRead into both AccessLevel+UserAccessLevel (keep the writable ReadWrite composite), and _historizedTagnames[variableNodeId] = historianTagname. When null, behavior is exactly as today (Historizing=false).
    • RebuildAddressSpace (:889): _historizedTagnames.Clear(); alongside the other clears.
  • Test: tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkAddressSpaceSinkTests.cs (extend) — or a new NodeManagerHistorizeTests.cs if cleaner. Reuse the node-manager harness in SdkAddressSpaceSinkTests / EquipmentWriteGateTests.

Steps:

  1. Failing test: materialize a variable with historianTagname:"WW.Tag" ⇒ the node's Historizing is true and (AccessLevel & AccessLevels.HistoryRead) != 0; a null tagname ⇒ Historizing false, no History bit. (Expose a read path: the existing tests already reach BaseDataVariableState via the node manager — assert on that, or add a small TryGetVariable test accessor if none exists.)
  2. Run → fail. Thread the param through all five seam files + node manager. Run → pass.
  3. Failing test (applier): a historized EquipmentTagPlan with no override ⇒ the sink receives historianTagname == FullName; with an override ⇒ receives the override. (Use a recording fake IOpcUaAddressSpaceSink.) Run → fail → implement the applier resolve → pass.
  4. Build clean; the OpcUaServer.Tests suite green (existing EnsureVariable callers updated for the new param). Commit by path.

Task 3: HistoryRead override — Raw / Processed / AtTime + NullHistorianDataSource + wiring prop

Classification: high-risk · Estimated implement time: ~6 min · Parallelizable with: none

This is the load-bearing OPC UA service-level task.

Files:

  • Create: src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/NullHistorianDataSource.cs — singleton Instance; all reads return empty HistoryReadResult/HistoricalEventsResult (empty list, null continuation); GetHealthSnapshot() returns a disabled snapshot; Dispose() no-op.
  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
    • Add private volatile IHistorianDataSource _historianDataSource = NullHistorianDataSource.Instance;
      • public IHistorianDataSource HistorianDataSource { get => _historianDataSource; set => _historianDataSource = value ?? NullHistorianDataSource.Instance; } (doc mirrors NodeWriteGateway).
    • Confirm the override surface of the installed OPCFoundation.NetStandard.Opc.Ua.Server first: query the DeepWiki MCP (OPCFoundation/UA-.NETStandard, per CLAUDE.md) AND inspect the actual base members (e.g. grep/decompile the referenced Opc.Ua.Server.CustomNodeManager2 for HistoryRead). RECOMMENDED: override the per-details protected virtuals (HistoryReadRawModified, HistoryReadProcessed, HistoryReadAtTime); FALLBACK: override the single public HistoryRead(...) and switch on details. Whatever the chosen surface, do not invoke base for nodes we own (_historizedTagnames hit); mark Processed/fill results[i].
    • Per node: resolve NodeId.Identifier (string) in _historizedTagnames; miss ⇒ BadHistoryOperationUnsupported. On hit, dispatch to _historianDataSource (block with .GetAwaiter().GetResult() in try/catch → per-node BadHistoryOperationUnsupported/ BadUnexpectedError + log; never fault the batch). Map DataValueSnapshotOpc.Ua.DataValue; wrap in HistoryData; results[i].HistoryData = new ExtensionObject(data); StatusCode = samples.Count == 0 ? GoodNoData : Good; ContinuationPoint = null. Handle releaseContinuationPoints == trueGood no-op.
    • MapAggregate(NodeId) helper → HistoryAggregateType; unknown ⇒ BadAggregateNotSupported.
    • DataValueSnapshot→DataValue mapping helper (value Variant, StatusCode, source+server ts).
  • Test: tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadTests.cs (new) — a recording/fake IHistorianDataSource.
  • Test: tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/Historian/NullHistorianDataSourceTests.cs (new).

Steps:

  1. Confirm the override surface (DeepWiki + assembly). Write NullHistorianDataSource + its test (empty results). Run → pass.
  2. Failing test (Raw): materialize a historized node; set a fake source returning 2 samples; invoke the HistoryRead override with a ReadRawModifiedDetails + one HistoryReadValueId for the node; assert the fake got (tagname == FullName, start, end, numValues), the result decodes to a HistoryData of 2 DataValues with mapped value/status/timestamps, StatusCode == Good.
  3. Run → fail. Add the prop + override + Raw path + mappers. Run → pass.
  4. Add tests + impl for: Processed (aggregate mapping, BadAggregateNotSupported on unknown), AtTime (request times passed through), default-vs-override tagname, empty ⇒ GoodNoData, non-historized node ⇒ BadHistoryOperationUnsupported, backend-throw ⇒ per-node bad + batch survives.
  5. Build clean; OpcUaServer.Tests + Core.Abstractions.Tests green. Commit by path.

Task 4: HistoryRead override — Events over equipment-folder notifiers

Classification: high-risk · Estimated implement time: ~6 min · Parallelizable with: Task 5

Files:

  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
    • Add private readonly ConcurrentDictionary<string,string> _eventNotifierSources = new(StringComparer.Ordinal);
    • EnsureFolderIsEventNotifier (:749): when _historianDataSource is not NullHistorianDataSource, OR EventNotifiers.HistoryRead into folder.EventNotifier (keep SubscribeToEvents); register _eventNotifierSources[folder.NodeId.Identifier?.ToString()] = folder.NodeId.Identifier?.ToString() (the equipment id = sourceName). (Reads the data-source field — fine; it's set before materialization in the Host. If a deployment materializes before wiring, the bit is simply absent until the next rebuild — acceptable; document it.)
    • RebuildAddressSpace: _eventNotifierSources.Clear();.
    • Events dispatch (the ReadEventDetails arm of the chosen override surface): resolve the target folder NodeId in _eventNotifierSources (miss ⇒ BadHistoryOperationUnsupported); call _historianDataSource.ReadEventsAsync(sourceName, start, end, maxEvents, ct) (block + guard); project each HistoricalEventHistoryEventFieldList per ReadEventDetails.Filter.SelectClauses — support BaseEventType operands EventId, SourceName, Time, Message, Severity; an unsupported operand ⇒ null field; wrap in HistoryEvent { Events = … }; StatusCode = Good (or GoodNoData when empty).
    • ProjectEventField(HistoricalEvent, SimpleAttributeOperand) helper.
  • Test: extend NodeManagerHistoryReadTests.cs — events arm.

Steps:

  1. Failing test: promote a folder to a notifier with a wired (fake non-Null) source; invoke the override with a ReadEventDetails (a select clause for EventId/SourceName/Time/Message/Severity) on the folder; assert the fake got (sourceName == equipmentId, …) and each returned HistoryEventFieldList carries the projected fields in select-clause order; an unsupported operand ⇒ null field; empty ⇒ GoodNoData; a non-notifier node ⇒ BadHistoryOperationUnsupported.
  2. Run → fail. Implement the _eventNotifierSources map + folder-promotion bit + the events arm + projector. Run → pass.
  3. Build clean; OpcUaServer.Tests green. Commit by path.

Task 5: DI + config + Host wiring (AddServerHistorian, SetHistorianDataSource)

Classification: standard · Estimated implement time: ~5 min · Parallelizable with: Task 4

Files:

  • Create: src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.csSectionName = "ServerHistorian"; Enabled, Host, Port, UseTls, ServerCertThumbprint, SharedSecret; Validate() warnings mirroring AlarmHistorianOptions (empty SharedSecret, etc.).
  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs
    • AddOtOpcUaRuntime: services.TryAddSingleton<IHistorianDataSource>(NullHistorianDataSource.Instance);
    • Add AddServerHistorian(this IServiceCollection, IConfiguration, Func<ServerHistorianOptions, IServiceProvider, IHistorianDataSource> factory) — bind the section; Enabled != true ⇒ no-op (Null stays); else log Validate() warnings + AddSingleton<IHistorianDataSource>(sp => factory(opts, sp)). Mirror AddAlarmHistorian.
  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs — add public bool SetHistorianDataSource(IHistorianDataSource? source) mirroring SetNodeWriteGateway.
  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs — after AddAlarmHistorian (:94), add AddServerHistorian(config, (opts,sp) => new WonderwareHistorianClient(new WonderwareHistorianClientOptions(opts.Host, opts.Port, opts.SharedSecret){ UseTls=opts.UseTls, ServerCertThumbprint=opts.ServerCertThumbprint }, sp.GetService<ILogger<WonderwareHistorianClient>>())).
  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs — after SetNodeWriteGateway (:147), resolve IHistorianDataSource from DI and _server.SetHistorianDataSource(source); in StopAsync (:166) _server?.SetHistorianDataSource(null). (The hosted service must be able to resolve IHistorianDataSource — inject it or pull from the provider exactly as the other sinks are obtained.)
  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.json — add a disabled ServerHistorian block (documented defaults). Do not put a real SharedSecret in a tracked file (leave "").
  • Test: tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/AddServerHistorianTests.cs (new; model on the existing AddAlarmHistorian DI test) — disabled ⇒ resolves NullHistorianDataSource; enabled ⇒ resolves the factory's source.

Steps:

  1. Failing DI test: ServerHistorian:Enabled=falseIHistorianDataSource is the Null instance; Enabled=true ⇒ the factory's fake. Run → fail.
  2. Implement ServerHistorianOptions + AddServerHistorian + the TryAddSingleton default + the SetHistorianDataSource setter. Run → pass.
  3. Wire the Host (Program.cs + hosted service + appsettings). Build the Host project to prove the Wonderware factory + DI compile (no unit test for the Host wiring — it's covered by the live gate). Build clean.
  4. Commit by path (do NOT stage appsettings.json secrets — the block is disabled with empty secret).

Task 6: Docs + bookkeeping

Classification: small · Estimated implement time: ~4 min · Parallelizable with: none

Files:

  • Create: docs/Historian.md — the historized-tag TagConfig schema (isHistorized, historianTagname default=FullName), the ServerHistorian config section, HistoryRead behavior (the four variants, graceful degrade to GoodNoData, the single-shot/no-continuation limitation, events-on-equipment-folders), and a Client.CLI historyread example.
  • Modify: CLAUDE.md — a short pointer under a Historian/HistoryRead note (one paragraph + the doc link).
  • Modify (disk-only, do NOT commit): pending.md — mark #2 Phase C done with the merge SHA + the deferred live gate.
  • Update: the plan's .tasks.json statuses.

Steps: Write the docs; commit docs/Historian.md + CLAUDE.md by path. Update pending.md on disk only (never staged).


Task 7: Live docker-dev /run verification — DEFERRED (user-driven)

Classification: n/a (live) · Parallelizable with: none · Depends on: Task 5

Deferred — the agent does NOT sign in and the local docker-dev rig has no Wonderware sidecar. The historian backend is the WW Historian VM (10.100.0.48) + AVEVA, so this gate is operator-driven. When run: author a historized Galaxy equipment tag (TagConfig "isHistorized":true), set ServerHistorian:Enabled=true pointed at the sidecar (Host/Port/SharedSecret/TLS), deploy on MAIN-galaxy-eq (POST http://localhost:9200/api/deployments, X-Api-Key: docker-dev-deploy-key), then Client.CLI historyread -u opc.tcp://localhost:4840 -n "ns=2;s=<equip>/<tag>" --start … --end … → samples return; a non-historized tag ⇒ BadHistoryOperationUnsupported.


Dependency graph

0 → 1 → 2 → 3 → (4 ∥ 5) → 6 → 7(deferred). Tasks 4 and 5 touch disjoint files (node-manager events arm vs. DI/Host/SdkServer) and may be dispatched concurrently after Task 3.

Done

Build clean (0 warnings in src/) + dotnet test green (new + existing suites) for Tasks 16. The live /run (Task 7) is the deferred user-driven gate.