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:
Joseph Doherty
2026-11-11 12:30:01 -05:00
parent 233e4bab25
commit 8a0032bfdf
11 changed files with 398 additions and 53 deletions
@@ -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>