Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayNativeAggregateGridTests.cs
T

217 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 &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 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))]);
}
}