Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/SdkAggregateParityTests.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

1081 lines
66 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.Conformance;
using static ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests.Fixture;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests;
/// <summary>
/// #677: our processed reads against the pinned SDK's own Part 13 aggregate calculators (<see cref="SdkAggregateOracle"/>),
/// forward AND reverse, bucket by bucket — timestamps, values and status. The per-sample aggregates are bucketed with
/// <see cref="BucketPlan.IndexOf"/> (the C# statement of the backends' SQL bucket math; the integration suites run the
/// same comparison through the real SQL) and evaluated by <see cref="AggregateEvaluator.EvaluateAll"/>; the
/// time-weighted ones by <see cref="TimeWeightedAggregator"/>.
/// </summary>
/// <remarks>
/// Deliberate, documented divergences the datasets avoid (they are not anchoring questions):
/// <list type="bullet">
/// <item><b>an Uncertain value beyond the Good extreme</b> in Minimum / Maximum / Range / *ActualTime: Part 13
/// (Tables 57-62) makes the result <c>Uncertain_DataSubNormal</c>, ours follows it; the SDK's <c>ComputeMinMax</c>
/// collects the Uncertain extremes and never reads them, so it answers Good. The value is the Good extreme in both
/// (#684). <see cref="DenseUncertain"/> keeps its Uncertain values inside every interval's Good range so the
/// per-sample comparison needs no mask; the divergence itself is pinned by
/// <see cref="An_uncertain_value_beyond_the_good_extreme_is_Uncertain_per_Part_13_where_the_SDK_says_Good"/>.
/// (Uncertain samples otherwise match since #684: Average / Min / Max / Range / *ActualTime / Count all use Good
/// samples only, as the SDK does under the server's TreatUncertainAsBad = false);</item>
/// <item>the per-sample <b>Calculated / MultipleValues</b> info bits (the SDK's MinMax sets Calculated only when the
/// extreme is not at the interval start, and flags ties) — those comparisons check code bits + Partial only;</item>
/// <item>a <b>truncated</b> interval: ours sets Partial on a non-Bad time-weighted result (Part 13 §5.3.3.2 Case 3
/// requires it; the SDK does not), so the non-multiple windows mask that one bucket's Partial bit;</item>
/// <item>a time-weighted <c>BadNoData</c> carries the Calculated bit in ours (every processed status does), not in the
/// SDK's — masked by <see cref="MaskDocumented"/>;</item>
/// <item><b>TimeAverage2 / Total2 over an interval wholly inside an outage:</b> ours generic <c>Bad</c> (data, but no
/// non-Bad time — Part 13's PercentData rule), the SDK <c>BadNoData</c> — masked by <see cref="MaskDocumented"/>;</item>
/// <item><b>the end-of-data interval</b> (the last sample inside it, nothing after) of TimeAverage2 / Total2 /
/// PercentGood / PercentBad: the SDK gives the region after the last sample a 1 ms duration (an artifact of its
/// <c>GetRegionsInValueSet</c>); Part 13 ends TimeAverage2/Total2 at the last sample and runs the PercentGood/Bad
/// region to the interval end, which ours does (#680; pinned by <c>TimeWeightedAggregatorTests</c>). That
/// interval's value is not compared; its status, Partial bit and timestamp are.</item>
/// <item><b>an outage marker as the last sample</b> (<see cref="OutageLast"/>): ours runs the outage to the window
/// end — TimeAverage2/Total2 <c>Bad</c>, the percentages 100 % Bad, no end-of-data Partial on the interval holding
/// it; the SDK treats the data as ended (<c>BadNoData</c> after it, Partial on that interval); and TimeAverage/Total
/// over the span the last usable value is held up to the marker are Uncertain here (no Good value follows ⇒
/// extrapolated, Part 13), Good in the SDK — masked by
/// <see cref="MaskDocumented"/>;</item>
/// <item><b>Booleans</b> (<see cref="BoolSwitch"/>): ours steps them (Part 13: a non-analog value is held, and the
/// bound next to an Uncertain sample takes the earlier sample's status); the SDK interpolates them as numbers — its
/// values (e.g. 1 vs our 0.5 over an interval switching half-way) and the Good/Uncertain choice are not compared,
/// Bad vs non-Bad, timestamps and Partial are;</item>
/// <item><b>TimeAverage / Total, reverse, a sample exactly on an interval's later edge:</b> the SDK's backward
/// <c>UpdateSlice</c> files it as the early bound (below), so the interval takes the status of the segment beyond
/// it (Uncertain when that segment skips a Bad sample); ours keeps the interval's own segments — masked by
/// <see cref="MaskDocumented"/>;</item>
/// <item>a <b>Bad</b> per-sample result never carries Partial (the SDK sets start/end-of-data Partial on Bad results
/// too) — the datasets keep the data's start and end out of all-Bad intervals;</item>
/// <item><b>Interpolative, reverse, a sample exactly at the point:</b> the SDK's backward <c>UpdateSlice</c> files
/// a sample AT the point as the early bound (<c>&gt;= 0</c>), so it interpolates from it to the next Good sample —
/// same value, but the Interpolated bit, and Uncertain when a Bad sample sits between. Ours returns the raw
/// sample with its own status in both directions (as the SDK itself does forward). Reverse Interpolative
/// therefore compares timestamps and values (the anchoring question) but not status. (The SDK's asymmetry —
/// forward it returns the raw sample — is its defect; ours is the same in both directions.)</item>
/// </list>
/// </remarks>
public sealed class SdkAggregateParityTests
{
private static readonly DateTime Now = At(3600);
/// <summary>Dense: every 2.5 s from −10 to +100, values cycling, one Bad sample at +22.5 — no empty interval.</summary>
private static readonly StoredSample[] Dense = Enumerable.Range(0, 45)
.Select(i => (t: -10 + i * 2.5, i))
.Select(x => S(x.t, HistorianValue.FromDouble(10 + (x.i * 7 % 13)), x.t == 22.5 ? Bad : Good))
.ToArray();
/// <summary>
/// #684: <see cref="Dense"/> with every fifth sample (from +2.5) Uncertain at 16 and the one Bad at +22.5. The cycling
/// Good values alternate around 16 (every adjacent pair has one at or below it and one at or above it), so an
/// Uncertain 16 lies inside the Good range of every interval of every window (each holds 5+ samples): Average /
/// Minimum / Maximum / Range / *ActualTime must leave it out of the value (Good only — the SDK, Part 13), and the
/// min/max family's status matches the SDK's without a mask.
/// </summary>
private static readonly StoredSample[] DenseUncertain = Enumerable.Range(0, 45)
.Select(i => (t: -10 + i * 2.5, i))
.Select(x => x.i % 5 == 0 && x.t != 22.5
? S(x.t, HistorianValue.FromDouble(16), Uncertain)
: S(x.t, HistorianValue.FromDouble(10 + (x.i * 7 % 13)), x.t == 22.5 ? Bad : Good))
.ToArray();
/// <summary>Tag A with Good bounds on both sides, plus an Uncertain sample and an outage marker further on.</summary>
private static readonly StoredSample[] Bounded =
[
S(-10, HistorianValue.FromDouble(5), Good),
.. A,
S(55, HistorianValue.FromDouble(70), Uncertain),
S(62, HistorianValue.Empty, BadNoCommunication),
S(71, HistorianValue.FromDouble(30), Good),
S(100, HistorianValue.FromDouble(60), Good),
];
/// <summary>
/// #680: data that ENDS inside every window (nothing after +52.5): every 2.5 s from −10, values cycling, a Bad
/// sample at +22.5 and an Uncertain one at +40 — the end-of-data rules (TimeAverage2/Total2 stop at the last
/// sample, Partial, then BadNoData; TimeAverage/Total extrapolate, Uncertain).
/// </summary>
private static readonly StoredSample[] EndsInside = Enumerable.Range(0, 26)
.Select(i => (t: -10 + i * 2.5, i))
.Select(x => S(x.t, HistorianValue.FromDouble(10 + (x.i * 7 % 13)),
x.t == 22.5 ? Bad : x.t == 40 ? Uncertain : Good))
.ToArray();
/// <summary>
/// #680: an outage +18 … +45 that wholly contains an interval of every window ([20,40), [20,35), [23,43), (20,40]):
/// TimeAverage/Total bridge it (Uncertain); TimeAverage2/Total2 have no non-Bad time there.
/// </summary>
private static readonly StoredSample[] OutageInside =
[
S(-10, HistorianValue.FromDouble(5), Good),
S(0, HistorianValue.FromDouble(10), Good),
S(15, HistorianValue.FromDouble(20), Good),
S(18, HistorianValue.Empty, BadNoCommunication),
S(45, HistorianValue.FromDouble(30), Good),
S(60, HistorianValue.FromDouble(40), Good),
S(100, HistorianValue.FromDouble(40), Good),
];
/// <summary>
/// #680 review: the comms-loss recovery shape — a Good sample, an outage marker, then ONE recovery sample and nothing
/// after (+27.5, inside an interval of every window, never on an edge): the interval holding the recovery sample has
/// no non-Bad time before it, so TimeAverage2/Total2 answer the sample's value (Total2 0), Uncertain + Partial.
/// </summary>
private static readonly StoredSample[] RecoveryEnds =
[
S(-10, HistorianValue.FromDouble(10), Good),
S(5, HistorianValue.Empty, BadNoCommunication),
S(27.5, HistorianValue.FromDouble(20), Good),
];
/// <summary>
/// #680 review: an outage marker as the LAST sample (+31, nothing after) — ours: the outage runs to the window end
/// (TimeAverage2/Total2 Bad, the percentages 100 % Bad); the SDK: the data ended (BadNoData, Partial on the interval
/// holding the marker). Also TimeAverage/Total over the span the last usable value is held up to the marker: ours
/// Uncertain (Part 13 — no Good value after it, so extrapolated), the SDK Good. Documented, deliberate differences.
/// </summary>
private static readonly StoredSample[] OutageLast =
[
S(-10, HistorianValue.FromDouble(10), Good),
S(25, HistorianValue.FromDouble(20), Good),
S(31, HistorianValue.Empty, BadNoCommunication),
];
/// <summary>
/// #680 review: Boolean (stepped) data with an Uncertain and a Bad sample. Ours steps Booleans (Part 13: a
/// non-analog value is held); the SDK interpolates them as numbers and bounds them sloped — so only timestamps,
/// status and Partial are compared for the values it computes that way (see <see cref="MaskDocumented"/>).
/// </summary>
private static readonly StoredSample[] BoolSwitch =
[
S(-10, HistorianValue.FromBoolean(true), Good),
S(10, HistorianValue.FromBoolean(false), Good),
S(27.5, HistorianValue.FromBoolean(true), Uncertain),
S(32, HistorianValue.FromBoolean(false), Good),
S(47.5, HistorianValue.FromBoolean(true), Good),
S(62, HistorianValue.FromBoolean(true), Bad),
S(71, HistorianValue.FromBoolean(false), Good),
S(100, HistorianValue.FromBoolean(true), Good),
];
/// <summary>Data that starts inside the window (no sample before +7) and runs past its end.</summary>
private static readonly StoredSample[] LateStart = Enumerable.Range(0, 20)
.Select(i => S(7 + i * 5, HistorianValue.FromDouble(i % 2 == 0 ? 20 : 40), Good))
.ToArray();
public static TheoryData<double, double, double> Windows => new()
{
{ 0, 80, 20 }, { 80, 0, 20 }, // whole intervals (samples sit on the edges: the inclusivity question)
{ 0, 50, 20 }, { 50, 0, 20 }, // a truncated interval: latest forward, EARLIEST reverse
{ 5, 80, 15 }, { 80, 5, 15 }, // a misaligned window
{ 3, 77, 20 }, { 77, 3, 20 }, // misaligned and truncated
{ 0, 60, 0 }, { 60, 0, 0 }, // one interval
};
private static IReadOnlyList<DataValueSnapshot> Ours(HistoryAggregateType agg, double from, double to, double intervalS,
StoredSample[] data)
{
var plan = BucketPlan.Create(At(from), At(to), TimeSpan.FromSeconds(intervalS));
if (AggregateFamilies.FamilyOf(agg) == AggregateFamily.TimeWeighted)
return TimeWeightedAggregator.Evaluate(agg, plan, data, Now);
return AggregateEvaluator.EvaluateAll(agg, plan, InMemoryRows(plan, data, agg == HistoryAggregateType.MaximumActualTime),
data.Min(s => s.TimeUtc), data.Max(s => s.TimeUtc), Now);
}
private static IReadOnlyList<SdkAggregateOracle.Result> Sdk(HistoryAggregateType agg, double from, double to,
double intervalS, StoredSample[] data)
{
var interval = intervalS > 0 ? TimeSpan.FromSeconds(intervalS) : (At(to) - At(from)).Duration();
return SdkAggregateOracle.Run(agg, At(from), At(to), interval, data.Select(s => (s.TimeUtc, s.Value, s.Status)));
}
/// <summary>Masks the Partial bit of our one truncated interval (a documented divergence: ours flags it, the SDK does not).</summary>
private static IReadOnlyList<DataValueSnapshot> MaskTruncation(IReadOnlyList<DataValueSnapshot> ours, double from,
double to, double intervalS)
{
var plan = BucketPlan.Create(At(from), At(to), TimeSpan.FromSeconds(intervalS));
var list = ours.ToList();
for (long i = 0; i < plan.BucketCount; i++)
{
if (!plan.IsTruncated(i)) continue;
var k = (int)(plan.Reversed ? plan.BucketCount - 1 - i : i);
list[k] = list[k] with { StatusCode = list[k].StatusCode & ~HistorianInfoBits.HistorianPartial };
}
return list;
}
/// <summary>
/// Applies the documented divergences listed on the class (other than truncation): strips the Calculated bit from our
/// <c>BadNoData</c>; maps our generic-Bad TimeAverage2/Total2 outage interval to the SDK's <c>BadNoData</c>; takes the
/// SDK's value for the end-of-data interval of TimeAverage2/Total2/PercentGood/PercentBad/DurationGood/DurationBad
/// (its 1 ms artifact).
/// </summary>
private static IReadOnlyList<DataValueSnapshot> MaskDocumented(HistoryAggregateType agg, IReadOnlyList<DataValueSnapshot> ours,
IReadOnlyList<SdkAggregateOracle.Result> sdk, double from, double to, double intervalS, StoredSample[] data)
{
var plan = BucketPlan.Create(At(from), At(to), TimeSpan.FromSeconds(intervalS));
var list = ours.ToList();
var last = data.Max(x => x.TimeUtc).Ticks;
var endsInWindow = last < Math.Max(At(from).Ticks, At(to).Ticks);
var dataEnds = endsInWindow && data.Single(x => x.TimeUtc.Ticks == last).IsUsable;
var outageEnds = endsInWindow && !dataEnds;
var boolean = data.Any(x => x.Value.Kind == HistorianValueKind.Boolean);
var percent = agg is HistoryAggregateType.PercentGood or HistoryAggregateType.PercentBad
or HistoryAggregateType.DurationGood or HistoryAggregateType.DurationBad;
for (long i = 0; i < plan.BucketCount; i++)
{
var k = (int)(plan.Reversed ? plan.BucketCount - 1 - i : i);
if (k >= sdk.Count) continue; // the count mismatch is reported by ShouldMatch
var code = list[k].StatusCode & StatusBits.CodeBitsMask;
if (code == HistorianStatusCodes.BadNoData)
list[k] = list[k] with { StatusCode = list[k].StatusCode & ~StatusBits.HistorianBitsMask };
var twoVariant = agg is HistoryAggregateType.TimeAverage2 or HistoryAggregateType.Total2;
if (twoVariant && code == HistorianStatusCodes.Bad && (sdk[k].StatusCode & StatusBits.CodeBitsMask) == HistorianStatusCodes.BadNoData)
list[k] = list[k] with { StatusCode = HistorianStatusCodes.BadNoData };
// Reverse TimeAverage/Total: the SDK's backward UpdateSlice files a sample exactly on the interval's later edge
// as the early bound, so the interval inherits the status of the segment beyond it (the root cause of the
// reverse Interpolative divergence). Ours keeps the interval's own segments.
if (plan.Reversed && agg is HistoryAggregateType.TimeAverage or HistoryAggregateType.Total
&& data.Any(x => x.TimeUtc.Ticks == plan.UpperTicks(i))
&& code == HistorianStatusCodes.Good
&& (sdk[k].StatusCode & StatusBits.CodeBitsMask) == HistorianStatusCodes.UncertainDataSubNormal)
list[k] = list[k] with { StatusCode = (list[k].StatusCode & ~StatusBits.CodeBitsMask) | HistorianStatusCodes.UncertainDataSubNormal };
if (dataEnds && (twoVariant || percent)
&& last >= plan.LowerTicks(i) && last < plan.UpperTicks(i) && sdk[k].Value is not null)
list[k] = list[k] with { Value = sdk[k].Value };
// An outage marker as the last sample: ours runs the outage to the window end (no end-of-data Partial on the
// interval holding it; Bad / 100 % Bad after it); the SDK treats the data as ended there.
if (outageEnds && (twoVariant || percent))
{
if (last >= plan.LowerTicks(i) && last < plan.UpperTicks(i))
list[k] = list[k] with
{
StatusCode = (list[k].StatusCode & ~HistorianInfoBits.HistorianPartial)
| (sdk[k].StatusCode & HistorianInfoBits.HistorianPartial),
};
else if (plan.LowerTicks(i) > last && (sdk[k].StatusCode & StatusBits.CodeBitsMask) == HistorianStatusCodes.BadNoData)
list[k] = list[k] with { Value = null, StatusCode = HistorianStatusCodes.BadNoData };
}
// ...and TimeAverage/Total hold the last usable value up to that marker: Part 13 calls that extrapolation (no
// Good value after it) ⇒ Uncertain; the SDK, which sees a later (Bad) sample, reports the held span Good.
var lastUsable = data.Where(x => x.IsUsable).Max(x => x.TimeUtc).Ticks;
if (outageEnds && agg is HistoryAggregateType.TimeAverage or HistoryAggregateType.Total
&& plan.LowerTicks(i) < last && plan.UpperTicks(i) > lastUsable
&& (list[k].StatusCode & StatusBits.CodeBitsMask) == HistorianStatusCodes.UncertainDataSubNormal
&& (sdk[k].StatusCode & StatusBits.CodeBitsMask) == HistorianStatusCodes.Good)
list[k] = list[k] with { StatusCode = list[k].StatusCode & ~StatusBits.CodeBitsMask };
// Booleans: the SDK slopes them (values, and the status of a bound next to an Uncertain sample); ours steps.
if (boolean && sdk[k].Value is not null && list[k].Value is not null)
list[k] = list[k] with { Value = sdk[k].Value };
if (boolean && (StatusBits.IsGood(list[k].StatusCode) || StatusBits.IsGood(sdk[k].StatusCode))
&& !StatusBits.IsBad(list[k].StatusCode) && !StatusBits.IsBad(sdk[k].StatusCode))
list[k] = list[k] with
{
StatusCode = (list[k].StatusCode & ~StatusBits.CodeBitsMask) | (sdk[k].StatusCode & StatusBits.CodeBitsMask),
};
}
return list;
}
[Theory]
[MemberData(nameof(Windows))]
public void Time_weighted_aggregates_match_the_SDK(double from, double to, double intervalS)
{
var failures = new List<string>();
foreach (var agg in new[]
{
HistoryAggregateType.TimeAverage, HistoryAggregateType.TimeAverage2, HistoryAggregateType.Total,
HistoryAggregateType.Total2, HistoryAggregateType.Interpolative, HistoryAggregateType.PercentGood,
HistoryAggregateType.PercentBad, HistoryAggregateType.DurationGood, HistoryAggregateType.DurationBad,
})
foreach (var (name, data) in new[]
{
("Bounded", Bounded), ("Dense", Dense), ("EndsInside", EndsInside), ("OutageInside", OutageInside),
("RecoveryEnds", RecoveryEnds), ("OutageLast", OutageLast), ("BoolSwitch", BoolSwitch),
})
{
try
{
var sdk = Sdk(agg, from, to, intervalS, data);
var ours = MaskDocumented(agg, MaskTruncation(Ours(agg, from, to, intervalS, data), from, to, intervalS),
sdk, from, to, intervalS, data);
SdkAggregateOracle.ShouldMatch(ours, sdk, $"{agg} {name} {from}→{to}/{intervalS}s",
compareCalculatedInterpolatedBits: !(agg == HistoryAggregateType.Interpolative && from > to),
compareCodeBits: !(agg == HistoryAggregateType.Interpolative && from > to));
}
catch (Exception ex)
{
failures.Add(ex.Message);
}
}
failures.ShouldBeEmpty(string.Join("\n", failures));
}
[Theory]
[MemberData(nameof(Windows))]
public void Per_sample_aggregates_match_the_SDK(double from, double to, double intervalS)
{
var failures = new List<string>();
foreach (var agg in PerSample)
foreach (var (name, data) in new[] { ("Dense", Dense), ("DenseUncertain", DenseUncertain) })
{
try
{
SdkAggregateOracle.ShouldMatch(Ours(agg, from, to, intervalS, data), Sdk(agg, from, to, intervalS, data),
$"{agg} {name} {from}→{to}/{intervalS}s", compareCalculatedInterpolatedBits: false,
compareValues: true);
}
catch (Exception ex)
{
failures.Add(ex.Message);
}
}
failures.ShouldBeEmpty(string.Join("\n", failures));
}
[Theory]
[MemberData(nameof(Windows))]
public void The_uncertain_dataset_is_not_vacuous(double from, double to, double intervalS)
{
// #684: DenseUncertain must discriminate the pre-#684 behaviour in every window, or its parity proves nothing.
// (1) VALUES: Average over the usable (Good + Uncertain) samples differs from the SDK's Good-only Average in some
// interval. (2) STATUS: Minimum / Maximum hold an Uncertain sample in some interval the SDK still calls Good (16 sits
// inside the Good range) — the pre-#684 "any non-Good sample ⇒ Uncertain" rule would answer Uncertain there.
// (Minimum / Maximum VALUES do not discriminate here: an in-range 16 never is the extreme — by design, so the
// min/max family needs no mask; the value half is pinned by UncertainBeyond and AggregateEvaluatorTests.)
var plan = BucketPlan.Create(At(from), At(to), TimeSpan.FromSeconds(intervalS));
// The one-interval windows hold the Bad +22.5 sample, so the SDK is Uncertain there for a reason the old rule shares:
// the status half is checked on the multi-interval windows only (the value half below covers every window).
foreach (var agg in plan.BucketCount > 1 ? new[] { HistoryAggregateType.Minimum, HistoryAggregateType.Maximum } : [])
{
var sdkMinMax = Sdk(agg, from, to, intervalS, DenseUncertain);
var rows = InMemoryRows(plan, DenseUncertain).ToList();
var holdsUncertain = (plan.Reversed ? Enumerable.Reverse(rows) : rows).Select(r => r.Usable > r.Good).ToList();
holdsUncertain.Zip(sdkMinMax).Any(p => p.First && (p.Second.StatusCode & 0xFFFF0000u) == 0)
.ShouldBeTrue($"{agg}: some interval holds an Uncertain sample yet is Good in the SDK");
}
var usableAverage = InMemoryRows(plan, DenseUncertain.Select(x => x.IsUsable && !x.IsGood
? x with { Status = Good } : x))
.Select(r => r.Avg).ToList();
var sdk = Sdk(HistoryAggregateType.Average, from, to, intervalS, DenseUncertain).Select(r => r.Value).ToList();
var ours = plan.Reversed ? Enumerable.Reverse(usableAverage).ToList() : usableAverage;
ours.Zip(sdk).Any(p => p.Second is double d && p.First is double u && Math.Abs(d - u) > 1e-9)
.ShouldBeTrue("an Uncertain sample must change some interval's Average when it is (wrongly) included");
}
/// <summary>
/// #796: one 20 s interval [0, 20) of 5 s regions Good / Uncertain / Bad / Good, a Good sample before it and one after
/// it (no bound or end-of-data effect): Part 13 PercentGood 50 (Uncertain is neither Good nor Bad), PercentBad 25.
/// </summary>
private static readonly StoredSample[] GoodUncertainBadGood =
[
S(-5, HistorianValue.FromDouble(5), Good),
S(0, HistorianValue.FromDouble(10), Good),
S(5, HistorianValue.FromDouble(20), Uncertain),
S(10, HistorianValue.Empty, BadNoCommunication),
S(15, HistorianValue.FromDouble(30), Good),
S(25, HistorianValue.FromDouble(40), Good),
];
/// <summary>#796 / Mantis 0006166: an interval [0, 20) covered by Uncertain data only — PercentGood 0 and PercentBad 0.</summary>
private static readonly StoredSample[] AllUncertain =
[
S(-5, HistorianValue.FromDouble(5), Good),
S(0, HistorianValue.FromDouble(10), Uncertain),
S(10, HistorianValue.FromDouble(20), Uncertain),
S(25, HistorianValue.FromDouble(40), Good),
];
/// <summary>#796: data that starts inside [0, 20) at +5 — 5 s of no data, then 15 s Good.</summary>
private static readonly StoredSample[] StartsInside =
[
S(5, HistorianValue.FromDouble(10), Good),
S(25, HistorianValue.FromDouble(40), Good),
];
private static double Pct(IReadOnlyList<SdkAggregateOracle.Result> r) => Convert.ToDouble(r.Single().Value);
private static double Pct(IReadOnlyList<DataValueSnapshot> r) => Convert.ToDouble(r.Single().Value);
private static IReadOnlyList<SdkAggregateOracle.Result> AsIs(HistoryAggregateType agg, double from, double to,
StoredSample[] data, Opc.Ua.AggregateConfiguration? cfg = null) =>
SdkAggregateOracle.RunSdkAsIs(agg, At(from), At(to), (At(to) - At(from)).Duration(),
data.Select(s => (s.TimeUtc, s.Value, s.Status)), cfg);
[Theory]
[InlineData(0, 20)]
[InlineData(20, 0)]
public void PercentGood_keeps_Uncertain_out_of_Good_per_Part_13_where_the_baseline_SDK_counts_it_as_Good(
double from, double to)
{
// #796, documented divergence of the untouched baseline (SdkAggregateOracle remarks; live follows Part 13 since
// #809). Part 13 §5.4.3.31 / §5.4.3.33: PercentGood is the Good share of the interval. The 1.5.378.182
// StatusAggregateCalculator, under the server's TreatUncertainAsBad = false, adds Uncertain regions to it
// (1.5.378.106 did not). Hand-computed over 4 × 5 s regions G / U / B / G:
// Good 10 s ⇒ 50 %, Bad 5 s ⇒ 25 %, Uncertain 5 s in neither; the baseline says 75 % Good.
Pct(AsIs(HistoryAggregateType.PercentGood, from, to, GoodUncertainBadGood))
.ShouldBe(75d, 1e-9, "the baseline counts the Uncertain 5 s as Good — if this moves, revisit the oracle (#796)");
Pct(Sdk(HistoryAggregateType.PercentGood, from, to, 0, GoodUncertainBadGood)).ShouldBe(50d, 1e-9);
Pct(Ours(HistoryAggregateType.PercentGood, from, to, 0, GoodUncertainBadGood)).ShouldBe(50d, 1e-9);
// PercentBad is unaffected by .182 and is the baseline's own answer.
Pct(Sdk(HistoryAggregateType.PercentBad, from, to, 0, GoodUncertainBadGood)).ShouldBe(25d, 1e-9);
Pct(Ours(HistoryAggregateType.PercentBad, from, to, 0, GoodUncertainBadGood)).ShouldBe(25d, 1e-9);
// Mantis 0006166 ("Uncertain data does not affect the calculations"): all-Uncertain ⇒ 0 % Good AND 0 % Bad.
Pct(AsIs(HistoryAggregateType.PercentGood, from, to, AllUncertain)).ShouldBe(100d, 1e-9);
Pct(Sdk(HistoryAggregateType.PercentGood, from, to, 0, AllUncertain)).ShouldBe(0d, 1e-9);
Pct(Ours(HistoryAggregateType.PercentGood, from, to, 0, AllUncertain)).ShouldBe(0d, 1e-9);
Pct(Sdk(HistoryAggregateType.PercentBad, from, to, 0, AllUncertain)).ShouldBe(0d, 1e-9);
Pct(Ours(HistoryAggregateType.PercentBad, from, to, 0, AllUncertain)).ShouldBe(0d, 1e-9);
// Everything but the value (timestamp, status, Partial, Calculated bits) is the baseline's own.
foreach (var data in new[] { GoodUncertainBadGood, AllUncertain, StartsInside })
{
var oracle = Sdk(HistoryAggregateType.PercentGood, from, to, 0, data).Single();
var baseline = AsIs(HistoryAggregateType.PercentGood, from, to, data).Single();
(oracle.StatusCode, oracle.Timestamp).ShouldBe((baseline.StatusCode, baseline.Timestamp));
}
}
[Theory]
[InlineData(0, 20)]
[InlineData(20, 0)]
public void PercentGood_over_a_partial_interval_without_Uncertain_data_is_the_baseline(double from, double to)
{
// #796: with no Uncertain data the oracle IS the baseline (the change only moves Uncertain time); the time before
// the first sample is non-Good: 15 s Good of 20 s ⇒ 75 %, with the data-start Partial bit.
var oracle = Sdk(HistoryAggregateType.PercentGood, from, to, 0, StartsInside);
AsIs(HistoryAggregateType.PercentGood, from, to, StartsInside).ShouldBe(oracle);
Pct(oracle).ShouldBe(75d, 1e-9);
SdkAggregateOracle.ShouldMatch(Ours(HistoryAggregateType.PercentGood, from, to, 0, StartsInside), oracle,
$"PercentGood StartsInside {from}→{to}");
}
[Fact]
public void PercentGood_is_not_100_minus_PercentBad_under_TreatUncertainAsBad()
{
// #796: why the oracle counts Good regions instead of deriving 100 − PercentBad under TreatUncertainAsBad = true.
// The flag also changes the baseline's region machinery (IsGood drives the end-of-data 1 ms rule): after an
// Uncertain LAST sample the region to the BadNoData end bound is 1 ms under false but the rest of the interval
// under true — a different total from the one the server-config PercentBad is computed over.
// Good +0, Uncertain +10, nothing after; [0, 20):
// false: Good 10 000 ms + Uncertain 1 ms ⇒ PercentGood 10 000 / 10 001, PercentBad 0 (the 1 ms artifact; this
// end-of-data interval's value is a documented, masked divergence — ours runs it to the interval end);
// true: Good 10 000 ms + Uncertain-as-Bad 10 000 ms ⇒ 100 − 50 = 50.
StoredSample[] data = [S(0, HistorianValue.FromDouble(10), Good), S(10, HistorianValue.FromDouble(20), Uncertain)];
Pct(Sdk(HistoryAggregateType.PercentGood, 0, 20, 0, data)).ShouldBe(10_000d / 10_001 * 100, 1e-9);
Pct(AsIs(HistoryAggregateType.PercentBad, 0, 20, data)).ShouldBe(0d, 1e-9);
var cfg = SdkAggregateOracle.DefaultConfiguration;
cfg.TreatUncertainAsBad = true;
(100 - Pct(AsIs(HistoryAggregateType.PercentBad, 0, 20, data, cfg))).ShouldBe(50d, 1e-9);
}
/// <summary>
/// #821: data that starts inside [0, 20) at +5 with an <b>Uncertain</b> first sample — the #810 QualityMix tag QP:
/// Uncertain 10 at +5, Good 11 at +12, Uncertain 12 at +25, nothing after.
/// </summary>
private static readonly StoredSample[] UncertainFirstLateStart =
[
S(5, HistorianValue.FromDouble(10), Uncertain),
S(12, HistorianValue.FromDouble(11), Good),
S(25, HistorianValue.FromDouble(12), Uncertain),
];
/// <summary>The <see cref="UncertainFirstLateStart"/> control: the same data with a <b>Good</b> first sample.</summary>
private static readonly StoredSample[] GoodFirstLateStart =
[S(5, HistorianValue.FromDouble(10), Good), .. UncertainFirstLateStart[1..]];
/// <summary>
/// Runs SDK 2.0.1's OWN stock calculator (<see cref="Opc.Ua.Server.Aggregators"/>, not the frozen 1.5.378.182 copy
/// the server registers) under the server-default configuration — for the #809/#821 facts only.
/// </summary>
private static List<(double? Value, uint Status)> Stock201(HistoryAggregateType agg, double from, double to,
StoredSample[] data)
{
var id = agg switch
{
HistoryAggregateType.Total => Opc.Ua.ObjectIds.AggregateFunction_Total,
HistoryAggregateType.TimeAverage => Opc.Ua.ObjectIds.AggregateFunction_TimeAverage,
HistoryAggregateType.DurationGood => Opc.Ua.ObjectIds.AggregateFunction_DurationGood,
HistoryAggregateType.PercentGood => Opc.Ua.ObjectIds.AggregateFunction_PercentGood,
_ => throw new ArgumentOutOfRangeException(nameof(agg)),
};
var calc = Opc.Ua.Server.Aggregators.CreateStandardCalculator(id, At(from), At(to),
(At(to) - At(from)).Duration().TotalMilliseconds, false, SdkAggregateOracle.DefaultConfiguration,
Opc.Ua.DefaultTelemetry.Create(_ => { }))!;
var ordered = data.OrderBy(s => s.TimeUtc).ToList();
if (to < from) ordered.Reverse();
foreach (var s in ordered)
calc.QueueRawValue(new Opc.Ua.DataValue(Opc.Ua.Variant.From(s.Value.Number), s.Status, s.TimeUtc, s.TimeUtc));
var results = new List<(double?, uint)>();
while (calc.TryGetProcessedValue(true, out var dv))
results.Add((dv.WrappedValue.TryGetValue(out double d) ? d : null, dv.StatusCode.Code));
return results;
}
[Theory]
[InlineData(0, 20)]
[InlineData(20, 0)]
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, 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 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.
Stock201(HistoryAggregateType.Total, from, to, UncertainFirstLateStart).Single().Value!.Value
.ShouldBe(part13Total + 25, 1e-9);
Stock201(HistoryAggregateType.TimeAverage, from, to, UncertainFirstLateStart).Single().Value!.Value
.ShouldBe((part13Total + 25) / 20, 1e-9);
// 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 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);
}
// #809 on the same input: DurationGood / PercentGood — Part 13 (ours), stock SDK 2.0.1 and, since #809, the live
// calculators count Good regions only ([12,20) = 8 s); the untouched baseline (live before #809) also counted the
// Uncertain [5,12) as Good (15 s).
Pct(Ours(HistoryAggregateType.DurationGood, from, to, 0, UncertainFirstLateStart)).ShouldBe(8_000d, 1e-9);
Stock201(HistoryAggregateType.DurationGood, from, to, UncertainFirstLateStart).Single().Value!.Value.ShouldBe(8_000d, 1e-9);
Pct(Sdk(HistoryAggregateType.DurationGood, from, to, 0, UncertainFirstLateStart)).ShouldBe(8_000d, 1e-9);
Pct(AsIs(HistoryAggregateType.DurationGood, from, to, UncertainFirstLateStart)).ShouldBe(15_000d, 1e-9);
Pct(Ours(HistoryAggregateType.PercentGood, from, to, 0, UncertainFirstLateStart)).ShouldBe(40d, 1e-9);
Stock201(HistoryAggregateType.PercentGood, from, to, UncertainFirstLateStart).Single().Value!.Value.ShouldBe(40d, 1e-9);
Pct(Sdk(HistoryAggregateType.PercentGood, from, to, 0, UncertainFirstLateStart)).ShouldBe(40d, 1e-9);
Pct(AsIs(HistoryAggregateType.PercentGood, from, to, UncertainFirstLateStart)).ShouldBe(75d, 1e-9);
}
/// <summary>
/// #809: the #810 QualityMix tag Q — Good / Uncertain / Bad samples and an outage marker, Uncertain regions starting
/// at a sample, through a sloped bound and held into an outage, with Good samples before (−10) and after (+90) every
/// window so no interval holds the start or the end of the data.
/// </summary>
private static readonly StoredSample[] UncertainMix =
[
S(-10, HistorianValue.FromDouble(10), Good),
S(0, HistorianValue.FromDouble(10), Good),
S(4, HistorianValue.FromDouble(12), Uncertain),
S(7, HistorianValue.FromDouble(13), Bad),
S(10, HistorianValue.FromDouble(14), Good),
S(13, HistorianValue.FromDouble(15), Uncertain),
S(20, HistorianValue.FromDouble(16), Uncertain),
S(25, HistorianValue.FromDouble(17), Uncertain),
S(33, HistorianValue.FromDouble(18), Uncertain),
S(40, HistorianValue.FromDouble(20), Good),
S(45, HistorianValue.Empty, BadNoCommunication),
S(50, HistorianValue.FromDouble(22), Uncertain),
S(56, HistorianValue.FromDouble(23), Good),
S(64, HistorianValue.FromDouble(25), Uncertain),
S(67, HistorianValue.FromDouble(26), Good),
S(75, HistorianValue.FromDouble(27), Bad),
S(78, HistorianValue.FromDouble(28), Good),
S(90, HistorianValue.FromDouble(30), Good),
];
/// <summary>Windows of whole intervals only (no truncated interval — ours flags it Partial, a documented divergence).</summary>
public static TheoryData<double, double, double> WholeIntervalWindows => new()
{
{ 0, 80, 20 }, { 80, 0, 20 },
{ 0, 80, 10 }, { 80, 0, 10 },
{ 5, 80, 15 }, { 80, 5, 15 },
{ 0, 60, 0 }, { 60, 0, 0 },
};
[Theory]
[MemberData(nameof(WholeIntervalWindows))]
public void Live_AggregateFilter_PercentGood_and_DurationGood_equal_HistoryRead_over_Uncertain_samples(
double from, double to, double intervalS)
{
// #809: a live AggregateFilter monitored item and a HistoryRead-Processed (SqlServer / QuestDB share
// TimeWeightedAggregator) answer PercentGood / DurationGood identically over Uncertain data — same values,
// timestamps, status and Partial / Calculated bits, with NO documented-divergence mask. RunLive is exactly
// CompatibilityAggregateFactory.Create, the calculator the server registers (Sdk2AggregateCompatibilityTests
// proves the booted runtime manager serves the same code).
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.PercentGood, HistoryAggregateType.DurationGood })
foreach (var (name, data) in new[] { ("UncertainMix", UncertainMix), ("DenseUncertain", DenseUncertain) })
{
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-#809 live calculator (the untouched baseline) counts the Uncertain
// time as Good, so it disagrees somewhere — 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-#809 live answer somewhere");
}
[Fact]
public void Live_AggregateFilter_PercentGood_and_DurationGood_are_the_hand_computed_Part_13_values()
{
// #809, hand-computed over UncertainMix's eight 10 s intervals (QualityMixConformance derives them region by
// region): Good seconds 4, 3, 0, 0, 5, 4, 3, 2. The pre-#809 live answer added the Uncertain time
// (7, 10, 10, 10, 5, 10, 10, 7 s).
double[] goodSeconds = [4, 3, 0, 0, 5, 4, 3, 2];
var rows = UncertainMix.Select(x => (x.TimeUtc, x.Value, x.Status)).ToArray();
var ten = TimeSpan.FromSeconds(10);
SdkAggregateOracle.RunLive(HistoryAggregateType.DurationGood, At(0), At(80), ten, rows)
.Select(r => Convert.ToDouble(r.Value)).ShouldBe(goodSeconds.Select(g => g * 1000), tolerance: 1e-9);
SdkAggregateOracle.RunLive(HistoryAggregateType.PercentGood, At(0), At(80), ten, rows)
.Select(r => Convert.ToDouble(r.Value)).ShouldBe(goodSeconds.Select(g => g * 10), tolerance: 1e-9);
SdkAggregateOracle.RunSdkAsIs(HistoryAggregateType.DurationGood, At(0), At(80), ten, rows)
.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 =
[
S(-5, HistorianValue.FromDouble(20), Good),
S(2, HistorianValue.FromDouble(10), Good),
S(4, HistorianValue.FromDouble(5), Uncertain),
S(6, HistorianValue.FromDouble(99), Uncertain),
S(8, HistorianValue.FromDouble(30), Good),
S(25, HistorianValue.FromDouble(20), Good),
];
[Theory]
[InlineData(HistoryAggregateType.Minimum, 0, 20)]
[InlineData(HistoryAggregateType.Minimum, 20, 0)]
[InlineData(HistoryAggregateType.Maximum, 0, 20)]
[InlineData(HistoryAggregateType.Maximum, 20, 0)]
[InlineData(HistoryAggregateType.Range, 0, 20)]
[InlineData(HistoryAggregateType.Range, 20, 0)]
[InlineData(HistoryAggregateType.MinimumActualTime, 0, 20)]
[InlineData(HistoryAggregateType.MaximumActualTime, 20, 0)]
public void An_uncertain_value_beyond_the_good_extreme_is_Uncertain_per_Part_13_where_the_SDK_says_Good(
HistoryAggregateType agg, double from, double to)
{
// #684, documented divergence (class remarks). Part 13 Tables 57-62: "If an Uncertain value is less than the
// minimum Good value [greater than the maximum] the Status is Uncertain_DataSubNormal". The SDK's ComputeMinMax
// tracks the Uncertain extremes and never reads them. Everything else — the value (the GOOD extreme, never 5 or
// 99), the timestamp, the Partial bit — matches the SDK; only the code bits differ, and only as Part 13 says.
var ours = Ours(agg, from, to, 0, UncertainBeyond).Single();
var sdk = Sdk(agg, from, to, 0, UncertainBeyond).Single();
(sdk.StatusCode & StatusBits.CodeBitsMask).ShouldBe(HistorianStatusCodes.Good, "the SDK ignores the Uncertain extreme");
(ours.StatusCode & StatusBits.CodeBitsMask).ShouldBe(HistorianStatusCodes.UncertainDataSubNormal);
SdkAggregateOracle.ShouldMatch([ours with { StatusCode = ours.StatusCode & ~StatusBits.CodeBitsMask }], [sdk],
$"{agg} UncertainBeyond {from}→{to}", compareCalculatedInterpolatedBits: false);
ours.Value.ShouldBe(agg switch
{
HistoryAggregateType.Minimum or HistoryAggregateType.MinimumActualTime => 10d,
HistoryAggregateType.Maximum or HistoryAggregateType.MaximumActualTime => 30d,
_ => 20d,
});
}
/// <summary>#684 review: Good numeric samples with an Uncertain STRING among them (+4).</summary>
private static readonly StoredSample[] UncertainString =
[
S(-5, HistorianValue.FromDouble(20), Good),
S(2, HistorianValue.FromDouble(10), Good),
S(4, HistorianValue.FromString("x"), Uncertain),
S(8, HistorianValue.FromDouble(30), Good),
S(25, HistorianValue.FromDouble(20), Good),
];
[Theory]
[InlineData(0, 20)]
[InlineData(20, 0)]
public void An_uncertain_non_numeric_sample_is_skipped_by_the_good_value_aggregates_like_the_SDK(double from, double to)
{
// #684 review: an Uncertain String is not part of the Good-value set, so it no longer refuses the interval
// (BadAggregateInvalidInputs); the SDK's Average skips it (value-based status: Uncertain) and its ComputeMinMax flags
// the value it cannot cast (Uncertain). Exact match, every bit but Calculated.
var failures = new List<string>();
foreach (var agg in new[]
{
HistoryAggregateType.Average, HistoryAggregateType.Minimum, HistoryAggregateType.Maximum,
HistoryAggregateType.Range, HistoryAggregateType.MinimumActualTime, HistoryAggregateType.MaximumActualTime,
})
{
try
{
var ours = Ours(agg, from, to, 0, UncertainString);
SdkAggregateOracle.ShouldMatch(ours, Sdk(agg, from, to, 0, UncertainString), $"{agg} UncertainString {from}→{to}",
compareCalculatedInterpolatedBits: false);
(ours.Single().StatusCode & StatusBits.CodeBitsMask).ShouldBe(HistorianStatusCodes.UncertainDataSubNormal, agg.ToString());
}
catch (Exception ex)
{
failures.Add(ex.Message);
}
}
failures.ShouldBeEmpty(string.Join("\n", failures));
// Delta keeps its usable picks and refusal (#680, unchanged): no Good-only reading applies to it.
Ours(HistoryAggregateType.Delta, from, to, 0, UncertainString).Single().StatusCode
.ShouldBe(HistorianInfoBits.WithCalculated(HistorianStatusCodes.BadAggregateInvalidInputs));
}
[Theory]
[InlineData(0, 20)]
[InlineData(20, 0)]
public void Average_over_uncertain_values_beyond_the_good_range_matches_the_SDK_exactly(double from, double to)
{
// #684: Average has no extreme rule — Good values only, value-based status (Uncertain: not every sample is Good).
SdkAggregateOracle.ShouldMatch(Ours(HistoryAggregateType.Average, from, to, 0, UncertainBeyond),
Sdk(HistoryAggregateType.Average, from, to, 0, UncertainBeyond), $"Average UncertainBeyond {from}→{to}");
Ours(HistoryAggregateType.Average, from, to, 0, UncertainBeyond).Single().Value.ShouldBe(20d);
}
private static readonly HistoryAggregateType[] PerSample =
[
HistoryAggregateType.Average, HistoryAggregateType.Minimum, HistoryAggregateType.Maximum,
HistoryAggregateType.Count, HistoryAggregateType.Range, HistoryAggregateType.Start, HistoryAggregateType.End,
HistoryAggregateType.Delta, HistoryAggregateType.MinimumActualTime, HistoryAggregateType.MaximumActualTime,
];
[Theory]
[InlineData(HistoryAggregateType.TimeAverage)]
[InlineData(HistoryAggregateType.TimeAverage2)]
[InlineData(HistoryAggregateType.Total)]
[InlineData(HistoryAggregateType.Total2)]
[InlineData(HistoryAggregateType.Minimum)]
[InlineData(HistoryAggregateType.Maximum)]
[InlineData(HistoryAggregateType.Count)]
[InlineData(HistoryAggregateType.Range)]
[InlineData(HistoryAggregateType.Delta)]
[InlineData(HistoryAggregateType.Start)]
[InlineData(HistoryAggregateType.Average)]
public void A_reverse_read_over_data_starting_inside_the_window_sets_no_start_of_data_Partial(HistoryAggregateType agg)
{
// Review F1: the SDK applies its start-of-data rule only on a FORWARD read (a reverse read keys m_startOfData on the
// first value it is fed — the latest — which is past the window here). Reverse 40 → 0 at 10 s, first sample +7:
// (0,10] holds the start of the data but is NOT Partial.
var tw = AggregateFamilies.FamilyOf(agg) == AggregateFamily.TimeWeighted;
var ours = Ours(agg, 40, 0, 10, LateStart);
SdkAggregateOracle.ShouldMatch(ours, Sdk(agg, 40, 0, 10, LateStart), $"{agg} reverse LateStart",
compareCalculatedInterpolatedBits: tw,
compareCodeBits: true);
ours.ShouldAllBe(x => (x.StatusCode & HistorianInfoBits.HistorianPartial) == 0);
}
[Theory]
[InlineData(HistoryAggregateType.TimeAverage)]
[InlineData(HistoryAggregateType.TimeAverage2)]
[InlineData(HistoryAggregateType.Total)]
[InlineData(HistoryAggregateType.Total2)]
[InlineData(HistoryAggregateType.Minimum)]
[InlineData(HistoryAggregateType.Maximum)]
[InlineData(HistoryAggregateType.Count)]
[InlineData(HistoryAggregateType.Average)]
public void The_interval_the_data_starts_inside_matches_the_SDK_Partial_bit(HistoryAggregateType agg)
{
// Forward [0,40) at 10 s: the data starts at +7, inside [0,10) — the SDK sets Partial there (m_startOfData) for
// every calculator but plain Average; [10,20) … are fully covered.
var tw = AggregateFamilies.FamilyOf(agg) == AggregateFamily.TimeWeighted;
var ours = Ours(agg, 0, 40, 10, LateStart);
SdkAggregateOracle.ShouldMatch(ours, Sdk(agg, 0, 40, 10, LateStart), $"{agg} LateStart",
compareCalculatedInterpolatedBits: tw);
(ours[0].StatusCode & HistorianInfoBits.HistorianPartial).ShouldBe(
agg == HistoryAggregateType.Average ? 0u : HistorianInfoBits.HistorianPartial);
ours.Skip(1).ShouldAllBe(x => (x.StatusCode & HistorianInfoBits.HistorianPartial) == 0);
}
/// <summary>
/// #680: sparse data with Good, Uncertain and Bad samples, empty intervals inside the data range, and intervals before
/// and after it: +1 G, +2 U, +25 Bad, +45 G, +70 G.
/// </summary>
private static readonly StoredSample[] Gappy =
[
S(1, HistorianValue.FromDouble(10), Good),
S(2, HistorianValue.FromDouble(11), Uncertain),
S(25, HistorianValue.FromDouble(12), Bad),
S(45, HistorianValue.FromDouble(13), Good),
S(70, HistorianValue.FromDouble(1), Good),
];
/// <summary>#680: Bad samples leading, trailing and mid-interval, plus an Uncertain one (Start / End / Delta).</summary>
private static readonly StoredSample[] Bracketed =
[
S(-5, HistorianValue.FromDouble(1), Good),
S(1, HistorianValue.FromDouble(10), Bad),
S(3, HistorianValue.FromDouble(11), Good),
S(5, HistorianValue.FromDouble(12), Bad),
S(7, HistorianValue.FromDouble(13), Uncertain),
S(9, HistorianValue.FromDouble(15), Good),
S(12, HistorianValue.FromDouble(20), Good),
S(14, HistorianValue.Empty, BadNoCommunication),
S(16, HistorianValue.FromDouble(21), Good),
S(19, HistorianValue.Empty, BadNoCommunication),
S(40, HistorianValue.FromDouble(1), Good),
];
public static TheoryData<double, double, double> GappyWindows => new()
{
{ 0, 60, 10 }, { 60, 0, 10 }, // empty intervals inside the data range; +70 is past the window
{ -10, 1, 5 }, { 1, -10, 5 }, // intervals before the first sample; one ending ON it (forward)
{ 70, 85, 5 }, { 85, 70, 5 }, // intervals after the last sample; one starting ON it (reverse)
{ 0, 1, 1 }, // one interval ending exactly on the first sample: empty but in range
};
[Theory]
[MemberData(nameof(GappyWindows))]
public void Count_Start_End_and_Delta_match_the_SDK_across_the_data_range(double from, double to, double intervalS)
{
// #680: Count over Good samples only with the value-based status, 0 (not BadNoData) for an empty interval inside
// [first, last], BadNoData outside it; Start/End the raw first/last sample; Delta's status from its bounding samples.
var failures = new List<string>();
foreach (var agg in new[]
{
HistoryAggregateType.Count, HistoryAggregateType.Start, HistoryAggregateType.End, HistoryAggregateType.Delta,
})
foreach (var (name, data) in new[] { ("Gappy", Gappy), ("Bracketed", Bracketed) })
{
try
{
SdkAggregateOracle.ShouldMatch(Ours(agg, from, to, intervalS, data), Sdk(agg, from, to, intervalS, data),
$"{agg} {name} {from}→{to}/{intervalS}s", compareCalculatedInterpolatedBits: false);
}
catch (Exception ex)
{
failures.Add(ex.Message);
}
}
failures.ShouldBeEmpty(string.Join("\n", failures));
}
[Theory]
[InlineData(0, 20, 10)]
[InlineData(20, 0, 10)]
[InlineData(0, 20, 0)]
[InlineData(20, 0, 0)]
public void Start_End_and_Delta_over_bad_bounding_samples_match_the_SDK_exactly(double from, double to, double intervalS)
{
// #680: with the raw sample's own status (incl. a Bad one and its null value) Start/End must match the SDK to the
// bit; Delta's Calculated bit as well.
foreach (var agg in new[] { HistoryAggregateType.Start, HistoryAggregateType.End, HistoryAggregateType.Delta })
SdkAggregateOracle.ShouldMatch(Ours(agg, from, to, intervalS, Bracketed), Sdk(agg, from, to, intervalS, Bracketed),
$"{agg} Bracketed {from}→{to}/{intervalS}s");
}
/// <summary>#680: data ending inside the window (−5, +3, +25) and ending exactly on an interval edge (+20).</summary>
private static readonly StoredSample[] EarlyEnd =
[S(-5, HistorianValue.FromDouble(1), Good), S(3, HistorianValue.FromDouble(2), Good), S(25, HistorianValue.FromDouble(3), Good)];
private static readonly StoredSample[] EndOnEdge =
[S(-5, HistorianValue.FromDouble(1), Good), S(3, HistorianValue.FromDouble(2), Good), S(20, HistorianValue.FromDouble(3), Good)];
[Theory]
[InlineData(0, 40, 10)]
[InlineData(0, 40, 25)]
[InlineData(0, 40, 0)]
[InlineData(40, 0, 10)]
[InlineData(40, 0, 25)]
[InlineData(40, 0, 0)]
public void The_interval_the_data_ends_inside_matches_the_SDK_Partial_bit(double from, double to, double intervalS)
{
// #680 (Part 13 §5.3.3.2 Case 2): the interval holding the last sample is Partial for every per-sample aggregate but
// Average — forward with its earlier edge included (SDK m_endOfData); reverse only strictly inside (the SDK's
// reverse read keys m_startOfData on the first value it is fed, the latest), so EndOnEdge sets nothing in reverse.
var failures = new List<string>();
foreach (var agg in PerSample)
foreach (var (name, data) in new[] { ("EarlyEnd", EarlyEnd), ("EndOnEdge", EndOnEdge) })
{
try
{
SdkAggregateOracle.ShouldMatch(Ours(agg, from, to, intervalS, data), Sdk(agg, from, to, intervalS, data),
$"{agg} {name} {from}→{to}/{intervalS}s", compareCalculatedInterpolatedBits: false);
}
catch (Exception ex)
{
failures.Add(ex.Message);
}
}
failures.ShouldBeEmpty(string.Join("\n", failures));
// Not vacuous: the forward EarlyEnd read does flag [20,30) (and only it).
Ours(HistoryAggregateType.Minimum, 0, 40, 10, EarlyEnd).Select(x => x.StatusCode & HistorianInfoBits.HistorianPartial)
.ShouldBe([0u, 0u, HistorianInfoBits.HistorianPartial, 0u]);
// ... and the reverse one (40 → 0: (30,40], (20,30], (10,20], (0,10]) flags (20,30], which holds +25 strictly.
Ours(HistoryAggregateType.Minimum, 40, 0, 10, EarlyEnd).Select(x => x.StatusCode & HistorianInfoBits.HistorianPartial)
.ShouldBe([0u, HistorianInfoBits.HistorianPartial, 0u, 0u]);
}
/// <summary>#680 review: the data ends on an OUTAGE MARKER (+25) — the interval holding it is all-Bad.</summary>
private static readonly StoredSample[] OutageEndsData =
[
S(-5, HistorianValue.FromDouble(1), Good), S(3, HistorianValue.FromDouble(2), Good),
S(25, HistorianValue.Empty, BadNoCommunication),
];
[Theory]
[InlineData(0, 40, 10)]
[InlineData(40, 0, 10)]
public void An_outage_marker_as_the_last_sample_sets_no_end_of_data_Partial_on_its_Bad_interval(double from, double to,
double intervalS)
{
// Documented divergence (remarks): the SDK sets the end-of-data Partial bit on that interval's Bad result too; ours
// never puts Partial on a Bad result. Masked by clearing Partial on the SDK's Bad results only — every other bit,
// timestamp and value (and every non-Bad interval's Partial) is still compared.
var failures = new List<string>();
var sawSdkPartialOnBad = false;
foreach (var agg in PerSample)
{
var ours = Ours(agg, from, to, intervalS, OutageEndsData);
var sdk = Sdk(agg, from, to, intervalS, OutageEndsData);
sawSdkPartialOnBad |= sdk.Any(r => (r.StatusCode & 0x80000000u) != 0 && (r.StatusCode & 0x4u) != 0);
var masked = sdk.Select(r => (r.StatusCode & 0x80000000u) != 0 ? r with { StatusCode = r.StatusCode & ~0x4u } : r)
.ToList();
try
{
SdkAggregateOracle.ShouldMatch(ours, masked, $"{agg} OutageEndsData {from}→{to}/{intervalS}s",
compareCalculatedInterpolatedBits: false);
// The interval holding the marker ((20,30] / [20,30)) is Bad and carries no Partial.
var k = from < to ? 2 : 1;
((ours[k].StatusCode & 0x80000000u) != 0).ShouldBeTrue($"{agg}: the outage interval is Bad");
(ours[k].StatusCode & HistorianInfoBits.HistorianPartial).ShouldBe(0u, $"{agg}: no Partial on a Bad result");
}
catch (Exception ex)
{
failures.Add(ex.Message);
}
}
failures.ShouldBeEmpty(string.Join("\n", failures));
sawSdkPartialOnBad.ShouldBeTrue("the mask is not vacuous: the SDK does flag the Bad interval Partial");
}
/// <summary>The per-bucket statistics the backends' SQL computes, built in memory with <see cref="BucketPlan.IndexOf"/>.</summary>
internal static IEnumerable<BucketRow> InMemoryRows(BucketPlan plan, IEnumerable<StoredSample> samples, bool maximum = false)
{
foreach (var g in samples.Select(s => (s, b: plan.IndexOf(s.TimeUtc))).Where(x => x.b is not null)
.GroupBy(x => x.b!.Value).OrderBy(g => g.Key))
{
var all = g.Select(x => x.s).OrderBy(s => s.TimeUtc).ToList();
var usable = all.Where(s => s.IsUsable).ToList();
var numeric = usable.Where(s => s.Value.Kind is HistorianValueKind.Double or HistorianValueKind.Int64
or HistorianValueKind.Boolean).ToList();
// #684: the value columns are over Good numeric samples; the Uncertain extremes feed the Min/Max status rule.
var good = numeric.Where(s => s.IsGood).ToList();
var uncertain = numeric.Where(s => !s.IsGood).ToList();
// ActualTime: the extreme Good value, ties ⇒ earliest (the SQL's ROW_NUMBER order).
var extreme = good.OrderBy(s => maximum ? -s.Value.Number : s.Value.Number).ThenBy(s => s.TimeUtc)
.Cast<StoredSample?>().FirstOrDefault();
yield return new BucketRow
{
Bucket = g.Key,
Total = all.Count,
Usable = usable.Count,
Good = all.Count(s => s.IsGood),
Bad = all.Count - usable.Count,
NonNumeric = usable.Count - numeric.Count,
GoodNonNumeric = usable.Count(s => s.IsGood) - numeric.Count(s => s.IsGood),
Avg = good.Count == 0 ? null : good.Average(s => s.Value.Number),
Min = good.Count == 0 ? null : good.Min(s => s.Value.Number),
Max = good.Count == 0 ? null : good.Max(s => s.Value.Number),
Sum = good.Count == 0 ? null : good.Sum(s => s.Value.Number),
UncertainMin = uncertain.Count == 0 ? null : uncertain.Min(s => s.Value.Number),
UncertainMax = uncertain.Count == 0 ? null : uncertain.Max(s => s.Value.Number),
First = usable.Count == 0 ? HistorianValue.Empty : usable[0].Value,
Last = usable.Count == 0 ? HistorianValue.Empty : usable[^1].Value,
Extreme = extreme?.Value.Number,
ExtremeTime = extreme?.TimeUtc,
FirstAny = all[0],
LastAny = all[^1],
};
}
}
}