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
@@ -31,8 +31,12 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
/// (owner-approved 2026-11-11) overrides exactly those two (<c>GoodOnlyStatusAggregateCalculator</c>), so live and
/// HistoryRead now agree on them — pinned by
/// <c>SdkAggregateParityTests.Live_AggregateFilter_PercentGood_and_DurationGood_equal_HistoryRead_over_Uncertain_samples</c>.
/// Other known live-vs-HistoryRead differences (e.g. #821, an Uncertain first sample in a late-start interval) are
/// documented in <c>docs/Historian.md</c>.
/// Likewise the frozen <c>AverageAggregateCalculator</c> integrated the span before an <b>Uncertain</b> first value
/// as a ramp from 0 when the data starts inside an interval (no start bound) — live TimeAverage / Total disagreed
/// with HistoryRead and Part 13 ("No Start Bound: Calculate Partial Interval"). #821 (owner-approved 2026-11-11)
/// overrides exactly those two (<c>CoveredTimeAverageAggregateCalculator</c>) — pinned by
/// <c>SdkAggregateParityTests.Live_AggregateFilter_TimeAverage_and_Total_equal_HistoryRead_over_an_Uncertain_first_late_start</c>.
/// The remaining documented live-vs-HistoryRead differences are listed in <c>docs/Historian.md</c>.
/// </para>
/// <para>
/// A request asking for <c>UseServerCapabilitiesDefaults</c>, or spelling out exactly these values, is served;