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 forward or reverse processed read of the AVEVA-native TimeAverage / Total on the SQL /// backends' interval grid. The gateway refuses start > end, so a reverse 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); a forward read's clipped LATEST interval is read over exactly its span (review). 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 GatewayNativeAggregateGridTests { 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(); } // ---- forward reads on the same grid (#691.2 review): the clipped LATEST interval is its own exact-span read ---------- [Theory] [InlineData(HistoryAggregateType.TimeAverage, RetrievalMode.TimeWeightedAverage)] [InlineData(HistoryAggregateType.Total, RetrievalMode.Integral)] public async Task A_forward_read_with_a_partial_interval_clips_the_LATEST_one_and_marks_it_Partial(HistoryAggregateType agg, RetrievalMode mode) { var fake = Aveva(); var s = (await Source(fake).ReadProcessedAsync("T", At(5), At(70), TimeSpan.FromSeconds(20), agg, Ct)).Samples; // [5, 70): [5,25), [25,45), [45,65) and the clipped [65,70) — stamped with each EARLIER edge, oldest first, as on // SqlServer/QuestDB (BucketPlan forward; the truncated interval Partial unless Bad). s.Select(x => x.SourceTimestampUtc).ShouldBe(new DateTime?[] { At(5), At(25), At(45), At(65) }); s.Select(x => (double)x.Value!).ShouldBe( [Expected(mode, At(5), At(25))!.Value, Expected(mode, At(25), At(45))!.Value, Expected(mode, At(45), At(65))!.Value, Expected(mode, At(65), At(70))!.Value], tolerance: 1e-9); s.Select(x => x.StatusCode).ShouldBe([GoodCalc, GoodCalc, GoodCalc, GoodCalc | Partial]); // AVEVA never averages past EndTime: the whole intervals, then the clipped one over exactly its span. fake.AggregateCalls.ShouldBe([ (At(5), At(65), mode, TimeSpan.FromSeconds(20)), (At(65), At(70), mode, TimeSpan.FromSeconds(5)), ]); } [Fact] public async Task A_forward_interval_wider_than_the_window_is_sent_as_the_window() { var fake = Aveva(); var s = (await Source(fake).ReadProcessedAsync("T", At(12), At(30), TimeSpan.FromSeconds(60), HistoryAggregateType.Total, Ct)).Samples; s.Single().SourceTimestampUtc.ShouldBe(At(12)); s.Single().StatusCode.ShouldBe(GoodCalc); fake.AggregateCalls.ShouldBe([(At(12), At(30), RetrievalMode.Integral, TimeSpan.FromSeconds(18))]); } [Fact] public async Task A_zero_interval_forward_read_is_one_interval_over_the_window_not_a_zero_interval_request() { var fake = Aveva(); var s = (await Source(fake).ReadProcessedAsync("T", At(0), At(30), TimeSpan.Zero, HistoryAggregateType.TimeAverage, Ct)).Samples; s.Single().SourceTimestampUtc.ShouldBe(At(0)); fake.AggregateCalls.Single().Interval.ShouldBe(TimeSpan.FromSeconds(30)); } [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))]); } }