From 3717405aa66c4ed8087dedac68bc00d6b398880d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 18 Apr 2026 05:53:01 -0400 Subject: [PATCH] =?UTF-8?q?Phase=202=20PR=207=20=E2=80=94=20wire=20IHistor?= =?UTF-8?q?yProvider.ReadProcessedAsync=20end-to-end.=20PR=205=20ported=20?= =?UTF-8?q?HistorianDataSource.ReadAggregateAsync=20into=20Galaxy.Host=20b?= =?UTF-8?q?ut=20left=20it=20internal=20=E2=80=94=20GalaxyProxyDriver.ReadP?= =?UTF-8?q?rocessedAsync=20still=20threw=20NotSupportedException,=20so=20O?= =?UTF-8?q?PC=20UA=20clients=20issuing=20HistoryReadProcessed=20requests?= =?UTF-8?q?=20against=20the=20v2=20topology=20got=20rejected=20at=20the=20?= =?UTF-8?q?driver=20boundary.=20This=20PR=20closes=20that=20gap=20by=20add?= =?UTF-8?q?ing=20two=20new=20Shared.Contracts=20messages=20(HistoryReadPro?= =?UTF-8?q?cessedRequest/Response,=20MessageKind=200x62/0x63),=20routing?= =?UTF-8?q?=20them=20through=20GalaxyFrameHandler,=20implementing=20Histor?= =?UTF-8?q?yReadProcessedAsync=20on=20all=20three=20IGalaxyBackend=20imple?= =?UTF-8?q?mentations=20(Stub/DbBacked=20return=20the=20canonical=20"pendi?= =?UTF-8?q?ng"=20Success=3Dfalse,=20MxAccessGalaxyBackend=20delegates=20to?= =?UTF-8?q?=20=5Fhistorian.ReadAggregateAsync),=20mapping=20HistorianAggre?= =?UTF-8?q?gateSample=20=E2=86=92=20GalaxyDataValue=20at=20the=20IPC=20bou?= =?UTF-8?q?ndary=20(null=20bucket=20Value=20=E2=86=92=20BadNoData=200x800E?= =?UTF-8?q?0000u,=20otherwise=20Good=200u),=20and=20flipping=20GalaxyProxy?= =?UTF-8?q?Driver.ReadProcessedAsync=20from=20the=20NotSupported=20throw?= =?UTF-8?q?=20to=20a=20real=20IPC=20call=20with=20OPC=20UA=20HistoryAggreg?= =?UTF-8?q?ateType=20enum=20mapped=20to=20Wonderware=20AnalogSummary=20col?= =?UTF-8?q?umn=20name=20on=20the=20Proxy=20side=20(Average=20=E2=86=92=20"?= =?UTF-8?q?Average",=20Minimum=20=E2=86=92=20"Minimum",=20Maximum=20?= =?UTF-8?q?=E2=86=92=20"Maximum",=20Count=20=E2=86=92=20"ValueCount",=20To?= =?UTF-8?q?tal=20=E2=86=92=20NotSupported=20since=20there's=20no=20direct?= =?UTF-8?q?=20SDK=20column=20for=20sum).=20Decision=20#13=20IPC=20data-sha?= =?UTF-8?q?pe=20stays=20intact=20=E2=80=94=20HistoryReadProcessedResponse?= =?UTF-8?q?=20carries=20GalaxyDataValue[]=20with=20the=20same=20MessagePac?= =?UTF-8?q?k=20value=20+=20OPC=20UA=20StatusCode=20+=20timestamps=20shape?= =?UTF-8?q?=20as=20the=20other=20history=20responses,=20so=20the=20Proxy's?= =?UTF-8?q?=20existing=20ToSnapshot=20helper=20handles=20the=20conversion?= =?UTF-8?q?=20without=20a=20new=20code=20path.=20MxAccessGalaxyBackend.His?= =?UTF-8?q?toryReadProcessedAsync=20guards:=20null=20historian=20=E2=86=92?= =?UTF-8?q?=20"Historian=20disabled"=20(symmetric=20with=20HistoryReadAsyn?= =?UTF-8?q?c);=20IntervalMs=20<=3D=200=20=E2=86=92=20"HistoryReadProcessed?= =?UTF-8?q?=20requires=20IntervalMs=20>=200"=20(prevents=20division-by-zer?= =?UTF-8?q?o=20inside=20the=20SDK's=20Resolution=20parameter);=20exception?= =?UTF-8?q?=20during=20SDK=20call=20=E2=86=92=20Success=3Dfalse=20Values?= =?UTF-8?q?=3D[]=20with=20the=20message=20so=20the=20Proxy=20surfaces=20it?= =?UTF-8?q?=20as=20InvalidOperationException=20with=20a=20clean=20error=20?= =?UTF-8?q?chain.=20Tests=20=E2=80=94=20HistoryReadProcessedTests=20(new,?= =?UTF-8?q?=204=20cases):=20disabled-error=20when=20historian=20null,=20re?= =?UTF-8?q?jects=20zero=20interval,=20maps=20Good=20sample=20with=20Value?= =?UTF-8?q?=3D12.34=20and=20the=20Proxy-supplied=20AggregateColumn=20+=20I?= =?UTF-8?q?ntervalMs=20flow=20unchanged=20through=20to=20the=20fake=20IHis?= =?UTF-8?q?torianDataSource,=20maps=20null=20Value=20bucket=20to=200x800E0?= =?UTF-8?q?000u=20BadNoData=20with=20null=20ValueBytes.=20AggregateColumnM?= =?UTF-8?q?appingTests=20(new,=205=20cases=20in=20Proxy.Tests):=20theory?= =?UTF-8?q?=20covers=20all=204=20supported=20HistoryAggregateType=20enum?= =?UTF-8?q?=20values=20=E2=86=92=20correct=20column=20string,=20and=20asse?= =?UTF-8?q?rts=20Total=20throws=20NotSupportedException=20with=20a=20messa?= =?UTF-8?q?ge=20that=20steers=20callers=20to=20Average/Minimum/Maximum/Cou?= =?UTF-8?q?nt=20(the=20SDK's=20AnalogSummaryQueryResult=20doesn't=20expose?= =?UTF-8?q?=20a=20sum=20column=20=E2=80=94=20the=20closest=20is=20Average?= =?UTF-8?q?=20=C3=97=20ValueCount=20which=20is=20the=20responsibility=20of?= =?UTF-8?q?=20a=20caller-side=20aggregation=20rather=20than=20an=20extra?= =?UTF-8?q?=20IPC=20round-trip).=20InternalsVisibleTo=20added=20to=20Galax?= =?UTF-8?q?y.Proxy=20csproj=20so=20Proxy.Tests=20can=20reach=20the=20inter?= =?UTF-8?q?nal=20MapAggregateToColumn=20static.=20Builds=20=E2=80=94=20Gal?= =?UTF-8?q?axy.Host=20(net48=20x86)=20+=20Galaxy.Proxy=20(net10)=20both=20?= =?UTF-8?q?0=20errors,=20full=20solution=20201=20warnings=20(pre-existing)?= =?UTF-8?q?=20/=200=20errors.=20Test=20counts=20=E2=80=94=20Host.Tests=20U?= =?UTF-8?q?nit=20suite:=2028=20pass=20(4=20new=20processed=20+=209=20PR5?= =?UTF-8?q?=20historian=20+=2015=20pre-existing);=20Proxy.Tests=20Unit=20s?= =?UTF-8?q?uite:=2014=20pass=20(5=20new=20column-mapping=20+=209=20pre-exi?= =?UTF-8?q?sting).=20Deferred=20to=20a=20later=20PR=20=E2=80=94=20ReadAtTi?= =?UTF-8?q?me=20+=20ReadEvents=20+=20Health=20IPC=20surfaces=20(HistorianD?= =?UTF-8?q?ataSource=20has=20them=20ported=20in=20PR=205=20but=20they=20ne?= =?UTF-8?q?ed=20additional=20contract=20messages=20and=20would=20push=20th?= =?UTF-8?q?is=20PR=20past=20a=20comfortable=20review=20size);=20the=20alar?= =?UTF-8?q?m=20subsystem=20wire-up=20(OnAlarmEvent=20raising=20from=20MxAc?= =?UTF-8?q?cessGalaxyBackend)=20which=20overlaps=20the=20ReadEventsAsync?= =?UTF-8?q?=20IPC=20work=20since=20both=20pull=20from=20HistorianAccess.Cr?= =?UTF-8?q?eateEventQuery=20on=20the=20SDK=20side;=20the=20Proxy-side=20qu?= =?UTF-8?q?ality-byte=20refinement=20where=20HistorianDataSource's=20per-s?= =?UTF-8?q?ample=20raw=20quality=20byte=20gets=20decoded=20through=20the?= =?UTF-8?q?=20existing=20QualityMapper=20instead=20of=20the=20category-onl?= =?UTF-8?q?y=20mapping=20in=20ToWire(HistorianSample)=20=E2=80=94=20doesn'?= =?UTF-8?q?t=20change=20correctness=20today=20since=20Good/Uncertain/Bad?= =?UTF-8?q?=20categories=20are=20all=20the=20Admin=20UI=20and=20OPC=20UA?= =?UTF-8?q?=20clients=20surface,=20but=20richer=20OPC=20DA=20status=20code?= =?UTF-8?q?s=20(BadNotConnected,=20UncertainSubNormal,=20etc.)=20are=20ava?= =?UTF-8?q?ilable=20on=20the=20wire=20and=20the=20Proxy=20could=20promote?= =?UTF-8?q?=20them=20before=20handing=20DataValueSnapshot=20to=20ISubscrib?= =?UTF-8?q?able=20consumers.=20This=20PR=20branches=20off=20phase-2-pr5-hi?= =?UTF-8?q?storian=20because=20it=20directly=20extends=20the=20Historian?= =?UTF-8?q?=20IPC=20surface=20added=20there;=20if=20PR=205=20merges=20firs?= =?UTF-8?q?t=20PR=207=20fast-forwards,=20otherwise=20it=20needs=20a=20reba?= =?UTF-8?q?se=20after=20PR=205=20lands.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Backend/DbBackedGalaxyBackend.cs | 9 + .../Backend/IGalaxyBackend.cs | 1 + .../Backend/MxAccessGalaxyBackend.cs | 57 +++++++ .../Backend/StubGalaxyBackend.cs | 9 + .../Ipc/GalaxyFrameHandler.cs | 7 + .../GalaxyProxyDriver.cs | 44 ++++- ....MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj | 4 + .../Contracts/Framing.cs | 2 + .../Contracts/History.cs | 24 +++ .../HistoryReadProcessedTests.cs | 158 ++++++++++++++++++ .../AggregateColumnMappingTests.cs | 27 +++ 11 files changed, 340 insertions(+), 2 deletions(-) create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/HistoryReadProcessedTests.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/AggregateColumnMappingTests.cs diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/DbBackedGalaxyBackend.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/DbBackedGalaxyBackend.cs index 95a626b..9c505db 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/DbBackedGalaxyBackend.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/DbBackedGalaxyBackend.cs @@ -127,6 +127,15 @@ public sealed class DbBackedGalaxyBackend(GalaxyRepository repository) : IGalaxy Tags = System.Array.Empty(), }); + public Task HistoryReadProcessedAsync( + HistoryReadProcessedRequest req, CancellationToken ct) + => Task.FromResult(new HistoryReadProcessedResponse + { + Success = false, + Error = "MXAccess + Historian code lift pending (Phase 2 Task B.1)", + Values = System.Array.Empty(), + }); + public Task RecycleAsync(RecycleHostRequest req, CancellationToken ct) => Task.FromResult(new RecycleStatusResponse { Accepted = true, GraceSeconds = 15 }); diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/IGalaxyBackend.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/IGalaxyBackend.cs index b4c0a93..5739146 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/IGalaxyBackend.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/IGalaxyBackend.cs @@ -38,6 +38,7 @@ public interface IGalaxyBackend Task AcknowledgeAlarmAsync(AlarmAckRequest req, CancellationToken ct); Task HistoryReadAsync(HistoryReadRequest req, CancellationToken ct); + Task HistoryReadProcessedAsync(HistoryReadProcessedRequest req, CancellationToken ct); Task RecycleAsync(RecycleHostRequest req, CancellationToken ct); } diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccessGalaxyBackend.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccessGalaxyBackend.cs index 7cd543a..eb38e52 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccessGalaxyBackend.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccessGalaxyBackend.cs @@ -264,6 +264,48 @@ public sealed class MxAccessGalaxyBackend : IGalaxyBackend, IDisposable } } + public async Task HistoryReadProcessedAsync( + HistoryReadProcessedRequest req, CancellationToken ct) + { + if (_historian is null) + return new HistoryReadProcessedResponse + { + Success = false, + Error = "Historian disabled — no OTOPCUA_HISTORIAN_ENABLED configuration", + Values = Array.Empty(), + }; + + if (req.IntervalMs <= 0) + return new HistoryReadProcessedResponse + { + Success = false, + Error = "HistoryReadProcessed requires IntervalMs > 0", + Values = Array.Empty(), + }; + + var start = DateTimeOffset.FromUnixTimeMilliseconds(req.StartUtcUnixMs).UtcDateTime; + var end = DateTimeOffset.FromUnixTimeMilliseconds(req.EndUtcUnixMs).UtcDateTime; + + try + { + var samples = await _historian.ReadAggregateAsync( + req.TagReference, start, end, req.IntervalMs, req.AggregateColumn, ct).ConfigureAwait(false); + + var wire = samples.Select(s => ToWire(req.TagReference, s)).ToArray(); + return new HistoryReadProcessedResponse { Success = true, Values = wire }; + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + return new HistoryReadProcessedResponse + { + Success = false, + Error = $"Historian aggregate read failed: {ex.Message}", + Values = Array.Empty(), + }; + } + } + public Task RecycleAsync(RecycleHostRequest req, CancellationToken ct) => Task.FromResult(new RecycleStatusResponse { Accepted = true, GraceSeconds = 15 }); @@ -305,6 +347,21 @@ public sealed class MxAccessGalaxyBackend : IGalaxyBackend, IDisposable return 0x80000000u; // Bad } + /// + /// Maps a (one aggregate bucket) to the IPC wire + /// shape. A null means the aggregate was + /// unavailable for the bucket — the Proxy translates that to OPC UA BadNoData. + /// + private static GalaxyDataValue ToWire(string reference, HistorianAggregateSample sample) => new() + { + TagReference = reference, + ValueBytes = sample.Value is null ? null : MessagePackSerializer.Serialize(sample.Value.Value), + ValueMessagePackType = 0, + StatusCode = sample.Value is null ? 0x800E0000u /* BadNoData */ : 0x00000000u, + SourceTimestampUtcUnixMs = new DateTimeOffset(sample.TimestampUtc, TimeSpan.Zero).ToUnixTimeMilliseconds(), + ServerTimestampUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + }; + private static GalaxyAttributeInfo MapAttribute(GalaxyAttributeRow row) => new() { AttributeName = row.AttributeName, diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/StubGalaxyBackend.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/StubGalaxyBackend.cs index bff89fe..27da0a4 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/StubGalaxyBackend.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/StubGalaxyBackend.cs @@ -85,6 +85,15 @@ public sealed class StubGalaxyBackend : IGalaxyBackend Tags = System.Array.Empty(), }); + public Task HistoryReadProcessedAsync( + HistoryReadProcessedRequest req, CancellationToken ct) + => Task.FromResult(new HistoryReadProcessedResponse + { + Success = false, + Error = "stub: MXAccess code lift pending (Phase 2 Task B.1)", + Values = System.Array.Empty(), + }); + public Task RecycleAsync(RecycleHostRequest req, CancellationToken ct) => Task.FromResult(new RecycleStatusResponse { diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Ipc/GalaxyFrameHandler.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Ipc/GalaxyFrameHandler.cs index a406c04..7d82808 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Ipc/GalaxyFrameHandler.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Ipc/GalaxyFrameHandler.cs @@ -80,6 +80,13 @@ public sealed class GalaxyFrameHandler(IGalaxyBackend backend, ILogger logger) : await writer.WriteAsync(MessageKind.HistoryReadResponse, resp, ct); return; } + case MessageKind.HistoryReadProcessedRequest: + { + var resp = await backend.HistoryReadProcessedAsync( + Deserialize(body), ct); + await writer.WriteAsync(MessageKind.HistoryReadProcessedResponse, resp, ct); + return; + } case MessageKind.RecycleHostRequest: { var resp = await backend.RecycleAsync(Deserialize(body), ct); diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/GalaxyProxyDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/GalaxyProxyDriver.cs index ee4a2d1..41086cb 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/GalaxyProxyDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/GalaxyProxyDriver.cs @@ -296,10 +296,50 @@ public sealed class GalaxyProxyDriver(GalaxyProxyOptions options) return new HistoryReadResult(samples, ContinuationPoint: null); } - public Task ReadProcessedAsync( + public async Task ReadProcessedAsync( string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval, HistoryAggregateType aggregate, CancellationToken cancellationToken) - => throw new NotSupportedException("Galaxy historian processed reads are not supported in v2; use ReadRawAsync."); + { + var client = RequireClient(); + var column = MapAggregateToColumn(aggregate); + + var resp = await client.CallAsync( + MessageKind.HistoryReadProcessedRequest, + new HistoryReadProcessedRequest + { + SessionId = _sessionId, + TagReference = fullReference, + StartUtcUnixMs = new DateTimeOffset(startUtc, TimeSpan.Zero).ToUnixTimeMilliseconds(), + EndUtcUnixMs = new DateTimeOffset(endUtc, TimeSpan.Zero).ToUnixTimeMilliseconds(), + IntervalMs = (long)interval.TotalMilliseconds, + AggregateColumn = column, + }, + MessageKind.HistoryReadProcessedResponse, + cancellationToken); + + if (!resp.Success) + throw new InvalidOperationException($"Galaxy.Host HistoryReadProcessed failed: {resp.Error}"); + + IReadOnlyList samples = [.. resp.Values.Select(ToSnapshot)]; + return new HistoryReadResult(samples, ContinuationPoint: null); + } + + /// + /// Maps the OPC UA Part 13 aggregate enum onto the Wonderware Historian + /// AnalogSummaryQuery column names consumed by HistorianDataSource.ReadAggregateAsync. + /// Kept on the Proxy side so Galaxy.Host stays OPC-UA-free. + /// + internal static string MapAggregateToColumn(HistoryAggregateType aggregate) => aggregate switch + { + HistoryAggregateType.Average => "Average", + HistoryAggregateType.Minimum => "Minimum", + HistoryAggregateType.Maximum => "Maximum", + HistoryAggregateType.Count => "ValueCount", + HistoryAggregateType.Total => throw new NotSupportedException( + "HistoryAggregateType.Total is not supported by the Wonderware Historian AnalogSummary " + + "query — use Average × Count on the caller side, or switch to Average/Minimum/Maximum/Count."), + _ => throw new NotSupportedException($"Unknown HistoryAggregateType {aggregate}"), + }; // ---- IRediscoverable ---- diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj index 6859a4c..47dadcc 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj @@ -16,6 +16,10 @@ + + + + diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/Framing.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/Framing.cs index 9694762..2a17478 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/Framing.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/Framing.cs @@ -50,6 +50,8 @@ public enum MessageKind : byte HistoryReadRequest = 0x60, HistoryReadResponse = 0x61, + HistoryReadProcessedRequest = 0x62, + HistoryReadProcessedResponse = 0x63, HostConnectivityStatus = 0x70, RuntimeStatusChange = 0x71, diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/History.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/History.cs index 6f10fe4..6990692 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/History.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/History.cs @@ -26,3 +26,27 @@ public sealed class HistoryReadResponse [Key(1)] public string? Error { get; set; } [Key(2)] public HistoryTagValues[] Tags { get; set; } = System.Array.Empty(); } + +/// +/// Processed (aggregated) historian read — OPC UA HistoryReadProcessed service. The +/// aggregate column is a string (e.g. "Average", "Minimum") mapped by the Proxy from the +/// OPC UA HistoryAggregateType enum so Galaxy.Host stays OPC-UA-free. +/// +[MessagePackObject] +public sealed class HistoryReadProcessedRequest +{ + [Key(0)] public long SessionId { get; set; } + [Key(1)] public string TagReference { get; set; } = string.Empty; + [Key(2)] public long StartUtcUnixMs { get; set; } + [Key(3)] public long EndUtcUnixMs { get; set; } + [Key(4)] public long IntervalMs { get; set; } + [Key(5)] public string AggregateColumn { get; set; } = "Average"; +} + +[MessagePackObject] +public sealed class HistoryReadProcessedResponse +{ + [Key(0)] public bool Success { get; set; } + [Key(1)] public string? Error { get; set; } + [Key(2)] public GalaxyDataValue[] Values { get; set; } = System.Array.Empty(); +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/HistoryReadProcessedTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/HistoryReadProcessedTests.cs new file mode 100644 index 0000000..7cca434 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/HistoryReadProcessedTests.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MessagePack; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Historian; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Sta; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests; + +[Trait("Category", "Unit")] +public sealed class HistoryReadProcessedTests +{ + [Fact] + public async Task ReturnsDisabledError_When_NoHistorianConfigured() + { + using var pump = new StaPump("Test.Sta"); + await pump.WaitForStartedAsync(); + var mx = new MxAccessClient(pump, new MxProxyAdapter(), "processed-test"); + using var backend = new MxAccessGalaxyBackend( + new GalaxyRepository(new GalaxyRepositoryOptions { ConnectionString = "Server=.;Database=ZB;Integrated Security=True;" }), + mx, + historian: null); + + var resp = await backend.HistoryReadProcessedAsync(new HistoryReadProcessedRequest + { + TagReference = "T", + StartUtcUnixMs = 0, + EndUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + IntervalMs = 1000, + AggregateColumn = "Average", + }, CancellationToken.None); + + resp.Success.ShouldBeFalse(); + resp.Error.ShouldContain("Historian disabled"); + } + + [Fact] + public async Task Rejects_NonPositiveInterval() + { + using var pump = new StaPump("Test.Sta"); + await pump.WaitForStartedAsync(); + var mx = new MxAccessClient(pump, new MxProxyAdapter(), "processed-test"); + var fake = new FakeHistorianDataSource(); + using var backend = new MxAccessGalaxyBackend( + new GalaxyRepository(new GalaxyRepositoryOptions { ConnectionString = "Server=.;Database=ZB;Integrated Security=True;" }), + mx, + fake); + + var resp = await backend.HistoryReadProcessedAsync(new HistoryReadProcessedRequest + { + TagReference = "T", + IntervalMs = 0, + AggregateColumn = "Average", + }, CancellationToken.None); + + resp.Success.ShouldBeFalse(); + resp.Error.ShouldContain("IntervalMs"); + } + + [Fact] + public async Task Maps_AggregateSample_With_Value_To_Good() + { + using var pump = new StaPump("Test.Sta"); + await pump.WaitForStartedAsync(); + var mx = new MxAccessClient(pump, new MxProxyAdapter(), "processed-test"); + var fake = new FakeHistorianDataSource(new HistorianAggregateSample + { + Value = 12.34, + TimestampUtc = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc), + }); + using var backend = new MxAccessGalaxyBackend( + new GalaxyRepository(new GalaxyRepositoryOptions { ConnectionString = "Server=.;Database=ZB;Integrated Security=True;" }), + mx, + fake); + + var resp = await backend.HistoryReadProcessedAsync(new HistoryReadProcessedRequest + { + TagReference = "T", + StartUtcUnixMs = 0, + EndUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + IntervalMs = 60_000, + AggregateColumn = "Average", + }, CancellationToken.None); + + resp.Success.ShouldBeTrue(); + resp.Values.Length.ShouldBe(1); + resp.Values[0].StatusCode.ShouldBe(0u); // Good + resp.Values[0].ValueBytes.ShouldNotBeNull(); + MessagePackSerializer.Deserialize(resp.Values[0].ValueBytes!).ShouldBe(12.34); + fake.LastAggregateColumn.ShouldBe("Average"); + fake.LastIntervalMs.ShouldBe(60_000d); + } + + [Fact] + public async Task Maps_Null_Bucket_To_BadNoData() + { + using var pump = new StaPump("Test.Sta"); + await pump.WaitForStartedAsync(); + var mx = new MxAccessClient(pump, new MxProxyAdapter(), "processed-test"); + var fake = new FakeHistorianDataSource(new HistorianAggregateSample + { + Value = null, + TimestampUtc = DateTime.UtcNow, + }); + using var backend = new MxAccessGalaxyBackend( + new GalaxyRepository(new GalaxyRepositoryOptions { ConnectionString = "Server=.;Database=ZB;Integrated Security=True;" }), + mx, + fake); + + var resp = await backend.HistoryReadProcessedAsync(new HistoryReadProcessedRequest + { + TagReference = "T", + IntervalMs = 1000, + AggregateColumn = "Minimum", + }, CancellationToken.None); + + resp.Success.ShouldBeTrue(); + resp.Values.Length.ShouldBe(1); + resp.Values[0].StatusCode.ShouldBe(0x800E0000u); // BadNoData + resp.Values[0].ValueBytes.ShouldBeNull(); + } + + private sealed class FakeHistorianDataSource : IHistorianDataSource + { + private readonly HistorianAggregateSample[] _samples; + public string? LastAggregateColumn { get; private set; } + public double LastIntervalMs { get; private set; } + + public FakeHistorianDataSource(params HistorianAggregateSample[] samples) => _samples = samples; + + public Task> ReadRawAsync(string tag, DateTime s, DateTime e, int max, CancellationToken ct) + => Task.FromResult(new List()); + + public Task> ReadAggregateAsync( + string tag, DateTime s, DateTime e, double intervalMs, string col, CancellationToken ct) + { + LastAggregateColumn = col; + LastIntervalMs = intervalMs; + return Task.FromResult(new List(_samples)); + } + + public Task> ReadAtTimeAsync(string tag, DateTime[] ts, CancellationToken ct) + => Task.FromResult(new List()); + + public Task> ReadEventsAsync(string? src, DateTime s, DateTime e, int max, CancellationToken ct) + => Task.FromResult(new List()); + + public HistorianHealthSnapshot GetHealthSnapshot() => new(); + public void Dispose() { } + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/AggregateColumnMappingTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/AggregateColumnMappingTests.cs new file mode 100644 index 0000000..85a094d --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/AggregateColumnMappingTests.cs @@ -0,0 +1,27 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests; + +[Trait("Category", "Unit")] +public sealed class AggregateColumnMappingTests +{ + [Theory] + [InlineData(HistoryAggregateType.Average, "Average")] + [InlineData(HistoryAggregateType.Minimum, "Minimum")] + [InlineData(HistoryAggregateType.Maximum, "Maximum")] + [InlineData(HistoryAggregateType.Count, "ValueCount")] + public void Maps_OpcUa_enum_to_AnalogSummary_column(HistoryAggregateType aggregate, string expected) + { + GalaxyProxyDriver.MapAggregateToColumn(aggregate).ShouldBe(expected); + } + + [Fact] + public void Total_is_not_supported() + { + Should.Throw( + () => GalaxyProxyDriver.MapAggregateToColumn(HistoryAggregateType.Total)); + } +}