fix(historian): #691.2 review — forward native TimeAverage/Total on the SQL grid: clipped latest interval read over its exact span, Partial, interval clamped to the window

This commit is contained in:
Joseph Doherty
2026-10-12 16:55:01 -04:00
parent 7b86059daf
commit 830b47f624
8 changed files with 205 additions and 166 deletions
@@ -145,11 +145,11 @@ public sealed class FakeHistorianGatewayClient : IHistorianGatewayClient
ReadAggregateCallCount++;
AggregateCalls.Add((startUtc, endUtc, mode, interval));
if (AggregateSource is null) return ToAsyncStream(AggregateSamples, ThrowOnRead, ct);
if (startUtc > endUtc)
if (startUtc > endUtc || interval <= TimeSpan.Zero)
{
// The real gateway's ValidateRange refusal, raised before any row.
// The real gateway's request validation (ValidateRange; interval must be > 0), 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."));
startUtc > endUtc ? "start_time must be less than or equal to end_time." : "interval must be greater than zero."));
return ToAsyncStream(Array.Empty<HistorianAggregateSample>(),
new ZB.MOM.WW.HistorianGateway.Client.HistorianGatewayException(rpc.Status.Detail, rpc), ct);
}
@@ -29,12 +29,14 @@ public sealed class GatewayHistorianDataSourceTests
Assert.Equal(100 + RawAggregateReader.PreWindowAllowance, fake.LastReadRawMaxValues);
}
private static readonly DateTime Hour0 = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
[Fact]
public async Task ReadProcessed_uses_aggregate_mode_mapping()
{
var fake = new FakeHistorianGatewayClient();
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
await ds.ReadProcessedAsync("T", default, default, TimeSpan.FromSeconds(60), HistoryAggregateType.Total, TestContext.Current.CancellationToken);
await ds.ReadProcessedAsync("T", Hour0, Hour0.AddHours(1), TimeSpan.FromSeconds(60), HistoryAggregateType.Total, TestContext.Current.CancellationToken); // an empty window sends nothing
Assert.Equal(RetrievalMode.Integral, fake.LastAggregateMode);
Assert.Equal(TimeSpan.FromSeconds(60), fake.LastAggregateInterval);
}
@@ -62,7 +64,7 @@ public sealed class GatewayHistorianDataSourceTests
{
var fake = new FakeHistorianGatewayClient();
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
await ds.ReadProcessedAsync("T", default, default, TimeSpan.FromSeconds(60), HistoryAggregateType.TimeAverage, TestContext.Current.CancellationToken);
await ds.ReadProcessedAsync("T", Hour0, Hour0.AddHours(1), TimeSpan.FromSeconds(60), HistoryAggregateType.TimeAverage, TestContext.Current.CancellationToken); // an empty window sends nothing
Assert.Equal(RetrievalMode.TimeWeightedAverage, fake.LastAggregateMode);
}
@@ -8,16 +8,17 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests;
/// <summary>
/// #691.2: a reverse processed read (<c>start &gt; end</c>) of the AVEVA-native <c>TimeAverage</c> / <c>Total</c>. The
/// gateway refuses <c>start &gt; end</c>, so the read is served by FORWARD native requests whose bucket grid is anchored
/// at the request's later time (#677: window <c>(end, start]</c>, intervals stepping back from <c>start</c>, the clipped
/// interval the earliest, each stamped with its later edge, results newest first). The fake is a native-mode AVEVA
/// #691.2: a forward or reverse processed read of the AVEVA-native <c>TimeAverage</c> / <c>Total</c> on the SQL
/// backends' interval grid. The gateway refuses <c>start &gt; end</c>, so a reverse read is served by FORWARD native
/// requests whose bucket grid is anchored at the request's later time (#677: window <c>(end, start]</c>, intervals
/// stepping back from <c>start</c>, 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 <c>[s, e)</c> with interval <c>I</c> it answers one row per
/// interval of the grid anchored at <c>s</c> (plus AVEVA's extra row for the interval before <c>s</c>, #685), each the
/// time-weighted average / integral of exactly that interval — the per-interval behaviour the gateway will have once
/// historiangw#27 is fixed.
/// </summary>
public sealed class GatewayReverseNativeAggregateTests
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);
@@ -156,6 +157,53 @@ public sealed class GatewayReverseNativeAggregateTests
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()
{