fix(historian): #691.2 serve reverse native TimeAverage/Total on Gateway via forward reads on the later-anchored #677 grid
This commit is contained in:
+17
-1
@@ -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<HistorianAggregateSample>(),
|
||||
new ZB.MOM.WW.HistorianGateway.Client.HistorianGatewayException(rpc.Status.Detail, rpc), ct);
|
||||
}
|
||||
return ToAsyncStream(AggregateSource(startUtc, endUtc, mode, interval).ToList(), ThrowOnRead, ct);
|
||||
}
|
||||
|
||||
/// <summary>When set, ReadAggregate serves <c>AggregateSource(start, end, mode, interval)</c> (and refuses start > end).</summary>
|
||||
public Func<DateTime, DateTime, RetrievalMode, TimeSpan, IEnumerable<HistorianAggregateSample>>? AggregateSource;
|
||||
|
||||
/// <summary>Every ReadAggregate call's (start, end, mode, interval), in order.</summary>
|
||||
public readonly List<(DateTime Start, DateTime End, RetrievalMode Mode, TimeSpan Interval)> AggregateCalls = new();
|
||||
|
||||
public Task<IReadOnlyList<HistorianSample>> ReadAtTimeAsync(
|
||||
string tag,
|
||||
IReadOnlyList<DateTime> timestampsUtc,
|
||||
|
||||
+3
-1
@@ -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 ------------------------------------------------------------------------
|
||||
|
||||
+168
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// #691.2: a reverse processed read (<c>start > end</c>) of the AVEVA-native <c>TimeAverage</c> / <c>Total</c>. The
|
||||
/// gateway refuses <c>start > 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
|
||||
/// 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
|
||||
{
|
||||
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));
|
||||
|
||||
/// <summary>Step-held series: value changes at these offsets (s); data starts at −100 s.</summary>
|
||||
private static readonly (double T, double V)[] Series = [(-100, 1), (7, 5), (23, 2), (41, 10), (55, 4), (80, 6)];
|
||||
|
||||
/// <summary>∫ v dt (value·s) of the step-held series over [a, b); null when no data covers any of it.</summary>
|
||||
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<HistorianAggregateSample>();
|
||||
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<GatewayHistorianDataSource>.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))]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user