From fb5ec80fe6dfedace8c78d38b6f0bf692a4338d6 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 11 Oct 2026 15:19:19 -0400 Subject: [PATCH] =?UTF-8?q?fix(historian):=20#679=20Gateway=20=E2=80=94=20?= =?UTF-8?q?Average/Count=20not=20supported=20(no=20AVEVA=20equivalent),=20?= =?UTF-8?q?TimeAverage=20=E2=86=92=20TimeWeightedAverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 2 +- docs/Client.CLI.md | 3 ++- docs/Historian.md | 15 ++++++++---- .../GatewayHistorianDataSource.cs | 4 ++-- .../Mapping/AggregateModeMapper.cs | 17 +++++++------ .../GatewayHistorianDataSourceTests.cs | 24 +++++++++++++++++++ .../Mapping/AggregateModeMapperTests.cs | 13 ++++++---- 7 files changed, 56 insertions(+), 22 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3c93a96b..935c55cf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1363,7 +1363,7 @@ historian tagname, which is the tag's driver `FullName`. The server dispatches all OPC UA HistoryRead to the registered `IHistorianDataSource` — the `GatewayHistorianDataSource` read client when enabled, else the `NullHistorianDataSource` default (historized nodes return `GoodNoData`, never an error). Supported variants: Raw, Processed -(Average/Minimum/Maximum/Total/Count aggregates), and AtTime over historized variable nodes; Events over +(backend-dependent: Gateway serves TimeAverage/Minimum/Maximum/Total, with Average/Count `BadAggregateNotSupported` since AVEVA has no mean or sample count, #679; SqlServer/QuestDb serve seventeen), and AtTime over historized variable nodes; Events over alarm-owning equipment-folder event-notifier nodes. Reads are ungated (served from any redundancy node); authorization uses the standard `AccessLevels.HistoryRead` bit set at materialization. diff --git a/docs/Client.CLI.md b/docs/Client.CLI.md index 76d1b212..66301d4b 100644 --- a/docs/Client.CLI.md +++ b/docs/Client.CLI.md @@ -210,7 +210,8 @@ otopcua-cli historyread -u opc.tcp://localhost:4840/OtOpcUa \ -n "ns=1;s=TestMachine_001.TestHistoryValue" \ --start "2026-03-25T00:00:00Z" --end "2026-03-30T00:00:00Z" -# Aggregate: 1-hour average +# Aggregate: 1-hour average (SqlServer/QuestDb; on the Gateway historian backend Average is +# BadAggregateNotSupported — use --aggregate TimeAverage there, #679) otopcua-cli historyread -u opc.tcp://localhost:4840/OtOpcUa \ -n "ns=1;s=TestMachine_001.TestHistoryValue" \ --start "2026-03-25T00:00:00Z" --end "2026-03-30T00:00:00Z" \ diff --git a/docs/Historian.md b/docs/Historian.md index 367579af..e237d115 100644 --- a/docs/Historian.md +++ b/docs/Historian.md @@ -77,7 +77,7 @@ consumes the historian. The existing enable gates (`ServerHistorian:Enabled`, `A | Value kinds recorded | `Double`, `Int64`, `Boolean` (numeric analog only) | all: `Double`, `Int64`, `Boolean`, `String`, `DateTime`, plus `Empty` (a Bad sample with no value) | same as `SqlServer` | | Status code stored | Sent as DA quality but **discarded** by the gateway (C-002); AVEVA stores every sample Good | Stored bit-for-bit (`StatusCode` int column) | Stored bit-for-bit (unsigned, in a `LONG`) | | Comms-loss / driver-Bad publishes | Dropped, counted in `DroppedUnsupportedKind` | Stored as an `Empty` outage-marker sample carrying the Bad code (see [below](#typed-values-real-status-and-outage-markers)) | Stored as an `Empty` outage-marker sample, as on `SqlServer` | -| Processed aggregates | Average, Minimum, Maximum, Total, Count | The seventeen in the [aggregate table](#aggregates-processed-reads) (incl. Part 13 time-weighted, #671) | The same seventeen, identical results | +| Processed aggregates | TimeAverage, Minimum, Maximum, Total (Average and Count: `BadAggregateNotSupported`, #679) | The seventeen in the [aggregate table](#aggregates-processed-reads) (incl. Part 13 time-weighted, #671) | The same seventeen, identical results | | HistoryUpdate | No (`BadHistoryOperationUnsupported`) | Yes (Insert / Replace / Update / DeleteRaw / DeleteAtTime) | Yes — soft delete (tombstones), see [below](#historyupdate-soft-delete) | | Modified-value history (`IsReadModified`, #668) | No (`BadHistoryOperationUnsupported`) | Yes — `SampleModified`, atomic with the change | Yes — `{prefix}sample_modified`, written before the change (orphan-on-crash) | | Alarm history | gateway `SendEvent` | `Event` table (insert-if-absent) | `{prefix}event` table (dedup upsert on `(ts, event_id)`) | @@ -745,8 +745,7 @@ otopcua-cli historyread -u opc.tcp://localhost:4840 -U apikey -P otua__ ⚠️ **#679 (client-visible): on the `Gateway` backend, `Average` and `Count` are now `BadAggregateNotSupported`.** AVEVA's 15 +> retrieval modes contain no arithmetic mean and no sample count. `Average` used to be mapped to `TimeWeightedAverage` (a different +> statistic from the OPC UA mean the SQL backends return); `Count` to `Counter`, which is a rollover-counter increase, not a sample +> count. Request **`TimeAverage`** for the time-weighted number (now mapped to `TimeWeightedAverage`). There is no Gateway sample +> count. `TimeAverage` magnitude and timestamping against live AVEVA are unverified (needs the VPN). + `HistoryAggregateType` carries the wider Part 13 set (Interpolative, TimeAverage, Start, End, Delta, Range, Min/MaxActualTime, Start/EndBound, DeltaBounds, DurationGood/Bad, PercentGood/Bad, WorstQuality, NumberOfTransitions, AnnotationCount, StandardDeviationPopulation, VariancePopulation); `OtOpcUaNodeManager.MapAggregate` maps each standard aggregate NodeId (`TimeAverage2` and `Total2` were appended for #671). The **`SqlServer`** and **`QuestDb`** backends serve the seventeen listed in the [aggregate table](#aggregates-processed-reads); everything else -(e.g. `StartBound`, `DurationGood`, `StandardDeviationPopulation`) is `BadAggregateNotSupported` for that node. The **HistorianGateway** backend only has retrieval modes for the original -five (Average, Minimum, Maximum, Total, Count): for any other member `AggregateModeMapper` throws +(e.g. `StartBound`, `DurationGood`, `StandardDeviationPopulation`) is `BadAggregateNotSupported` for that node. The **HistorianGateway** backend only has retrieval modes for the four +(TimeAverage, Minimum, Maximum, Total): for any other member `AggregateModeMapper` throws `HistoryAggregateNotSupportedException`, which the HistoryRead path turns into `BadAggregateNotSupported` for that node only (not a generic failure, and not counted as a backend fault). The OPC UA Client driver forwards every member upstream. A NodeId outside the mapped set (e.g. `StandardDeviationSample`) is still `BadAggregateNotSupported`. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianDataSource.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianDataSource.cs index afc1a105..34e25ad2 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianDataSource.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianDataSource.cs @@ -84,8 +84,8 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis var mode = AggregateModeMapper.ToRetrievalMode(aggregate); try { - // Total/Count are now native gateway retrieval modes — no client-side scaling - // (unlike the Wonderware path that derived Total as Average × interval-seconds). + // Total is a native gateway retrieval mode (Integral) — no client-side scaling. Average and Count + // have no AVEVA mode and were rejected by the mapper above (#679). var buckets = new List(); await foreach (var bucket in _client .ReadAggregateAsync(fullReference, startUtc, endUtc, mode, interval, cancellationToken) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/AggregateModeMapper.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/AggregateModeMapper.cs index 09eb3921..35654d2a 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/AggregateModeMapper.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/AggregateModeMapper.cs @@ -9,15 +9,15 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Mapping; /// /// /// -/// Average/Minimum/Maximum line up with the legacy Wonderware client's aggregate mapping. The two -/// remaining members are now served by native gateway retrieval modes: -/// → and -/// → . +/// Served: → , +/// Minimum → , Maximum → , +/// Total → . /// /// -/// This replaces the Wonderware-era client-side workarounds (Total derived as Average × interval, -/// Count approximated from a value count): no client-side scaling is performed any more, so the -/// gateway path is a strict improvement. +/// Deliberately NOT served (#679): (OPC UA Average is the arithmetic mean; +/// AVEVA's modes include no arithmetic mean — only the time-weighted one, which is TimeAverage) and +/// (AVEVA Counter is a rollover-counter increase, not a sample count). +/// Both surface as BadAggregateNotSupported rather than returning a different statistic under the requested name. /// /// internal static class AggregateModeMapper @@ -29,11 +29,10 @@ internal static class AggregateModeMapper /// An undefined enum value. public static RetrievalMode ToRetrievalMode(HistoryAggregateType aggregate) => aggregate switch { - HistoryAggregateType.Average => RetrievalMode.TimeWeightedAverage, + HistoryAggregateType.TimeAverage => RetrievalMode.TimeWeightedAverage, HistoryAggregateType.Minimum => RetrievalMode.MinimumWithTime, HistoryAggregateType.Maximum => RetrievalMode.MaximumWithTime, HistoryAggregateType.Total => RetrievalMode.Integral, - HistoryAggregateType.Count => RetrievalMode.Counter, // Part 13 aggregates the gateway has no retrieval mode for (#285) — an UNDEFINED enum value is a // programming error, but a defined-yet-unserved member is a normal client request the server // answers with BadAggregateNotSupported. diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayHistorianDataSourceTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayHistorianDataSourceTests.cs index 423d9b03..084c831e 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayHistorianDataSourceTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayHistorianDataSourceTests.cs @@ -37,6 +37,30 @@ public sealed class GatewayHistorianDataSourceTests Assert.Equal(TimeSpan.FromSeconds(60), fake.LastAggregateInterval); } + [Fact] + public async Task ReadProcessed_TimeAverage_requests_TimeWeightedAverage() + { + var fake = new FakeHistorianGatewayClient(); + var ds = new GatewayHistorianDataSource(fake, NullLogger.Instance); + await ds.ReadProcessedAsync("T", default, default, TimeSpan.FromSeconds(60), HistoryAggregateType.TimeAverage, TestContext.Current.CancellationToken); + Assert.Equal(RetrievalMode.TimeWeightedAverage, fake.LastAggregateMode); + } + + [Theory] // #679: no AVEVA equivalent — not supported, gateway never called, no backend failure recorded + [InlineData(HistoryAggregateType.Average)] + [InlineData(HistoryAggregateType.Count)] + public async Task ReadProcessed_unsupported_aggregate_throws_without_calling_gateway_or_recording_failure(HistoryAggregateType aggregate) + { + var fake = new FakeHistorianGatewayClient(); + var ds = new GatewayHistorianDataSource(fake, NullLogger.Instance); + await Assert.ThrowsAsync(() => ds.ReadProcessedAsync( + "T", default, default, TimeSpan.FromSeconds(60), aggregate, TestContext.Current.CancellationToken)); + Assert.Equal(0, fake.ReadAggregateCallCount); + var h = ds.GetHealthSnapshot(); + Assert.Equal(0, h.TotalFailures); + Assert.Equal(0, h.TotalQueries); + } + [Fact] public async Task ReadAtTime_aligns_one_snapshot_per_timestamp_with_gaps_Bad() { diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/AggregateModeMapperTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/AggregateModeMapperTests.cs index a608b500..8a8c0857 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/AggregateModeMapperTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/AggregateModeMapperTests.cs @@ -8,11 +8,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests.Mapping; public sealed class AggregateModeMapperTests { [Theory] - [InlineData(HistoryAggregateType.Average, RetrievalMode.TimeWeightedAverage)] + [InlineData(HistoryAggregateType.TimeAverage, RetrievalMode.TimeWeightedAverage)] [InlineData(HistoryAggregateType.Minimum, RetrievalMode.MinimumWithTime)] [InlineData(HistoryAggregateType.Maximum, RetrievalMode.MaximumWithTime)] [InlineData(HistoryAggregateType.Total, RetrievalMode.Integral)] - [InlineData(HistoryAggregateType.Count, RetrievalMode.Counter)] public void Maps_each_aggregate(HistoryAggregateType a, RetrievalMode expected) => Assert.Equal(expected, AggregateModeMapper.ToRetrievalMode(a)); @@ -21,8 +20,8 @@ public sealed class AggregateModeMapperTests { var original = new[] { - HistoryAggregateType.Average, HistoryAggregateType.Minimum, HistoryAggregateType.Maximum, - HistoryAggregateType.Total, HistoryAggregateType.Count, + HistoryAggregateType.Minimum, HistoryAggregateType.Maximum, + HistoryAggregateType.Total, HistoryAggregateType.TimeAverage, }; foreach (var a in Enum.GetValues()) { @@ -33,6 +32,12 @@ public sealed class AggregateModeMapperTests } } + [Theory] // #679: AVEVA has no arithmetic mean and no sample count (Counter is a rollover increase) + [InlineData(HistoryAggregateType.Average)] + [InlineData(HistoryAggregateType.Count)] + public void Average_and_Count_are_not_supported(HistoryAggregateType a) + => Assert.Throws(() => AggregateModeMapper.ToRetrievalMode(a)); + [Fact] public void Undefined_value_is_a_programming_error_not_a_not_supported() => Assert.Throws(() => AggregateModeMapper.ToRetrievalMode((HistoryAggregateType)9999));