Files
lmxopcua/tests/Drivers/Historian.Conformance/QualityMixConformance.cs
T
Joseph Doherty 8a0032bfdf fix(aggregates): live TimeAverage/Total leave the span before an Uncertain first value out (#821)
When the data starts inside an interval (no start bound) and its first value is
Uncertain, the frozen 1.5.378.182 AverageAggregateCalculator integrated the
uncovered span as a ramp from 0 and divided TimeAverage by the whole interval:
GetRegionsInValueSet (ignoreBadData, sloped) overwrites the BadNoData start
region's status with UncertainDataSubNormal in its one branch without a not-Bad
guard. Live AggregateFilter therefore disagreed with HistoryRead and Part 13
§5.4.3.6/§5.4.3.8 ("No Start Bound: Calculate Partial Interval"); QP: Total
188.96 vs 163.96, TimeAverage 9.448 vs 10.931. SDK 2.0.1 stock does the same.
Owner-approved 2026-11-11.

- New app-owned CoveredTimeAverageAggregateCalculator subclasses the baseline
  and recomputes only TimeAverage/Total, restoring the Bad status of a region
  that starts at a Bad point (only the start bound can). Only such an interval's
  value changes; status, timestamps, type and bits are the baseline's. Stepped
  and TreatUncertainAsBad=true never reached the defect. CompatibilityAggregate-
  Factory routes exactly those two IDs; copied SDK files untouched.
- Golden test: 0 fixture vectors change (every data set has a usable value at
  -10 s), asserted. New sweep over late-start variants of every data set (cut
  +5 s / +25 s, first non-Bad sample Uncertain, all directions/stepping/configs/
  incremental): 150 / 142 of 384 change, each pinned to a baseline-derived
  oracle (untouched baseline's value with that sample Good, its status/bits on
  the variant itself) - never our calculator. Plus a hand-computed case.
- Live-vs-HistoryRead parity (TimeWeightedAggregator) forward + reverse, no
  masks, with discrimination against the pre-#821 baseline; QualityMix now
  compares QP's TimeAverage/Total with the live oracle on both SQL backends;
  booted-runtime facts test expects 163.96 / 10.93.
- README recorded decision, ServerAggregateConfiguration remarks, oracle
  remarks, docs/Historian.md and .claude/rules/historian.md updated.

Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
2026-11-11 12:30:01 -05:00

333 lines
21 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 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;
/// <summary>
/// #810: processed reads over Good / Uncertain / Bad mixes. Before this suite no conformance dataset fed
/// PercentGood an Uncertain region, so the suites passed against the 1.5.378.182 baseline calculator that counts
/// Uncertain time as Good (#796) as well as against the Part 13 oracle. Every expectation here is derived by hand from
/// Part 13 (§5.4.3.31-34: DurationGood / DurationBad are the Good / Bad region time of the interval, PercentGood /
/// PercentBad that time ÷ the interval; Uncertain regions count toward neither — Mantis 0006166 — and the time before
/// the first sample is Bad, no data), and the oracle itself is checked against the same hand values. Since #820 the SQL
/// backends serve DurationGood / DurationBad (the same region times, in ms) and this suite asserts them directly.
/// </summary>
public abstract class QualityMixConformance : ConformanceBase
{
private static readonly CancellationToken Ct = CancellationToken.None;
private static readonly TimeSpan Ten = TimeSpan.FromSeconds(10);
private static readonly TimeSpan Twenty = TimeSpan.FromSeconds(20);
private const uint GoodCalc = 0x00000401u; // Good | InfoType DataValue 0x400 | Calculated 0x1
private const uint NoDataCalc = 0x809B0401u; // BadNoData | Calculated
private const uint UncertainCalc = 0x40A40401u; // UncertainDataSubNormal | Calculated
private const uint Partial = 0x4u;
/// <summary>
/// Tag Q, Double, 10 s intervals over [0, 80) — a Good sample before the window (−10) and after it (+90), so no
/// interval holds the start or the end of the data. Stepped status regions, each with the status of the sample that
/// starts it; the region before an interval's first own sample has the simple bounding value's status:
/// <list type="bullet">
/// <item>I0 [0,10): Good 0, Uncertain 4, Bad 7 ⇒ Good 4 s, Uncertain 3 s, Bad 3 s.</item>
/// <item>I1 [10,20): Good 10, Uncertain 13 ⇒ Good 3 s, Uncertain 7 s.</item>
/// <item>I2 [20,30): Uncertain 20, Uncertain 25 ⇒ all Uncertain (Mantis 0006166: 0 % Good AND 0 % Bad).</item>
/// <item>I3 [30,40): no sample at 30 — the bound slopes Uncertain 25 → Uncertain 33 (Uncertain); Uncertain 33
/// ⇒ all Uncertain again, this time through the bound.</item>
/// <item>I4 [40,50): Good 40, BadNoCommunication outage marker 45 ⇒ Good 5 s, Bad 5 s.</item>
/// <item>I5 [50,60): Uncertain 50, Good 56 ⇒ Uncertain 6 s, Good 4 s.</item>
/// <item>I6 [60,70): the bound slopes Good 56 → Uncertain 64 (a non-Good end ⇒ Uncertain) for 4 s, Uncertain 64
/// for 3 s, Good 67 for 3 s.</item>
/// <item>I7 [70,80): the bound holds Good 67 into the outage at 75 (⇒ Uncertain) for 5 s, Bad 75 for 3 s, Good 78
/// for 2 s.</item>
/// </list>
/// </summary>
private static readonly (DateTime T, HistorianValue V, uint Status)[] Q =
[
(At(-10), HistorianValue.FromDouble(10), Good),
(At(0), HistorianValue.FromDouble(10), Good),
(At(4), HistorianValue.FromDouble(12), Uncertain),
(At(7), HistorianValue.FromDouble(13), Bad),
(At(10), HistorianValue.FromDouble(14), Good),
(At(13), HistorianValue.FromDouble(15), Uncertain),
(At(20), HistorianValue.FromDouble(16), Uncertain),
(At(25), HistorianValue.FromDouble(17), Uncertain),
(At(33), HistorianValue.FromDouble(18), Uncertain),
(At(40), HistorianValue.FromDouble(20), Good),
(At(45), HistorianValue.Empty, BadNoCommunication),
(At(50), HistorianValue.FromDouble(22), Uncertain),
(At(56), HistorianValue.FromDouble(23), Good),
(At(64), HistorianValue.FromDouble(25), Uncertain),
(At(67), HistorianValue.FromDouble(26), Good),
(At(75), HistorianValue.FromDouble(27), Bad),
(At(78), HistorianValue.FromDouble(28), Good),
(At(90), HistorianValue.FromDouble(30), Good),
];
/// <summary>Hand-computed Part 13 DurationGood of Q's eight intervals, seconds.</summary>
private static readonly double[] QDurationGood = [4, 3, 0, 0, 5, 4, 3, 2];
/// <summary>Hand-computed Part 13 DurationBad of Q's eight intervals, seconds.</summary>
private static readonly double[] QDurationBad = [3, 0, 0, 0, 5, 0, 0, 3];
/// <summary>What the untouched 1.5.378.182 baseline answers for Q's PercentGood (Uncertain counted as Good, #796; live before #809).</summary>
private static readonly double[] QBaselinePercentGood = [70, 100, 100, 100, 50, 100, 100, 70];
/// <summary>
/// Tag QP, partial data, 20 s intervals over [−20, 60): the data starts at +5 and ends at +25 (nothing after).
/// [−20,0) ends before the first sample ⇒ BadNoData. [0,20): 5 s no data (Bad), Uncertain 5 for 7 s, Good 12 for
/// 8 s ⇒ PercentGood 40, PercentBad 25, Partial (the data starts inside). [20,40): the bound slopes Good 12 →
/// Uncertain 25 (Uncertain) for 5 s, then the last sample's Uncertain region runs to the interval end ⇒ PercentGood 0,
/// PercentBad 0, Partial (the data ends inside). [40,60): after the end of the data ⇒ BadNoData.
/// </summary>
private static readonly (DateTime T, HistorianValue V, uint Status)[] QP =
[
(At(5), HistorianValue.FromDouble(10), Uncertain),
(At(12), HistorianValue.FromDouble(11), Good),
(At(25), HistorianValue.FromDouble(12), Uncertain),
];
private (DateTime, HistorianValue, uint)[] Rounded((DateTime T, HistorianValue V, uint Status)[] rows) =>
rows.Select(r => (B.Round(r.T), r.V, r.Status)).ToArray();
private static double[] Values(IEnumerable<DataValueSnapshot> s) => s.Select(x => Convert.ToDouble(x.Value)).ToArray();
private static void ShouldBeClose(double[] actual, double[] expected, string label)
{
actual.Length.ShouldBe(expected.Length, label);
for (var i = 0; i < expected.Length; i++) actual[i].ShouldBe(expected[i], 1e-6, $"{label} [{i}]");
}
[Fact]
public async Task PercentGood_and_PercentBad_over_a_Good_Uncertain_Bad_mix_are_the_Part_13_region_shares()
{
await B.SeedAsync("Q", Q);
var src = B.CreateDataSource();
var good = (await src.ReadProcessedAsync("Q", At(0), At(80), Ten, HistoryAggregateType.PercentGood, Ct)).Samples;
var bad = (await src.ReadProcessedAsync("Q", At(0), At(80), Ten, HistoryAggregateType.PercentBad, Ct)).Samples;
// Part 13 §5.4.3.33/34: PercentGood = DurationGood ÷ interval, PercentBad = DurationBad ÷ interval.
var expectedGood = QDurationGood.Select(d => 100 * d / Ten.TotalSeconds).ToArray();
var expectedBad = QDurationBad.Select(d => 100 * d / Ten.TotalSeconds).ToArray();
ShouldBeClose(Values(good), expectedGood, "PercentGood");
ShouldBeClose(Values(bad), expectedBad, "PercentBad");
good.Select(x => x.StatusCode).ShouldAllBe(c => c == GoodCalc);
bad.Select(x => x.StatusCode).ShouldAllBe(c => c == GoodCalc);
good.Select(x => x.SourceTimestampUtc).ShouldBe(Enumerable.Range(0, 8).Select(i => (DateTime?)B.Round(At(i * 10))));
// Uncertain time is neither Good nor Bad: only I4 (no Uncertain region) adds up to 100.
Values(good).Zip(Values(bad), (g, b) => Math.Round(g + b, 6)).ShouldBe([70d, 30d, 0d, 0d, 100d, 40d, 30d, 50d]);
((double)good[2].Value! + (double)bad[2].Value!).ShouldBe(0d, 1e-9, "Mantis 0006166: all-Uncertain ⇒ 0 % and 0 %");
((double)good[3].Value! + (double)bad[3].Value!).ShouldBe(0d, 1e-9, "all-Uncertain through the bound");
}
[Fact]
public void The_hand_computed_Q_values_are_the_Part_13_oracle_and_not_the_baseline()
{
// The oracle the parity tests below compare against must agree with the hand derivation above; the baseline must
// not (it counts the Uncertain regions as Good). Backend-independent, but kept beside the data it checks.
var rows = Q.Select(r => (r.T, r.V, r.Status)).ToArray();
var oracleGood = SdkAggregateOracle.Run(HistoryAggregateType.PercentGood, At(0), At(80), Ten, rows);
var oracleBad = SdkAggregateOracle.Run(HistoryAggregateType.PercentBad, At(0), At(80), Ten, rows);
ShouldBeClose(oracleGood.Select(r => Convert.ToDouble(r.Value)).ToArray(),
QDurationGood.Select(d => 100 * d / Ten.TotalSeconds).ToArray(), "oracle PercentGood");
ShouldBeClose(oracleBad.Select(r => Convert.ToDouble(r.Value)).ToArray(),
QDurationBad.Select(d => 100 * d / Ten.TotalSeconds).ToArray(), "oracle PercentBad");
var baseline = SdkAggregateOracle.RunSdkAsIs(HistoryAggregateType.PercentGood, At(0), At(80), Ten, rows);
ShouldBeClose(baseline.Select(r => Convert.ToDouble(r.Value)).ToArray(), QBaselinePercentGood,
"baseline PercentGood (#796) — if this moves, revisit SdkAggregateOracle");
}
[Fact]
public async Task PercentGood_and_PercentBad_over_partial_data_count_no_data_as_Bad_and_Uncertain_as_neither()
{
await B.SeedAsync("QP", QP);
var src = B.CreateDataSource();
var good = (await src.ReadProcessedAsync("QP", At(-20), At(60), Twenty, HistoryAggregateType.PercentGood, Ct)).Samples;
var bad = (await src.ReadProcessedAsync("QP", At(-20), At(60), Twenty, HistoryAggregateType.PercentBad, Ct)).Samples;
good.Select(x => x.Value).ShouldBe([null, 40d, 0d, null]);
bad.Select(x => x.Value).ShouldBe([null, 25d, 0d, null]);
// Start-of-data and end-of-data intervals are Partial; outside the data range BadNoData.
var statuses = new[] { NoDataCalc, GoodCalc | Partial, GoodCalc | Partial, NoDataCalc };
good.Select(x => x.StatusCode).ShouldBe(statuses);
bad.Select(x => x.StatusCode).ShouldBe(statuses);
}
[Fact]
public async Task DurationGood_and_DurationBad_over_a_Good_Uncertain_Bad_mix_are_the_Part_13_region_times()
{
// #820: served on both SQL backends from the same region totals as PercentGood / PercentBad. Part 13
// §5.4.3.31/32: the Good / Bad region time of the interval as an OPC UA Duration (milliseconds); Uncertain regions
// count toward neither (Mantis 0006166) — never the 1.5.378.182 baseline rule that counts Uncertain time as Good.
await B.SeedAsync("Q", Q);
var src = B.CreateDataSource();
src.SupportedAggregates.ShouldContain(HistoryAggregateType.DurationGood);
src.SupportedAggregates.ShouldContain(HistoryAggregateType.DurationBad);
var good = (await src.ReadProcessedAsync("Q", At(0), At(80), Ten, HistoryAggregateType.DurationGood, Ct)).Samples;
var bad = (await src.ReadProcessedAsync("Q", At(0), At(80), Ten, HistoryAggregateType.DurationBad, Ct)).Samples;
ShouldBeClose(Values(good), QDurationGood.Select(d => d * 1000).ToArray(), "DurationGood");
ShouldBeClose(Values(bad), QDurationBad.Select(d => d * 1000).ToArray(), "DurationBad");
good.ShouldAllBe(x => x.Value is double);
good.Select(x => x.StatusCode).ShouldAllBe(c => c == GoodCalc);
bad.Select(x => x.StatusCode).ShouldAllBe(c => c == GoodCalc);
good.Select(x => x.SourceTimestampUtc).ShouldBe(Enumerable.Range(0, 8).Select(i => (DateTime?)B.Round(At(i * 10))));
// Uncertain time is neither Good nor Bad: only I4 (no Uncertain region) adds up to the whole 10 s.
Values(good).Zip(Values(bad), (g, b) => Math.Round(g + b, 6)).ShouldBe([7000d, 3000d, 0d, 0d, 10000d, 4000d, 3000d, 5000d]);
// DurationGood / DurationBad = PercentGood / PercentBad × interval ÷ 100, interval by interval.
var pGood = (await src.ReadProcessedAsync("Q", At(0), At(80), Ten, HistoryAggregateType.PercentGood, Ct)).Samples;
ShouldBeClose(Values(good), Values(pGood).Select(p => p * Ten.TotalMilliseconds / 100).ToArray(), "DurationGood vs PercentGood");
// The untouched baseline (the live AggregateFilter calculator before #809) answers Q's DurationGood with the
// Uncertain time added; the live calculator now answers the Part 13 values above (SdkAggregateParityTests).
var baseline = SdkAggregateOracle.RunSdkAsIs(HistoryAggregateType.DurationGood, At(0), At(80), Ten,
Q.Select(r => (r.T, r.V, r.Status)));
ShouldBeClose(baseline.Select(r => Convert.ToDouble(r.Value)).ToArray(),
QBaselinePercentGood.Select(p => p * 100).ToArray(), "baseline DurationGood (#809)");
}
[Fact]
public async Task DurationGood_and_DurationBad_over_partial_data_count_no_data_as_Bad_and_Uncertain_as_neither()
{
// #820 over QP: [0,20) 5 s no data (Bad), Uncertain 7 s, Good 8 s; [20,40) all Uncertain; outside the data BadNoData.
await B.SeedAsync("QP", QP);
var src = B.CreateDataSource();
var good = (await src.ReadProcessedAsync("QP", At(-20), At(60), Twenty, HistoryAggregateType.DurationGood, Ct)).Samples;
var bad = (await src.ReadProcessedAsync("QP", At(-20), At(60), Twenty, HistoryAggregateType.DurationBad, Ct)).Samples;
good.Select(x => x.Value).ShouldBe([null, 8000d, 0d, null]);
bad.Select(x => x.Value).ShouldBe([null, 5000d, 0d, null]);
var statuses = new[] { NoDataCalc, GoodCalc | Partial, GoodCalc | Partial, NoDataCalc };
good.Select(x => x.StatusCode).ShouldBe(statuses);
bad.Select(x => x.StatusCode).ShouldBe(statuses);
}
[Theory]
[InlineData(0, 80, 10)]
[InlineData(80, 0, 10)]
[InlineData(0, 80, 20)]
[InlineData(3, 77, 10)]
[InlineData(77, 3, 10)]
public async Task Processed_reads_over_a_Good_Uncertain_Bad_mix_match_the_Part_13_oracle(double from, double to,
double intervalS)
{
// The real backend against SdkAggregateOracle over Q — forward, reverse, aligned and truncated windows — for every
// served aggregate whose Uncertain handling the data reaches. Minimum / Maximum / Range / *ActualTime are left out:
// Q has Uncertain values beyond the Good extremes, where Part 13 (ours) says Uncertain and the SDK Good (#684,
// documented in SdkAggregateParityTests).
await B.SeedAsync("Q", Q);
await AssertMatchesOracle("Q", Rounded(Q), from, to, intervalS,
[
HistoryAggregateType.PercentGood, HistoryAggregateType.PercentBad, HistoryAggregateType.DurationGood,
HistoryAggregateType.DurationBad, HistoryAggregateType.Count,
HistoryAggregateType.Average, HistoryAggregateType.TimeAverage, HistoryAggregateType.TimeAverage2,
HistoryAggregateType.Total, HistoryAggregateType.Total2, HistoryAggregateType.Start, HistoryAggregateType.End,
HistoryAggregateType.Delta, HistoryAggregateType.Interpolative,
]);
}
[Theory]
[InlineData(-20, 60, 20)]
[InlineData(-20, 60, 10)]
[InlineData(60, -20, 20)]
[InlineData(60, -20, 10)]
public async Task Processed_reads_over_partial_data_match_the_Part_13_oracle(double from, double to, double intervalS)
{
// QP's start-of-data and end-of-data intervals against the oracle, forward and reverse (#821). Left out, each a documented or pinned divergence:
// TimeAverage2 / Total2 (the SDK's 1 ms end-of-data region) and the min/max family (the SDK sets Partial on a Bad
// result). TimeAverage / Total are in since #821: the live calculator no longer integrates the span before QP's
// Uncertain first sample (the hand-computed values are pinned below as well).
await B.SeedAsync("QP", QP);
await AssertMatchesOracle("QP", Rounded(QP), from, to, intervalS,
[
HistoryAggregateType.PercentGood, HistoryAggregateType.PercentBad, HistoryAggregateType.DurationGood,
HistoryAggregateType.DurationBad, HistoryAggregateType.Count,
HistoryAggregateType.Average, HistoryAggregateType.Start, HistoryAggregateType.End, HistoryAggregateType.Delta,
HistoryAggregateType.Interpolative, HistoryAggregateType.TimeAverage, HistoryAggregateType.Total,
]);
}
[Fact]
public async Task Time_weighted_values_over_partial_data_cover_only_the_data()
{
// Part 13 §5.4.3.3/§5.4.3.8 over QP's [0,20) (data from +5): the interpolated-bounding signal is defined from +5
// only — 10 → 11 over [5,12) (area 73.5) and 11 → 11 + 8/13 over [12,20) (area 4·(22 + 8/13)) — so
// Total = 73.5 + 88 + 32/13 and TimeAverage = Total ÷ 15 s; Uncertain (an Uncertain value used, not fully covered),
// Partial (the data starts inside). Found by #810: the untouched 1.5.378.182 baseline, whose first sample is
// Uncertain here, integrates the uncovered [0,5) as a ramp from 0 (Total +25); since #821 the live calculator (the
// oracle) does not.
await B.SeedAsync("QP", QP);
var src = B.CreateDataSource();
const double total = 73.5 + 88 + 32d / 13;
var tot = (await src.ReadProcessedAsync("QP", At(0), At(20), Twenty, HistoryAggregateType.Total, Ct)).Samples.Single();
((double)tot.Value!).ShouldBe(total, 1e-6);
tot.StatusCode.ShouldBe(UncertainCalc | Partial);
var avg = (await src.ReadProcessedAsync("QP", At(0), At(20), Twenty, HistoryAggregateType.TimeAverage, Ct)).Samples.Single();
((double)avg.Value!).ShouldBe(total / 15, 1e-6);
avg.StatusCode.ShouldBe(UncertainCalc | Partial);
var live = SdkAggregateOracle.Run(HistoryAggregateType.Total, B.Round(At(0)), B.Round(At(20)), Twenty, Rounded(QP)).Single();
Convert.ToDouble(live.Value).ShouldBe(total, 1e-6, "live AggregateFilter equals HistoryRead (#821)");
var before = SdkAggregateOracle.RunSdkAsIs(HistoryAggregateType.Total, B.Round(At(0)), B.Round(At(20)), Twenty, Rounded(QP)).Single();
Convert.ToDouble(before.Value).ShouldBe(total + 25, 1e-6, "the untouched baseline (live before #821)");
}
[Fact]
public async Task Time_weighted_values_over_partial_data_cover_only_the_data_in_a_reverse_read()
{
// #821: the reverse-order variant of the late-start case above — (0,20] read 20 → 0. The same Part 13 integral and
// average over the covered [5,20); Uncertain; NOT Partial (a reverse read keys start-of-data on the latest sample,
// which is past the window — the SDK's rule, SdkAggregateParityTests); stamped with the later edge (#677). The live
// calculator agrees since #821; the untouched baseline's reverse answer carries the same +25 ramp.
await B.SeedAsync("QP", QP);
var src = B.CreateDataSource();
const double total = 73.5 + 88 + 32d / 13;
var tot = (await src.ReadProcessedAsync("QP", At(20), At(0), Twenty, HistoryAggregateType.Total, Ct)).Samples.Single();
((double)tot.Value!).ShouldBe(total, 1e-6);
tot.StatusCode.ShouldBe(UncertainCalc);
tot.SourceTimestampUtc.ShouldBe(B.Round(At(20)));
var avg = (await src.ReadProcessedAsync("QP", At(20), At(0), Twenty, HistoryAggregateType.TimeAverage, Ct)).Samples.Single();
((double)avg.Value!).ShouldBe(total / 15, 1e-6);
avg.StatusCode.ShouldBe(UncertainCalc);
var live = SdkAggregateOracle.Run(HistoryAggregateType.Total, B.Round(At(20)), B.Round(At(0)), Twenty, Rounded(QP)).Single();
Convert.ToDouble(live.Value).ShouldBe(total, 1e-6, "live AggregateFilter equals HistoryRead (reverse) — #821");
var before = SdkAggregateOracle.RunSdkAsIs(HistoryAggregateType.Total, B.Round(At(20)), B.Round(At(0)), Twenty, Rounded(QP)).Single();
Convert.ToDouble(before.Value).ShouldBe(total + 25, 1e-6, "the untouched baseline (live before #821, reverse)");
}
private async Task AssertMatchesOracle(string tag, (DateTime, HistorianValue, uint)[] rows, double from, double to,
double intervalS, HistoryAggregateType[] aggregates)
{
var src = B.CreateDataSource();
var interval = TimeSpan.FromSeconds(intervalS);
var (start, end) = (B.Round(At(from)), B.Round(At(to)));
var plan = BucketPlan.Create(start, end, interval);
var problems = new List<string>();
foreach (var agg in aggregates)
{
var ours = (await src.ReadProcessedAsync(tag, At(from), At(to), interval, agg, Ct)).Samples.ToList();
if (AggregateFamilies.FamilyOf(agg) == AggregateFamily.TimeWeighted)
{
// Documented divergence (ProcessedConformance): ours flags the truncated interval Partial, the SDK does not.
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 & ~Partial };
}
}
// Calculated / Interpolated bits are not compared: a BadNoData carries Calculated here, not in the SDK
// (documented); the hand-computed tests above pin those bits.
var failure = Record.Exception(() => SdkAggregateOracle.ShouldMatch(ours,
SdkAggregateOracle.Run(agg, start, end, interval, rows), $"{tag} {agg} {from}→{to}/{intervalS}s",
compareCalculatedInterpolatedBits: false));
if (failure is not null) problems.Add(failure.Message);
}
problems.ShouldBeEmpty();
}
}