Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/SdkAggregateParityTests.cs
T
Joseph Doherty e7b485c0a2 feat(historian): serve DurationGood/DurationBad on the SQL backends; pin the #821 late-start facts (#820, #821)
#820: SqlServer + QuestDB now serve Part 13 DurationGood / DurationBad from the
same Good / Bad region totals TimeWeightedAggregator already computes for
PercentGood / PercentBad — reported in milliseconds (OPC UA Duration, the SDK's
unit), Uncertain time in neither (Mantis 0006166), same BadNoData / status /
Partial rules. AggregateFamilies.Served grows 17 -> 19, so i=11201 and the
up-front refusal follow automatically; Gateway still refuses both (explicit
served set, unchanged). Client.Shared AggregateType / mapper / CLI gain both.
The #796 oracle answers DurationGood through the Good-only subclass as well.
QualityMix conformance asserts both directly (Q and QP, hand values, ms, and
= PercentGood x interval), replaces the "refused up front" test, and adds
Duration* to the forward/reverse/truncated oracle comparisons.

#821 (facts only, no decision): SdkAggregateParityTests pins an Uncertain-first
late-start interval (QP over [0,20), both directions) against ours (Part 13
Total 163.96, TimeAverage 10.93), the frozen 1.5.378.182 calculators (188.96,
9.448), SDK 2.0.1's stock calculators (188.96, 9.448) and a Good-first control
(all 163.96); LiveAggregateLateStartFactsTests pins the same through the booted
server's runtime AggregateManager (live AggregateFilter) incl. DurationGood
15000 / PercentGood 75 there vs 8000 / 40 in HistoryRead (#809). QualityMix
adds the reverse late-start variant (reverse TW pin + reverse oracle windows).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-11 01:28:54 -05:00

923 lines
56 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 (SdkAggregateOracle remarks). 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, pinned divergence (pending the #809/#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.
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 frozen 1.5.378.182 calculators — 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.
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);
(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
// 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(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) and stock SDK 2.0.1 count Good regions only
// ([12,20) = 8 s); the frozen live calculators also count 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(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(AsIs(HistoryAggregateType.PercentGood, from, to, UncertainFirstLateStart)).ShouldBe(75d, 1e-9);
}
/// <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],
};
}
}
}