From 0e47e887debc2a05599a40b56f5c3f4d47ffbdb0 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 12 Oct 2026 16:17:05 -0400 Subject: [PATCH] fix(historian): #691.2 serve reverse native TimeAverage/Total on Gateway via forward reads on the later-anchored #677 grid --- CLAUDE.md | 2 +- docs/Historian.md | 15 +- .../GatewayHistorianDataSource.cs | 45 ++++- .../NativeReverseAggregatePlan.cs | 103 +++++++++++ .../FakeHistorianGatewayClient.cs | 18 +- .../GatewayNativeAggregateRowsTests.cs | 4 +- .../GatewayReverseNativeAggregateTests.cs | 168 ++++++++++++++++++ 7 files changed, 349 insertions(+), 6 deletions(-) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/NativeReverseAggregatePlan.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayReverseNativeAggregateTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index d732c194..c5e0b0f4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1365,7 +1365,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` for Raw/AtTime — Processed is `BadAggregateNotSupported` since #682). Supported variants: Raw (⚠️ #687, client-visible: Gateway raw reads no longer return AVEVA's synthetic start-boundary row — asked from 1 ms early (epoch-floored) with `NumValuesPerNode + 16` rows (unclamped; 0 ⇒ `int.MaxValue`) and filtered by timestamp — so an empty window is `GoodNoData`, not a fabricated value at StartTime; the forward end is now exclusive (a point read inclusive); pre-window rows that crowd the budget trigger ONE re-read with room for them, else `BadTooManyOperations`; `ReturnBounds` is ignored on every backend; ⚠️ #690: a Gateway Bad row — a stored NULL, which histsdk sends as value 0 — now reads value **null** + Bad on Raw and AtTime, as on the SQL backends; ⚠️ #691.3: a strict gateway (`AllowStreamingBeyondCeiling=false`) that refuses a raw budget is re-asked ONCE at the ceiling its refusal names — raw and per-sample reads alike — so an uncapped (`NumValuesPerNode = 0`) read of a small window is served; a ceiling-bounded stream that is not provably complete is `BadTooManyOperations`, never a truncated answer), Processed -(backend-dependent: SqlServer/QuestDb serve seventeen; Gateway twelve = `GatewayHistorianDataSource.ServedAggregates` — ⚠️ #681, client-visible: the ten per-sample ones (Average, Count, Min/Max, Range, Start/End/Delta, Min/MaxActualTime) are evaluated from RAW gateway reads by the shared `Sql.Common` evaluator — identical to the SQL backends, Calculated bits, interval-start stamps, #677 reverse; Min/Max are no longer AVEVA modes, the read moves every raw sample of the window (a month of 1 Hz ⇒ `BadTooManyOperations` at the default `MaxTimeWeightedRawRows` cap), and every gateway stream is consumed to completion (an abandoned one makes the gateway evict its shared historian session) — plus TimeAverage/Total via AVEVA modes (interval-start stamped + Calculated; ⚠️ #685: AVEVA's extra pre-window row is dropped ⇒ one row per interval in `[Start, End)`, and an Uncertain (opc 64) interval keeps its value as `UncertainDataSubNormal` — AVEVA's TWA excludes NULL-gap time where the SQL backends bridge it; per-interval values still need gateway fix historiangw#27); the other time-weighted five stay `BadAggregateNotSupported` because the gateway has no prior-sample read for Part 13 bounds; `docs/Historian.md` §Aggregate set), and AtTime over historized variable nodes; Events over +(backend-dependent: SqlServer/QuestDb serve seventeen; Gateway twelve = `GatewayHistorianDataSource.ServedAggregates` — ⚠️ #681, client-visible: the ten per-sample ones (Average, Count, Min/Max, Range, Start/End/Delta, Min/MaxActualTime) are evaluated from RAW gateway reads by the shared `Sql.Common` evaluator — identical to the SQL backends, Calculated bits, interval-start stamps, #677 reverse; Min/Max are no longer AVEVA modes, the read moves every raw sample of the window (a month of 1 Hz ⇒ `BadTooManyOperations` at the default `MaxTimeWeightedRawRows` cap), and every gateway stream is consumed to completion (an abandoned one makes the gateway evict its shared historian session) — plus TimeAverage/Total via AVEVA modes (interval-start stamped + Calculated; ⚠️ #685: AVEVA's extra pre-window row is dropped ⇒ one row per interval in `[Start, End)`, and an Uncertain (opc 64) interval keeps its value as `UncertainDataSubNormal` — AVEVA's TWA excludes NULL-gap time where the SQL backends bridge it; per-interval values still need gateway fix historiangw#27; ⚠️ #691.2: a REVERSE native read — refused by the gateway before — is now served by forward native requests on the later-anchored #677 grid (whole intervals in one read, the clipped earliest interval as its own exact-span read, Partial unless Bad), newest first, later-edge stamped); the other time-weighted five stay `BadAggregateNotSupported` because the gateway has no prior-sample read for Part 13 bounds; `docs/Historian.md` §Aggregate set), 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/Historian.md b/docs/Historian.md index 78fce238..44325087 100644 --- a/docs/Historian.md +++ b/docs/Historian.md @@ -880,8 +880,19 @@ fixed: `HistorianTagDefinition.IntegralDivisor` is `0`, so `GatewayTagProvisioner` now sends **`IntegralDivisor = 1`** explicitly on every `EnsureTags` — the value·seconds unit the SQL backends' `Total` uses. A tag provisioned earlier keeps its divisor until its next `EnsureTags` (an upsert). The live magnitude check against AVEVA (a constant 1 integrated over 1 s reads 1) passed on 2026-10-12 at the AVEVA -level; end to end it waits on historiangw#27 (above). Reverse processed reads of the AVEVA-native `TimeAverage`/`Total` are not re-anchored by #677 (the gateway refuses `end < start`); -the raw-evaluated per-sample aggregates (#681) do follow #677. Since #681 every native bucket is stamped with its interval **start** +level; end to end it waits on historiangw#27 (above). ⚠️ **Reverse native reads (#691.2, client-visible).** A reverse processed read (`StartTime > EndTime`) of the AVEVA-native +`TimeAverage`/`Total` used to reach the gateway as `start > end` and fail the node (`InvalidArgument`, counted as a backend failure). It +now follows #677 like the SQL backends and the raw-evaluated per-sample aggregates (#681): window `(EndTime, StartTime]`, intervals +stepping back from `StartTime`, the clipped interval the **earliest**, each stamped with its **later** edge, newest first. It is served +by **forward** native requests on that later-anchored grid (`NativeReverseAggregatePlan` — AVEVA anchors a native read's grid at the +request start): one read `[O + I·f, StartTime)` with the interval `I` for the whole intervals (`O = StartTime − n·I`), and — when the +window is not a whole number of intervals — a second read over exactly the clipped earliest span `[EndTime, O + I)` with its own width +as the interval, so AVEVA never averages time before the window. A time-weighted value over `[a, b)` and over `(a, b]` is the same +integral (two instants apart), so each forward row is the reverse interval's value; rows are mapped exactly as forward native rows +(#685) and the clipped interval carries the **Partial** bit unless Bad (Part 13 §5.3.3.2 Case 3, as on the SQL backends — the forward +native read still sets no Partial bit). An interval AVEVA answers no row for is absent, as on a forward native read. ⚠️ Pinned against +a native-mode fake only: under historiangw#27 AVEVA answers one row per request today, so end to end a reverse native read is as wrong +as a forward one until the gateway is fixed — re-run the reverse leg then. Since #681 every native bucket is stamped with its interval **start** (Part 13; it was the interval end) and carries the Calculated info bits (`Good` ⇒ `0x00000401`, no data ⇒ `BadNoData | 0x401`). The live AVEVA gate (2026-10-12) confirmed `start_time` is the interval start (and the `value·s` unit). Previously noted as pending: it reads the aggregate row's `start_time`, which histsdk decodes from a row 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 09001a6e..3600a70f 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 @@ -249,7 +249,9 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis /// /// TimeAverage / Total stay native AVEVA retrieval modes (), stamped with /// the interval start and the Calculated bit; AVEVA's extra row for the interval before the window is dropped and - /// an Uncertain interval keeps its value (#685, ). Everything else is + /// an Uncertain interval keeps its value (#685, ). A reverse read + /// (start > end, which the gateway refuses) is served by forward requests on the later-anchored #677 grid + /// (#691.2, ). Everything else is /// (see ). /// /// @@ -266,6 +268,9 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis // Mapped OUTSIDE the failure-recording try: an aggregate the gateway has no mode for is a client // request the server answers BadAggregateNotSupported, not a backend fault to count (#285). var mode = AggregateModeMapper.ToRetrievalMode(aggregate); + if (startUtc > endUtc) + return await ReadNativeReverseAsync(fullReference, BucketPlan.Create(startUtc, endUtc, interval), mode, cancellationToken) + .ConfigureAwait(false); try { // TimeAverage / Total are native gateway retrieval modes (TimeWeightedAverage / Integral) — no @@ -294,6 +299,44 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis } } + /// + /// A reverse (start > end) native TimeAverage / Total read (#691.2), anchored at its later time as on the SQL + /// backends (#677) — the gateway refuses start > end, so it is served by forward native requests on the + /// later-anchored grid. See . + /// + private async Task ReadNativeReverseAsync(string fullReference, BucketPlan plan, RetrievalMode mode, + CancellationToken cancellationToken) + { + if (plan.BucketCount == 0) return new HistoryReadResult([], ContinuationPoint: null); + try + { + var byBucket = new Dictionary(); + foreach (var request in NativeReverseAggregatePlan.Requests(plan)) + { + var buckets = new List(); + await foreach (var bucket in _client + .ReadAggregateAsync(fullReference, request.StartUtc, request.EndUtc, mode, request.Interval, cancellationToken) + .ConfigureAwait(false)) + { + buckets.Add(bucket); // drained to completion, mapped afterwards + } + NativeReverseAggregatePlan.Place(plan, request, buckets, byBucket); + } + + RecordOutcome(success: true, error: null); + return new HistoryReadResult(NativeReverseAggregatePlan.Assemble(plan, byBucket), ContinuationPoint: null); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; // caller-requested, not a backend fault + } + catch (Exception ex) + { + RecordReadFailure(ex); + throw; + } + } + /// Serves a per-sample aggregate from a raw read (#681); see . private async Task ReadProcessedFromRawAsync( string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval, diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/NativeReverseAggregatePlan.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/NativeReverseAggregatePlan.cs new file mode 100644 index 00000000..ab1d29e6 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/NativeReverseAggregatePlan.cs @@ -0,0 +1,103 @@ +using ZB.MOM.WW.HistorianGateway.Contracts.Grpc; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Mapping; +using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway; + +/// +/// #691.2: serves a REVERSE (start > end) processed read of the AVEVA-native TimeAverage / Total +/// with the #677 semantics the SQL backends give it — window (end, start], intervals stepping back from the +/// request's later time, the clipped interval the EARLIEST, each result stamped with its later edge, newest first +/// (, reversed) — although the gateway refuses start > end. +/// +/// +/// +/// Forward requests on the later-anchored grid. AVEVA anchors a native read's interval grid at the request +/// start (live, 2026-10-12: rows at start + j·interval, plus one extra row for the interval before it, #685). +/// So the whole intervals of the reverse plan — (O + k·I, O + (k+1)·I] with O = later − n·I — are +/// requested as ONE forward read [O + I·f, later) with interval I (f = 1 when the earliest +/// interval is clipped, else 0), and the clipped earliest interval (earlier, O + I] as a second forward read +/// over exactly that span with its own width as the interval — so AVEVA computes it over the clipped span only, never +/// over time before the window. Neither request is ever reversed. +/// +/// +/// Soundness. A time-weighted average / integral over [a, b) and over (a, b] integrate the same +/// function over sets differing by two instants (measure zero), so a forward native row IS the value of the +/// reverse interval with the same edges. The one place the half-open side could matter is AVEVA's status for an +/// interval whose only non-good sample lies exactly on an edge — accepted, as on every native row. Each row is +/// mapped exactly as a forward native row (: Uncertain keeps its value, +/// no data ⇒ BadNoData, Calculated bits), restamped with its interval's LATER edge; the clipped interval +/// carries the Partial bit unless Bad (Part 13 §5.3.3.2 Case 3, as the SQL backends' time-weighted aggregates). +/// +/// +/// Missing rows are not invented. As on a forward native read, an interval AVEVA answers no row for is absent +/// from the result (not a fabricated BadNoData). Today historiangw#27 (the interval reaches AVEVA in ticks +/// where it reads milliseconds) makes AVEVA answer ONE row per request — the whole-intervals read lands on its +/// earliest interval and the rest are absent — exactly as wrong as a forward native read; this mapping needs no +/// change once the gateway is fixed. +/// +/// +internal static class NativeReverseAggregatePlan +{ + /// One forward native request and the ascending bucket index of its first interval. + /// Forward request start (inclusive). + /// Forward request end. + /// The request's interval. + /// Ascending plan bucket of the request's first interval. + /// Number of plan buckets the request covers. + internal readonly record struct Request(DateTime StartUtc, DateTime EndUtc, TimeSpan Interval, long FirstBucket, long BucketCount); + + /// The forward requests serving a reverse plan: the whole intervals first, then the clipped earliest one. + /// A reverse plan with at least one bucket. + /// One or two forward requests. + public static IReadOnlyList Requests(BucketPlan plan) + { + var interval = TimeSpan.FromTicks(plan.IntervalTicks); + if (!plan.IsTruncated(0)) + return [new Request(plan.StartUtc, plan.EndUtc, interval, 0, plan.BucketCount)]; + + var firstWholeStart = new DateTime(plan.UpperTicks(0), DateTimeKind.Utc); // O + I + var requests = new List(2); + if (plan.BucketCount > 1) + requests.Add(new Request(firstWholeStart, plan.EndUtc, interval, 1, plan.BucketCount - 1)); + requests.Add(new Request(plan.StartUtc, firstWholeStart, firstWholeStart - plan.StartUtc, 0, 1)); + return requests; + } + + /// + /// Maps a request's rows onto the plan's buckets: rows outside the request window (AVEVA's pre-window row, #685) + /// are dropped; each in-window row is placed by its interval start (or, without one, its end). + /// + /// The reverse plan. + /// The forward request the rows answer. + /// The drained rows. + /// Results by ascending bucket index (first row per bucket wins). + public static void Place(BucketPlan plan, Request request, IEnumerable rows, + Dictionary byBucket) + { + var interval = request.Interval.Ticks; + foreach (var row in rows) + { + if (!SampleMapper.IsInWindow(row, request.StartUtc, request.EndUtc)) continue; + long offset = row.StartTime is { } s + ? (s.ToDateTime().Ticks - request.StartUtc.Ticks) / interval + : (row.EndTime.ToDateTime().Ticks - request.StartUtc.Ticks - 1) / interval; // IsInWindow ⇒ end in (lo, hi] + if (offset < 0 || offset >= request.BucketCount) continue; + var index = request.FirstBucket + offset; + if (byBucket.ContainsKey(index)) continue; + + var snapshot = SampleMapper.ToAggregateSnapshot(row); + var status = snapshot.StatusCode; + if (plan.IsTruncated(index) && !StatusBits.IsBad(status)) status |= HistorianInfoBits.HistorianPartial; + byBucket[index] = snapshot with { StatusCode = status, SourceTimestampUtc = plan.BucketTimestamp(index) }; + } + } + + /// The placed results newest first (the reverse read's order). + /// The reverse plan. + /// Results by ascending bucket index. + /// The results, descending. + public static IReadOnlyList Assemble(BucketPlan plan, IReadOnlyDictionary byBucket) => + byBucket.OrderByDescending(kv => kv.Key).Select(kv => kv.Value).ToList(); +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/FakeHistorianGatewayClient.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/FakeHistorianGatewayClient.cs index fd354a62..e1694c9c 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/FakeHistorianGatewayClient.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/FakeHistorianGatewayClient.cs @@ -140,9 +140,25 @@ public sealed class FakeHistorianGatewayClient : IHistorianGatewayClient LastAggregateMode = mode; LastAggregateInterval = interval; ReadAggregateCallCount++; - return ToAsyncStream(AggregateSamples, ThrowOnRead, ct); + AggregateCalls.Add((startUtc, endUtc, mode, interval)); + if (AggregateSource is null) return ToAsyncStream(AggregateSamples, ThrowOnRead, ct); + if (startUtc > endUtc) + { + // The real gateway's ValidateRange refusal, raised before any row. + var rpc = new Grpc.Core.RpcException(new Grpc.Core.Status(Grpc.Core.StatusCode.InvalidArgument, + "start_time must be less than or equal to end_time.")); + return ToAsyncStream(Array.Empty(), + new ZB.MOM.WW.HistorianGateway.Client.HistorianGatewayException(rpc.Status.Detail, rpc), ct); + } + return ToAsyncStream(AggregateSource(startUtc, endUtc, mode, interval).ToList(), ThrowOnRead, ct); } + /// When set, ReadAggregate serves AggregateSource(start, end, mode, interval) (and refuses start > end). + public Func>? AggregateSource; + + /// Every ReadAggregate call's (start, end, mode, interval), in order. + public readonly List<(DateTime Start, DateTime End, RetrievalMode Mode, TimeSpan Interval)> AggregateCalls = new(); + public Task> ReadAtTimeAsync( string tag, IReadOnlyList timestampsUtc, diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayNativeAggregateRowsTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayNativeAggregateRowsTests.cs index 34abda80..43950ff7 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayNativeAggregateRowsTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayNativeAggregateRowsTests.cs @@ -106,9 +106,11 @@ public sealed class GatewayNativeAggregateRowsTests public async Task The_window_filter_is_direction_agnostic() { // A reverse request (start > end) covers the same span (end, start]; a row for the interval before the span is out. + // #691.2: served by a forward request on the later-anchored grid, newest first, stamped with each later edge. var wire = new[] { Row(T0 - I20, T0, 9, 192), Row(T0, T0 + I20, 1, 192), Row(T0 + I20, T0 + 2 * I20, 2, 192) }; var s = await Read(wire, T0 + 2 * I20, T0); - s.Select(x => x.Value).ShouldBe(new object?[] { 1d, 2d }); + s.Select(x => x.Value).ShouldBe(new object?[] { 2d, 1d }); + s.Select(x => x.SourceTimestampUtc).ShouldBe(new DateTime?[] { T0 + 2 * I20, T0 + I20 }); } // ---- D2: an Uncertain row keeps its value ------------------------------------------------------------------------ diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayReverseNativeAggregateTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayReverseNativeAggregateTests.cs new file mode 100644 index 00000000..2b2d28d9 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayReverseNativeAggregateTests.cs @@ -0,0 +1,168 @@ +using Google.Protobuf.WellKnownTypes; +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; +using Xunit; +using ZB.MOM.WW.HistorianGateway.Contracts.Grpc; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests; + +/// +/// #691.2: a reverse processed read (start > end) of the AVEVA-native TimeAverage / Total. The +/// gateway refuses start > end, so the read is served by FORWARD native requests whose bucket grid is anchored +/// at the request's later time (#677: window (end, start], intervals stepping back from start, the clipped +/// interval the earliest, each stamped with its later edge, results newest first). The fake is a native-mode AVEVA +/// model: a step-held stored series; for a forward request [s, e) with interval I it answers one row per +/// interval of the grid anchored at s (plus AVEVA's extra row for the interval before s, #685), each the +/// time-weighted average / integral of exactly that interval — the per-interval behaviour the gateway will have once +/// historiangw#27 is fixed. +/// +public sealed class GatewayReverseNativeAggregateTests +{ + private static readonly CancellationToken Ct = CancellationToken.None; + private static readonly DateTime T0 = new(2026, 10, 12, 18, 0, 0, DateTimeKind.Utc); + + private const uint GoodCalc = 0x00000401u; + private const uint Partial = 0x4u; + private const uint NoDataCalc = 0x809B0401u; + + private static DateTime At(double seconds) => T0.AddTicks((long)(seconds * TimeSpan.TicksPerSecond)); + + /// Step-held series: value changes at these offsets (s); data starts at −100 s. + private static readonly (double T, double V)[] Series = [(-100, 1), (7, 5), (23, 2), (41, 10), (55, 4), (80, 6)]; + + /// ∫ v dt (value·s) of the step-held series over [a, b); null when no data covers any of it. + private static double? Integral(DateTime a, DateTime b) + { + double sum = 0; + var covered = false; + for (var i = 0; i < Series.Length; i++) + { + var from = At(Series[i].T); + var to = i + 1 < Series.Length ? At(Series[i + 1].T) : DateTime.MaxValue; + var lo = from > a ? from : a; + var hi = to < b ? to : b; + if (hi <= lo) continue; + covered = true; + sum += Series[i].V * (hi - lo).TotalSeconds; + } + return covered ? sum : null; + } + + private static double? Expected(RetrievalMode mode, DateTime a, DateTime b) => + Integral(a, b) is { } i ? mode == RetrievalMode.Integral ? i : i / (b - a).TotalSeconds : null; + + private static FakeHistorianGatewayClient Aveva() => new() + { + AggregateSource = (s, e, mode, interval) => + { + var rows = new List(); + for (var lo = s - interval; lo < e; lo += interval) // the pre-window interval first (#685) + { + var hi = lo + interval; + var v = Expected(mode, lo, hi); + rows.Add(new HistorianAggregateSample + { + Tag = "T", + StartTime = Timestamp.FromDateTime(lo), + EndTime = Timestamp.FromDateTime(hi), + Value = v ?? 0, + OpcQuality = v is null ? 0u : 192u, + RetrievalMode = mode, + }); + } + return rows; + }, + }; + + private static GatewayHistorianDataSource Source(FakeHistorianGatewayClient fake) => + new(fake, NullLogger.Instance); + + [Theory] + [InlineData(HistoryAggregateType.TimeAverage, RetrievalMode.TimeWeightedAverage)] + [InlineData(HistoryAggregateType.Total, RetrievalMode.Integral)] + public async Task A_reverse_read_over_whole_intervals_is_anchored_at_its_later_time(HistoryAggregateType agg, RetrievalMode mode) + { + var fake = Aveva(); + var ds = Source(fake); + var s = (await ds.ReadProcessedAsync("T", At(60), At(0), TimeSpan.FromSeconds(20), agg, Ct)).Samples; + + // (0, 60] in three intervals, newest first, each stamped with its LATER edge (#677). + s.Select(x => x.SourceTimestampUtc).ShouldBe(new DateTime?[] { At(60), At(40), At(20) }); + s.Select(x => (double)x.Value!).ShouldBe( + [Expected(mode, At(40), At(60))!.Value, Expected(mode, At(20), At(40))!.Value, Expected(mode, At(0), At(20))!.Value], + tolerance: 1e-9); + s.ShouldAllBe(x => x.StatusCode == GoodCalc); + fake.AggregateCalls.ShouldBe([(At(0), At(60), mode, TimeSpan.FromSeconds(20))]); + ds.GetHealthSnapshot().TotalFailures.ShouldBe(0); + } + + [Fact] + public async Task A_reverse_read_with_a_partial_interval_clips_the_EARLIEST_one_and_marks_it_Partial() + { + var fake = Aveva(); + var s = (await Source(fake).ReadProcessedAsync("T", At(70), At(5), TimeSpan.FromSeconds(20), + HistoryAggregateType.TimeAverage, Ct)).Samples; + + // (5, 70]: (50,70], (30,50], (10,30], and the clipped (5,10] — the grid steps back from 70, not forward from 5. + s.Select(x => x.SourceTimestampUtc).ShouldBe(new DateTime?[] { At(70), At(50), At(30), At(10) }); + var m = RetrievalMode.TimeWeightedAverage; + s.Select(x => (double)x.Value!).ShouldBe( + [Expected(m, At(50), At(70))!.Value, Expected(m, At(30), At(50))!.Value, Expected(m, At(10), At(30))!.Value, + Expected(m, At(5), At(10))!.Value], + tolerance: 1e-9); + s.Select(x => x.StatusCode).ShouldBe([GoodCalc, GoodCalc, GoodCalc, GoodCalc | Partial]); + + // Two forward requests, neither reversed: the whole intervals on the later-anchored grid, and the clipped one alone + // (its own width as the interval, so AVEVA computes it over exactly (5, 10]). + fake.AggregateCalls.ShouldBe([ + (At(10), At(70), m, TimeSpan.FromSeconds(20)), + (At(5), At(10), m, TimeSpan.FromSeconds(5)), + ]); + } + + [Fact] + public async Task A_reverse_read_narrower_than_one_interval_is_one_interval_stamped_at_its_later_time() + { + var fake = Aveva(); + var s = (await Source(fake).ReadProcessedAsync("T", At(30), At(12), TimeSpan.FromSeconds(60), + HistoryAggregateType.Total, Ct)).Samples; + s.Single().SourceTimestampUtc.ShouldBe(At(30)); + ((double)s.Single().Value!).ShouldBe(Expected(RetrievalMode.Integral, At(12), At(30))!.Value, 1e-9); + s.Single().StatusCode.ShouldBe(GoodCalc); // a whole (clamped) interval, not a truncated one + fake.AggregateCalls.ShouldBe([(At(12), At(30), RetrievalMode.Integral, TimeSpan.FromSeconds(18))]); + } + + [Fact] + public async Task A_zero_interval_reverse_read_is_one_interval_over_the_window() + { + var fake = Aveva(); + var s = (await Source(fake).ReadProcessedAsync("T", At(30), At(0), TimeSpan.Zero, + HistoryAggregateType.TimeAverage, Ct)).Samples; + s.Single().SourceTimestampUtc.ShouldBe(At(30)); + fake.AggregateCalls.Single().Interval.ShouldBe(TimeSpan.FromSeconds(30)); + } + + [Fact] + public async Task A_no_data_interval_is_BadNoData_without_the_Partial_bit() + { + // Data starts at −100 s: (−110, −90] holds some; (−130, −110] and the clipped earliest (−135, −130] hold none. + var s = (await Source(Aveva()).ReadProcessedAsync("T", At(-90), At(-135), TimeSpan.FromSeconds(20), + HistoryAggregateType.TimeAverage, Ct)).Samples; + s.Select(x => x.SourceTimestampUtc).ShouldBe(new DateTime?[] { At(-90), At(-110), At(-130) }); + s[0].StatusCode.ShouldBe(GoodCalc); + s[1].StatusCode.ShouldBe(NoDataCalc); + s[2].StatusCode.ShouldBe(NoDataCalc); // the clipped interval, but Bad ⇒ no Partial bit + s[2].Value.ShouldBeNull(); + } + + [Fact] + public async Task The_forward_read_is_unchanged() + { + var fake = Aveva(); + var s = (await Source(fake).ReadProcessedAsync("T", At(0), At(60), TimeSpan.FromSeconds(20), + HistoryAggregateType.TimeAverage, Ct)).Samples; + s.Select(x => x.SourceTimestampUtc).ShouldBe(new DateTime?[] { At(0), At(20), At(40) }); + fake.AggregateCalls.ShouldBe([(At(0), At(60), RetrievalMode.TimeWeightedAverage, TimeSpan.FromSeconds(20))]); + } +}