217 lines
11 KiB
C#
217 lines
11 KiB
C#
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 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 > 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 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));
|
||
|
||
/// <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();
|
||
}
|
||
|
||
// ---- 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))]);
|
||
}
|
||
}
|