e7b485c0a2
#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>
923 lines
56 KiB
C#
923 lines
56 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/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],
|
||
};
|
||
}
|
||
}
|
||
}
|