Merge feat/677-aggregates: reverse processed reads SDK-anchored, start-of-data Partial (forward), AggregateConfiguration accept-defaults, IntegralDivisor=1, SDK parity tests (#677)

This commit is contained in:
Joseph Doherty
2026-10-11 11:47:52 -04:00
24 changed files with 1291 additions and 135 deletions
@@ -1,6 +1,7 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Conformance;
@@ -171,7 +172,8 @@ public abstract class ProcessedConformance : ConformanceBase
// The time-weighted path over the whole range: ≈ the extrapolated 50 that dominates eight millennia.
var tw = (await src.ReadProcessedAsync("A", start, end, TimeSpan.Zero, HistoryAggregateType.TimeAverage2, Ct)).Samples.Single();
((double)tw.Value!).ShouldBe(50d, 1e-6);
tw.StatusCode.ShouldBe(UncertainCalc);
// The data starts (+0) strictly inside the one interval ⇒ Partial as well (#677, the SDK's m_startOfData rule).
tw.StatusCode.ShouldBe(UncertainCalc | 0x4u);
src.GetHealthSnapshot().TotalFailures.ShouldBe(0);
}
@@ -196,12 +198,165 @@ public abstract class ProcessedConformance : ConformanceBase
}
[Fact]
public async Task A_reverse_window_is_computed_forward_and_returned_descending()
public async Task A_reverse_window_is_anchored_at_its_later_time()
{
// #677 (Part 13 / SDK): At(80) → At(0) is the window (0,80] in intervals (60,80], (40,60], (20,40], (0,20], each
// stamped with its LATER edge, latest first. A sample on an edge belongs to the interval it ends: +40 is in
// (20,40], +20 (Bad) in (0,20], and +0 is outside the window. (The pre-#677 layout answered [60,80) … [0,20)
// stamped 60/40/20/0 with values null, 50, 40, 15.)
await ConformanceData.SeedAAsync(B);
var s = (await B.CreateDataSource().ReadProcessedAsync("A", At(80), At(0), Twenty, HistoryAggregateType.Average, Ct)).Samples;
s.Select(x => x.SourceTimestampUtc).ShouldBe([B.Round(At(60)), B.Round(At(40)), B.Round(At(20)), B.Round(At(0))]);
s.Select(x => x.Value).ShouldBe([null, 50d, 40d, 15d]);
s.Select(x => x.SourceTimestampUtc).ShouldBe([B.Round(At(80)), B.Round(At(60)), B.Round(At(40)), B.Round(At(20))]);
s.Select(x => x.Value).ShouldBe([null, null, 45d, 20d]);
s.Select(x => x.StatusCode).ShouldBe([NoDataCalc, NoDataCalc, GoodCalc, UncertainCalc]);
}
[Fact]
public async Task A_reverse_window_truncates_its_earliest_interval()
{
// At(50) → At(0), 20 s over tag A (+0=10, +10=20, +20=Bad, +30=40, +40=50): (30,50] = {+40} → 50 (+30 is its
// earlier edge, excluded); (10,30] = {+20 Bad, +30} → 40, Uncertain; (0,10] = {+10} → 20 — the truncated interval
// is the EARLIEST one. (Pre-#677: [0,20), [20,40), [40,50) stamped 40/20/0 with values 50, 40, 15.)
await ConformanceData.SeedAAsync(B);
var s = (await B.CreateDataSource().ReadProcessedAsync("A", At(50), At(0), Twenty, HistoryAggregateType.Average, Ct)).Samples;
s.Select(x => x.SourceTimestampUtc).ShouldBe([B.Round(At(50)), B.Round(At(30)), B.Round(At(10))]);
s.Select(x => x.Value).ShouldBe([50d, 40d, 20d]);
s.Select(x => x.StatusCode).ShouldBe([GoodCalc, UncertainCalc, GoodCalc]);
}
[Fact]
public async Task Reverse_bucket_edges_are_later_edge_inclusive_to_the_tick()
{
// The mirror of the forward edge test: At(40) → At(0), 20 s ⇒ (20,40], (0,20]. The bucket math is pushed down,
// so this pins the SQL, not just the C# plan.
var r = B.ResolutionTicks;
await B.SeedAsync("ER",
(At(0), HistorianValue.FromDouble(1000), Good), // == earlier edge: excluded
(At(0).AddTicks(r), HistorianValue.FromDouble(1), Good), // first tick of (0,20]
(At(20), HistorianValue.FromDouble(2), Good), // == edge: the LATER edge of (0,20]
(At(20).AddTicks(r), HistorianValue.FromDouble(4), Good), // first tick of (20,40]
(At(40), HistorianValue.FromDouble(8), Good), // == request start: included, in (20,40]
(At(40).AddTicks(r), HistorianValue.FromDouble(2000), Good)); // after the window: excluded
var s = (await B.CreateDataSource().ReadProcessedAsync("ER", At(40), At(0), Twenty, HistoryAggregateType.Average, Ct)).Samples;
s.Select(x => x.SourceTimestampUtc).ShouldBe([B.Round(At(40)), B.Round(At(20))]);
s.Select(x => x.Value).ShouldBe([6d, 1.5d]);
}
[Fact]
public async Task The_interval_the_data_starts_inside_carries_the_Partial_bit()
{
// #677 (SDK m_startOfData): the tag's first sample is +5, inside [0,20) ⇒ Partial there for every aggregate but
// Average; [20,40) is fully covered. With an earlier sample stored (even outside the window) nothing is Partial.
await B.SeedAsync("LS",
(At(5), HistorianValue.FromDouble(10), Good),
(At(15), HistorianValue.FromDouble(30), Good),
(At(25), HistorianValue.FromDouble(20), Good),
(At(60), HistorianValue.FromDouble(20), Good));
var src = B.CreateDataSource();
foreach (var agg in new[] { HistoryAggregateType.Count, HistoryAggregateType.Minimum, HistoryAggregateType.TimeAverage2 })
{
var s = (await src.ReadProcessedAsync("LS", At(0), At(40), Twenty, agg, Ct)).Samples;
(s[0].StatusCode & 0x4u).ShouldBe(0x4u, agg.ToString());
(s[1].StatusCode & 0x4u).ShouldBe(0u, agg.ToString());
}
(await src.ReadProcessedAsync("LS", At(0), At(40), Twenty, HistoryAggregateType.Average, Ct)).Samples
.ShouldAllBe(x => (x.StatusCode & 0x4u) == 0);
await B.SeedAsync("LS2",
(At(-100), HistorianValue.FromDouble(10), Good),
(At(5), HistorianValue.FromDouble(10), Good),
(At(15), HistorianValue.FromDouble(30), Good),
(At(25), HistorianValue.FromDouble(20), Good));
(await src.ReadProcessedAsync("LS2", At(0), At(40), Twenty, HistoryAggregateType.Count, Ct)).Samples
.ShouldAllBe(x => (x.StatusCode & 0x4u) == 0);
}
[Fact]
public async Task A_reverse_read_sets_no_start_of_data_Partial_and_matches_the_SDK()
{
// Review F1: the SDK applies the start-of-data rule to FORWARD reads only. Reverse 40 → 0 at 10 s over data that
// starts at +7: (0,10] holds the data's start but is not Partial — checked through the real SQL and the SDK oracle.
var rows = Enumerable.Range(0, 20)
.Select(i => (At(7 + i * 5), HistorianValue.FromDouble(i % 2 == 0 ? 20 : 40), Good)).ToArray();
await B.SeedAsync("RLS", rows);
var sdkRows = rows.Select(r => (B.Round(r.Item1), r.Item2, r.Item3)).ToArray();
var src = B.CreateDataSource();
var ten = TimeSpan.FromSeconds(10);
foreach (var agg in new[]
{
HistoryAggregateType.Count, HistoryAggregateType.Minimum, HistoryAggregateType.Range,
HistoryAggregateType.Delta, HistoryAggregateType.TimeAverage, HistoryAggregateType.TimeAverage2,
HistoryAggregateType.Total2,
})
{
var ours = (await src.ReadProcessedAsync("RLS", At(40), At(0), ten, agg, Ct)).Samples;
ours.ShouldAllBe(x => (x.StatusCode & 0x4u) == 0, agg.ToString());
var tw = AggregateFamilies.FamilyOf(agg) == AggregateFamily.TimeWeighted;
SdkAggregateOracle.ShouldMatch(ours, SdkAggregateOracle.Run(agg, B.Round(At(40)), B.Round(At(0)), ten, sdkRows),
$"{agg} reverse late start", compareCalculatedInterpolatedBits: tw,
// pre-existing, #680: the SDK's Delta is Good with a Bad sample present; ours Uncertain (none here).
compareCodeBits: agg != HistoryAggregateType.Delta);
}
}
/// <summary>Dense: every 2.5 s from −10 to +100, values cycling, one Bad sample at +22.5 (no empty interval).</summary>
private Task SeedDenseAsync() => B.SeedAsync("P", DenseRows());
private static (DateTime T, HistorianValue V, uint Status)[] DenseRows() => Enumerable.Range(0, 45)
.Select(i => (At(-10 + i * 2.5), HistorianValue.FromDouble(10 + (i * 7 % 13)), i == 13 ? Bad : Good))
.ToArray();
[Theory]
[InlineData(0, 80, 20)]
[InlineData(80, 0, 20)]
[InlineData(77, 3, 20)]
[InlineData(3, 77, 20)]
public async Task Processed_reads_match_the_SDK_aggregate_calculators(double from, double to, double intervalS)
{
// #677: the real backend (pushed-down SQL bucket math + the shared evaluators) against the pinned SDK's own Part 13
// calculators over the same stored rows — timestamps, values, status code bits and the Partial bit, forward and
// reverse. See SdkAggregateParityTests (Sql.Common.Tests) for the documented divergences this avoids/masks.
await SeedDenseAsync();
var rows = DenseRows().Select(r => (B.Round(r.T), r.V, r.Status)).ToArray();
var src = B.CreateDataSource();
var interval = TimeSpan.FromSeconds(intervalS);
// The SDK sees the window the backend actually serves (QuestDB rounds the bounds to µs).
var (start, end) = (B.Round(At(from)), B.Round(At(to)));
foreach (var agg in new[]
{
HistoryAggregateType.Average, HistoryAggregateType.Minimum, HistoryAggregateType.Maximum,
HistoryAggregateType.Count, HistoryAggregateType.TimeAverage, HistoryAggregateType.TimeAverage2,
HistoryAggregateType.Total, HistoryAggregateType.Total2, HistoryAggregateType.Interpolative,
HistoryAggregateType.Range, HistoryAggregateType.Start, HistoryAggregateType.End,
HistoryAggregateType.Delta, HistoryAggregateType.MinimumActualTime, HistoryAggregateType.MaximumActualTime,
HistoryAggregateType.PercentGood, HistoryAggregateType.PercentBad,
})
{
var ours = (await src.ReadProcessedAsync("P", At(from), At(to), interval, agg, Ct)).Samples.ToList();
var startOrEnd = agg is HistoryAggregateType.Start or HistoryAggregateType.End;
var tw = AggregateFamilies.FamilyOf(agg) == AggregateFamily.TimeWeighted;
if (tw && agg != HistoryAggregateType.Interpolative)
{
// Documented divergence: ours flags the truncated interval Partial, the SDK does not.
var plan = BucketPlan.Create(start, end, interval);
for (long i = 0; i < plan.BucketCount; i++)
if (plan.IsTruncated(i))
{
var k = (int)(plan.Reversed ? plan.BucketCount - 1 - i : i);
ours[k] = ours[k] with { StatusCode = ours[k].StatusCode & ~0x4u };
}
}
var reverseInterpolative = agg == HistoryAggregateType.Interpolative && from > to;
SdkAggregateOracle.ShouldMatch(ours, SdkAggregateOracle.Run(agg, start, end, interval, rows),
$"{agg} {from}→{to}/{intervalS}s",
compareCalculatedInterpolatedBits: tw && !reverseInterpolative,
// pre-existing, #680: TimeAverage/Total status rule; Start/End raw-sample timestamp + status; Delta status;
// PercentGood/Bad per-sample vs per-duration values (see SdkAggregateParityTests).
compareCodeBits: agg is not (HistoryAggregateType.TimeAverage or HistoryAggregateType.Total
or HistoryAggregateType.Delta) && !reverseInterpolative && !startOrEnd,
compareTimestamps: !startOrEnd,
compareValues: agg is not (HistoryAggregateType.PercentGood or HistoryAggregateType.PercentBad));
}
}
[Fact]
@@ -245,7 +400,8 @@ public abstract class ProcessedConformance : ConformanceBase
(await One(HistoryAggregateType.Count)).StatusCode.ShouldBe(NoDataCalc);
var pb = await One(HistoryAggregateType.PercentBad);
pb.Value.ShouldBe(100d);
pb.StatusCode.ShouldBe(GoodCalc);
// The tag's data starts (+1) inside the interval ⇒ Partial on the non-Bad percentage (#677).
pb.StatusCode.ShouldBe(GoodCalc | 0x4u);
}
[Fact]
@@ -0,0 +1,162 @@
using Opc.Ua;
using Opc.Ua.Server;
using Shouldly;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Conformance;
/// <summary>
/// Drives the pinned OPC UA SDK's own Part 13 aggregate calculators (<c>Opc.Ua.Server</c> — the classes the SDK's
/// <c>AggregateManager</c> registers) in-process over a sample set, so our processed reads can be compared with the
/// reference implementation bucket by bucket (#677). The calculators run under the server's default
/// <see cref="AggregateConfiguration"/> (TreatUncertainAsBad = false, PercentDataBad = PercentDataGood = 100,
/// UseSlopedExtrapolation = false), sloped interpolation, source timestamps.
/// </summary>
/// <remarks>
/// Shared (source-linked) by <c>Sql.Common.Tests</c> and both SQL backends' integration suites.
/// </remarks>
public static class SdkAggregateOracle
{
/// <summary>One processed value: value (double / int / null), full status code, timestamp.</summary>
public readonly record struct Result(object? Value, uint StatusCode, DateTime Timestamp);
/// <summary>The server-default aggregate configuration (what <c>AggregateManager.GetDefaultConfiguration</c> returns).</summary>
public static AggregateConfiguration DefaultConfiguration => new()
{
TreatUncertainAsBad = false,
PercentDataBad = 100,
PercentDataGood = 100,
UseSlopedExtrapolation = false,
UseServerCapabilitiesDefaults = false,
};
/// <summary>Runs the SDK calculator for <paramref name="aggregate"/> over <paramref name="samples"/>.</summary>
/// <param name="aggregate">The aggregate.</param>
/// <param name="start">Request start (later than <paramref name="end"/> for a reverse read).</param>
/// <param name="end">Request end.</param>
/// <param name="interval">Processing interval.</param>
/// <param name="samples">Every stored sample of the tag, any order (sorted here).</param>
/// <returns>The SDK's processed values, in the order it returns them.</returns>
public static IReadOnlyList<Result> Run(HistoryAggregateType aggregate, DateTime start, DateTime end, TimeSpan interval,
IEnumerable<(DateTime Time, HistorianValue Value, uint Status)> samples)
{
var id = AggregateId(aggregate);
var ms = interval.TotalMilliseconds;
var cfg = DefaultConfiguration;
IAggregateCalculator calc = aggregate switch
{
HistoryAggregateType.Average or HistoryAggregateType.TimeAverage or HistoryAggregateType.TimeAverage2
or HistoryAggregateType.Total or HistoryAggregateType.Total2 =>
new AverageAggregateCalculator(id, start, end, ms, false, cfg, null),
HistoryAggregateType.Minimum or HistoryAggregateType.Maximum or HistoryAggregateType.Range
or HistoryAggregateType.MinimumActualTime or HistoryAggregateType.MaximumActualTime =>
new MinMaxAggregateCalculator(id, start, end, ms, false, cfg, null),
HistoryAggregateType.Count => new CountAggregateCalculator(id, start, end, ms, false, cfg, null),
HistoryAggregateType.PercentGood or HistoryAggregateType.PercentBad =>
new StatusAggregateCalculator(id, start, end, ms, false, cfg, null),
HistoryAggregateType.Start or HistoryAggregateType.End or HistoryAggregateType.Delta =>
new StartEndAggregateCalculator(id, start, end, ms, false, cfg, null),
HistoryAggregateType.Interpolative => new AggregateCalculator(id, start, end, ms, false, cfg, null),
_ => throw new ArgumentOutOfRangeException(nameof(aggregate), aggregate, "no SDK calculator mapped"),
};
// The SDK expects values in the direction of the read: ascending forward, descending reverse.
var ordered = samples.OrderBy(s => s.Time).ToList();
if (end < start) ordered.Reverse();
foreach (var s in ordered) calc.QueueRawValue(ToDataValue(s.Time, s.Value, s.Status));
var results = new List<Result>();
for (var guard = 0; guard < 100_000; guard++)
{
var dv = calc.GetProcessedValue(true);
if (dv is null) break;
results.Add(new Result(dv.Value, dv.StatusCode.Code, DateTime.SpecifyKind(dv.SourceTimestamp, DateTimeKind.Utc)));
}
return results;
}
/// <summary>
/// Asserts our snapshots equal the SDK's: same count, same timestamps, same status code bits (severity + sub-code)
/// and Partial bit (and optionally the Calculated / Interpolated bits), same value (doubles to 1e-9; ints exactly;
/// null on a Bad status). A failure lists every differing interval side by side.
/// </summary>
/// <param name="ours">Our processed values.</param>
/// <param name="sdk">The SDK's.</param>
/// <param name="label">Context for failure messages.</param>
/// <param name="compareCalculatedInterpolatedBits">Also compare the Calculated / Interpolated aggregate bits.</param>
/// <param name="compareCodeBits">Compare the severity + sub-code (false only for a documented status divergence).</param>
/// <param name="compareValues">Compare the values (false only for a documented value divergence).</param>
/// <param name="compareTimestamps">Compare the timestamps (false only for a documented timestamp divergence).</param>
/// <param name="comparePartialBit">Compare the Partial bit (false only for a documented divergence).</param>
public static void ShouldMatch(IReadOnlyList<DataValueSnapshot> ours, IReadOnlyList<Result> sdk, string label,
bool compareCalculatedInterpolatedBits = true, bool compareCodeBits = true, bool compareValues = true,
bool compareTimestamps = true, bool comparePartialBit = true)
{
var problems = new List<string>();
if (ours.Count != sdk.Count) problems.Add($"interval count: ours {ours.Count}, sdk {sdk.Count}");
for (var i = 0; i < Math.Min(ours.Count, sdk.Count); i++)
{
var o = ours[i];
var s = sdk[i];
var bitMask = (comparePartialBit ? 0x4u : 0u) | (compareCodeBits ? 0xFFFF0000u : 0u)
| (compareCalculatedInterpolatedBits ? 0x3u : 0u);
var same = (!compareTimestamps || o.SourceTimestampUtc == s.Timestamp)
&& (o.StatusCode & bitMask) == (s.StatusCode & bitMask);
if (!compareValues || (!compareCodeBits && StatusCode.IsBad(s.StatusCode)))
{
// Values not compared (or the SDK answered Bad under a status this comparison does not check).
}
else if (same && !StatusCode.IsBad(s.StatusCode))
{
same = s.Value switch
{
null => o.Value is null,
int n => o.Value is not null && Convert.ToInt32(o.Value) == n,
_ => o.Value is not null && Math.Abs(Convert.ToDouble(o.Value) - Convert.ToDouble(s.Value)) <= 1e-9,
};
}
else if (same && compareCodeBits) same = o.Value is null;
if (!same)
problems.Add($"[{i}] ours {o.SourceTimestampUtc:HH:mm:ss.fffffff} {o.Value ?? "null"} 0x{o.StatusCode:X8}" +
$" | sdk {s.Timestamp:HH:mm:ss.fffffff} {s.Value ?? "null"} 0x{s.StatusCode:X8}");
}
problems.ShouldBeEmpty($"{label} differs from the SDK:\n" + string.Join("\n", problems));
}
private static DataValue ToDataValue(DateTime t, HistorianValue v, uint status) => new()
{
WrappedValue = v.Kind switch
{
HistorianValueKind.Empty => Variant.Null,
HistorianValueKind.Int64 => new Variant(v.Integer),
HistorianValueKind.Boolean => new Variant(v.Number != 0),
HistorianValueKind.String => new Variant(v.Text),
_ => new Variant(v.Number),
},
StatusCode = status,
SourceTimestamp = t,
ServerTimestamp = t,
};
private static NodeId AggregateId(HistoryAggregateType a) => a switch
{
HistoryAggregateType.Average => ObjectIds.AggregateFunction_Average,
HistoryAggregateType.Minimum => ObjectIds.AggregateFunction_Minimum,
HistoryAggregateType.Maximum => ObjectIds.AggregateFunction_Maximum,
HistoryAggregateType.Range => ObjectIds.AggregateFunction_Range,
HistoryAggregateType.MinimumActualTime => ObjectIds.AggregateFunction_MinimumActualTime,
HistoryAggregateType.MaximumActualTime => ObjectIds.AggregateFunction_MaximumActualTime,
HistoryAggregateType.Count => ObjectIds.AggregateFunction_Count,
HistoryAggregateType.PercentGood => ObjectIds.AggregateFunction_PercentGood,
HistoryAggregateType.PercentBad => ObjectIds.AggregateFunction_PercentBad,
HistoryAggregateType.Start => ObjectIds.AggregateFunction_Start,
HistoryAggregateType.End => ObjectIds.AggregateFunction_End,
HistoryAggregateType.Delta => ObjectIds.AggregateFunction_Delta,
HistoryAggregateType.TimeAverage => ObjectIds.AggregateFunction_TimeAverage,
HistoryAggregateType.TimeAverage2 => ObjectIds.AggregateFunction_TimeAverage2,
HistoryAggregateType.Total => ObjectIds.AggregateFunction_Total,
HistoryAggregateType.Total2 => ObjectIds.AggregateFunction_Total2,
HistoryAggregateType.Interpolative => ObjectIds.AggregateFunction_Interpolative,
_ => throw new ArgumentOutOfRangeException(nameof(a), a, "no SDK aggregate id mapped"),
};
}