8a0032bfdf
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
333 lines
21 KiB
C#
333 lines
21 KiB
C#
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();
|
||
}
|
||
}
|