a11ab0dc08
Part 13 defines Average/Minimum/Maximum/Range/*ActualTime over Good raw values (independent of TreatUncertainAsBad) and the SDK's calculators skip Uncertain values under the server's TreatUncertainAsBad=false. SqlServer/QuestDB value columns now use the good flag (g) instead of usable (u); new UncMin/UncMax columns feed the Part 13 Minimum/Maximum/Range status rule (Uncertain on a Bad sample or an Uncertain value beyond the Good extreme -- the SDK applies only the Bad half). An Uncertain-only interval is BadNoData. Uncertain-bearing parity datasets (in-memory + real SQL) match the SDK unmasked; the beyond-extreme divergence is pinned explicitly. Co-Authored-By: Claude <noreply@anthropic.com>
666 lines
40 KiB
C#
666 lines
40 KiB
C#
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>>= 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 (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;
|
||
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 || agg is HistoryAggregateType.PercentGood or HistoryAggregateType.PercentBad)
|
||
&& 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,
|
||
})
|
||
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 actually move the per-sample values the pre-#684 (Good + Uncertain) columns computed —
|
||
// otherwise its parity proves nothing. Average over the usable samples differs from the SDK's in some interval of
|
||
// every window.
|
||
var plan = BucketPlan.Create(At(from), At(to), TimeSpan.FromSeconds(intervalS));
|
||
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>#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,
|
||
});
|
||
}
|
||
|
||
[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,
|
||
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],
|
||
};
|
||
}
|
||
}
|
||
}
|