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
This commit is contained in:
@@ -237,15 +237,16 @@ public abstract class QualityMixConformance : ConformanceBase
|
||||
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), the min/max family (the SDK sets Partial on a Bad
|
||||
// result), and TimeAverage / Total (pinned by Time_weighted_values_over_partial_data_cover_only_the_data below).
|
||||
// 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.Interpolative, HistoryAggregateType.TimeAverage, HistoryAggregateType.Total,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -255,9 +256,9 @@ public abstract class QualityMixConformance : ConformanceBase
|
||||
// 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). Divergence found by #810: the 1.5.378.182 baseline, whose first sample is
|
||||
// Uncertain here, integrates the uncovered [0,5) as a ramp from 0 (Total +25) — so these are pinned by hand, not
|
||||
// against the oracle.
|
||||
// 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;
|
||||
@@ -268,8 +269,10 @@ public abstract class QualityMixConformance : ConformanceBase
|
||||
((double)avg.Value!).ShouldBe(total / 15, 1e-6);
|
||||
avg.StatusCode.ShouldBe(UncertainCalc | Partial);
|
||||
|
||||
var sdk = SdkAggregateOracle.Run(HistoryAggregateType.Total, B.Round(At(0)), B.Round(At(20)), Twenty, Rounded(QP)).Single();
|
||||
Convert.ToDouble(sdk.Value).ShouldBe(total + 25, 1e-6, "baseline divergence — if this moves, compare QP's TimeAverage/Total to the oracle");
|
||||
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]
|
||||
@@ -277,8 +280,8 @@ public abstract class QualityMixConformance : ConformanceBase
|
||||
{
|
||||
// #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
|
||||
// baseline's reverse answer carries the same +25 ramp divergence.
|
||||
// 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;
|
||||
@@ -290,8 +293,10 @@ public abstract class QualityMixConformance : ConformanceBase
|
||||
((double)avg.Value!).ShouldBe(total / 15, 1e-6);
|
||||
avg.StatusCode.ShouldBe(UncertainCalc);
|
||||
|
||||
var sdk = SdkAggregateOracle.Run(HistoryAggregateType.Total, B.Round(At(20)), B.Round(At(0)), Twenty, Rounded(QP)).Single();
|
||||
Convert.ToDouble(sdk.Value).ShouldBe(total + 25, 1e-6, "baseline divergence (reverse) — #821");
|
||||
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,
|
||||
|
||||
@@ -28,8 +28,15 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Conformance;
|
||||
/// <c>GoodOnlyStatusAggregateCalculator</c> (the same baseline region machinery — simple bounds, end-of-data 1 ms
|
||||
/// region, status and Partial bits — counting only Good regions), so <see cref="Run"/> / <see cref="RunLive"/> are
|
||||
/// exactly what a live AggregateFilter computes, for every aggregate. PercentBad / DurationBad count Bad regions only
|
||||
/// (unchanged by .182). <see cref="RunSdkAsIs"/> runs the untouched baseline (what live served before #809), for the
|
||||
/// tests that pin that divergence.
|
||||
/// (unchanged by .182). <see cref="RunSdkAsIs"/> runs the untouched baseline (what live served before #809 / #821),
|
||||
/// for the tests that pin those divergences.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>TimeAverage / Total with no start bound (#821).</b> When the data starts inside an interval with an
|
||||
/// <b>Uncertain</b> first value, the baseline integrates the uncovered span before it as a ramp from 0 (its region
|
||||
/// machinery overwrites that region's Bad status with Uncertain). Part 13 ("No Start Bound: Calculate Partial
|
||||
/// Interval") and ours integrate only the covered part, as the baseline itself does after a Good first value. Since
|
||||
/// #821 the live calculators follow Part 13 here too (<c>CoveredTimeAverageAggregateCalculator</c>).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Not <c>100 − PercentBad</c> under TreatUncertainAsBad = true: that flag also changes the region machinery
|
||||
@@ -56,8 +63,8 @@ public static class SdkAggregateOracle
|
||||
|
||||
/// <summary>
|
||||
/// Runs the Part 13 oracle for <paramref name="aggregate"/> over <paramref name="samples"/>: the live calculator
|
||||
/// (<see cref="RunLive"/>) — the baseline, except PercentGood / DurationGood, which count Good regions only (see the
|
||||
/// remarks, #796/#820/#809).
|
||||
/// (<see cref="RunLive"/>) — the baseline, except PercentGood / DurationGood, which count Good regions only, and
|
||||
/// TimeAverage / Total, which leave the span before an Uncertain first value out (see the remarks, #796/#820/#809/#821).
|
||||
/// </summary>
|
||||
/// <param name="aggregate">The aggregate.</param>
|
||||
/// <param name="start">Request start (later than <paramref name="end"/> for a reverse read).</param>
|
||||
@@ -71,7 +78,8 @@ public static class SdkAggregateOracle
|
||||
|
||||
/// <summary>
|
||||
/// Runs exactly the calculator the server registers for live AggregateFilter monitored items
|
||||
/// (<c>CompatibilityAggregateFactory.Create</c>, the #809 Good-only PercentGood / DurationGood included).
|
||||
/// (<c>CompatibilityAggregateFactory.Create</c>, the #809 Good-only PercentGood / DurationGood and the #821
|
||||
/// covered-only TimeAverage / Total included).
|
||||
/// </summary>
|
||||
/// <param name="aggregate">The aggregate.</param>
|
||||
/// <param name="start">Request start (later than <paramref name="end"/> for a reverse read).</param>
|
||||
@@ -89,7 +97,8 @@ public static class SdkAggregateOracle
|
||||
/// <summary>
|
||||
/// Runs the untouched baseline (1.5.378.182) calculator for <paramref name="aggregate"/> exactly as it computes it,
|
||||
/// under <see cref="DefaultConfiguration"/> — including its PercentGood / DurationGood rule (Uncertain counted as
|
||||
/// Good) that neither HistoryRead nor, since #809, the live calculators use.
|
||||
/// Good) and its TimeAverage / Total ramp before an Uncertain first value, which neither HistoryRead nor, since
|
||||
/// #809 / #821, the live calculators use.
|
||||
/// </summary>
|
||||
/// <param name="aggregate">The aggregate.</param>
|
||||
/// <param name="start">Request start (later than <paramref name="end"/> for a reverse read).</param>
|
||||
|
||||
+72
-8
@@ -525,17 +525,19 @@ public sealed class SdkAggregateParityTests
|
||||
public void An_Uncertain_first_sample_in_a_late_start_interval_makes_the_SDK_integrate_the_time_before_the_data(
|
||||
double from, double to)
|
||||
{
|
||||
// #821, pinned divergence (pending the #821 owner decision — this test records, it does not choose).
|
||||
// Part 13 §5.4.3.3/§5.4.3.8: the interpolated-bounding signal is defined from the first sample only — 10 → 11 over
|
||||
// [5,12) (73.5) and 11 → 11 + 8/13 over [12,20) (4·(22 + 8/13)) ⇒ Total 163.96…, TimeAverage = Total ÷ 15 s.
|
||||
// #821, decided 2026-11-11: live follows Part 13. Part 13 §5.4.3.6/§5.4.3.8 ("No Start Bound: Calculate Partial
|
||||
// Interval"): the interpolated-bounding signal is defined from the first sample only — 10 → 11 over [5,12) (73.5)
|
||||
// and 11 → 11 + 8/13 over [12,20) (4·(22 + 8/13)) ⇒ Total 163.96…, TimeAverage = Total ÷ 15 s.
|
||||
const double part13Total = 73.5 + 88 + 32d / 13;
|
||||
Pct(Ours(HistoryAggregateType.Total, from, to, 0, UncertainFirstLateStart)).ShouldBe(part13Total, 1e-9);
|
||||
Pct(Ours(HistoryAggregateType.TimeAverage, from, to, 0, UncertainFirstLateStart)).ShouldBe(part13Total / 15, 1e-9);
|
||||
// The live AggregateFilter calculators (CompatibilityAggregateFactory — CoveredTimeAverageAggregateCalculator since
|
||||
// #821; Sdk2AggregateCompatibilityTests proves the runtime manager serves the same code) answer the same.
|
||||
Pct(Sdk(HistoryAggregateType.Total, from, to, 0, UncertainFirstLateStart)).ShouldBe(part13Total, 1e-9);
|
||||
Pct(Sdk(HistoryAggregateType.TimeAverage, from, to, 0, UncertainFirstLateStart)).ShouldBe(part13Total / 15, 1e-9);
|
||||
|
||||
// The frozen 1.5.378.182 calculators — for Total / TimeAverage exactly what the server registers for live
|
||||
// AggregateFilter items (CompatibilityAggregateFactory; Sdk2AggregateCompatibilityTests proves the runtime manager
|
||||
// serves the same code)
|
||||
// — integrate the uncovered [0,5) as a ramp 0 → 10 (+25) and divide TimeAverage by the WHOLE 20 s interval.
|
||||
// The untouched frozen 1.5.378.182 calculators (live before #821) integrate the uncovered [0,5) as a ramp 0 → 10
|
||||
// (+25) and divide TimeAverage by the WHOLE 20 s interval.
|
||||
Pct(AsIs(HistoryAggregateType.Total, from, to, UncertainFirstLateStart)).ShouldBe(part13Total + 25, 1e-9);
|
||||
Pct(AsIs(HistoryAggregateType.TimeAverage, from, to, UncertainFirstLateStart)).ShouldBe((part13Total + 25) / 20, 1e-9);
|
||||
// SDK 2.0.1's own stock calculators answer the same: the upgrade did not change this.
|
||||
@@ -546,15 +548,18 @@ public sealed class SdkAggregateParityTests
|
||||
// Status agrees everywhere (Uncertain — an Uncertain value is used and the interval is not fully covered).
|
||||
(AsIs(HistoryAggregateType.Total, from, to, UncertainFirstLateStart).Single().StatusCode & StatusBits.CodeBitsMask)
|
||||
.ShouldBe(HistorianStatusCodes.UncertainDataSubNormal);
|
||||
Sdk(HistoryAggregateType.Total, from, to, 0, UncertainFirstLateStart).Single().StatusCode
|
||||
.ShouldBe(AsIs(HistoryAggregateType.Total, from, to, UncertainFirstLateStart).Single().StatusCode);
|
||||
(Ours(HistoryAggregateType.Total, from, to, 0, UncertainFirstLateStart).Single().StatusCode & StatusBits.CodeBitsMask)
|
||||
.ShouldBe(HistorianStatusCodes.UncertainDataSubNormal);
|
||||
|
||||
// Control: the SAME data with a Good first sample — all three agree on the Part 13 answer, so the trigger is the
|
||||
// Control: the SAME data with a Good first sample — all agree on the Part 13 answer, so the trigger is the
|
||||
// Uncertain first sample, not the late start.
|
||||
foreach (var agg in new[] { HistoryAggregateType.Total, HistoryAggregateType.TimeAverage })
|
||||
{
|
||||
var expected = agg == HistoryAggregateType.Total ? part13Total : part13Total / 15;
|
||||
Pct(Ours(agg, from, to, 0, GoodFirstLateStart)).ShouldBe(expected, 1e-9);
|
||||
Pct(Sdk(agg, from, to, 0, GoodFirstLateStart)).ShouldBe(expected, 1e-9);
|
||||
Pct(AsIs(agg, from, to, GoodFirstLateStart)).ShouldBe(expected, 1e-9);
|
||||
Stock201(agg, from, to, GoodFirstLateStart).Single().Value!.Value.ShouldBe(expected, 1e-9);
|
||||
}
|
||||
@@ -661,6 +666,65 @@ public sealed class SdkAggregateParityTests
|
||||
.Select(r => Convert.ToDouble(r.Value)).ShouldBe([7000d, 10000, 10000, 10000, 5000, 10000, 10000, 7000], tolerance: 1e-9);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #821: late-start data whose first usable sample is Uncertain (+7, after every window's start), with Good / Uncertain / Bad samples after it
|
||||
/// and a Good sample past every window's end (+90), so only the start of the data is in question. No sample sits on
|
||||
/// an interval edge of <see cref="WholeIntervalWindows"/> (the reverse on-edge divergence, class remarks).
|
||||
/// </summary>
|
||||
private static readonly StoredSample[] UncertainFirstLate =
|
||||
[
|
||||
S(7, HistorianValue.FromDouble(10), Uncertain),
|
||||
S(12, HistorianValue.FromDouble(11), Good),
|
||||
S(25, HistorianValue.FromDouble(12), Uncertain),
|
||||
S(33, HistorianValue.FromDouble(14), Good),
|
||||
S(47, HistorianValue.FromDouble(13), Bad),
|
||||
S(52, HistorianValue.FromDouble(15), Good),
|
||||
S(90, HistorianValue.FromDouble(16), Good),
|
||||
];
|
||||
|
||||
/// <summary>#821: the same, preceded by Bad samples only (an outage before the data — still no start bound).</summary>
|
||||
private static readonly StoredSample[] BadThenUncertainFirstLate =
|
||||
[
|
||||
S(-10, HistorianValue.Empty, BadNoCommunication),
|
||||
S(2, HistorianValue.FromDouble(7), Bad),
|
||||
.. UncertainFirstLate,
|
||||
];
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(WholeIntervalWindows))]
|
||||
public void Live_AggregateFilter_TimeAverage_and_Total_equal_HistoryRead_over_an_Uncertain_first_late_start(
|
||||
double from, double to, double intervalS)
|
||||
{
|
||||
// #821: a live AggregateFilter monitored item and a HistoryRead-Processed (SqlServer / QuestDB share
|
||||
// TimeWeightedAggregator) answer TimeAverage / Total identically when the data starts inside an interval with an
|
||||
// Uncertain value — same values, timestamps, status and Partial / Calculated bits, NO documented-divergence mask.
|
||||
// RunLive is exactly CompatibilityAggregateFactory.Create, the calculator the server registers.
|
||||
var interval = intervalS > 0 ? TimeSpan.FromSeconds(intervalS) : (At(to) - At(from)).Duration();
|
||||
var failures = new List<string>();
|
||||
var discriminated = 0;
|
||||
foreach (var agg in new[] { HistoryAggregateType.TimeAverage, HistoryAggregateType.Total })
|
||||
foreach (var (name, data) in new[] { ("UncertainFirstLate", UncertainFirstLate), ("BadThenUncertainFirstLate", BadThenUncertainFirstLate) })
|
||||
{
|
||||
var rows = data.Select(x => (x.TimeUtc, x.Value, x.Status)).ToArray();
|
||||
var live = SdkAggregateOracle.RunLive(agg, At(from), At(to), interval, rows);
|
||||
try
|
||||
{
|
||||
SdkAggregateOracle.ShouldMatch(Ours(agg, from, to, intervalS, data), live, $"live {agg} {name} {from}→{to}/{intervalS}s");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failures.Add(ex.Message);
|
||||
}
|
||||
// The data must discriminate: the pre-#821 live calculator (the untouched baseline) integrates the span before
|
||||
// the data, so it disagrees on the late-start interval — or this parity would pass against the old behaviour too.
|
||||
var before = SdkAggregateOracle.RunSdkAsIs(agg, At(from), At(to), interval, rows);
|
||||
if (before.Zip(live).Any(p => Math.Abs(Convert.ToDouble(p.First.Value) - Convert.ToDouble(p.Second.Value)) > 1e-9))
|
||||
discriminated++;
|
||||
}
|
||||
failures.ShouldBeEmpty(string.Join("\n", failures));
|
||||
discriminated.ShouldBe(4, "every aggregate × data set must differ from the pre-#821 live answer somewhere");
|
||||
}
|
||||
|
||||
/// <summary>#684: Good 10 / 30 with an Uncertain value BELOW the Good minimum (+4) and one ABOVE the maximum (+6).</summary>
|
||||
private static readonly StoredSample[] UncertainBeyond =
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user