Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/SdkAggregateParityTests.cs
T
Joseph Doherty 0d206a0884 fix(historian): #680 review — recovery/only-sample TA2/Total2 value, outage-last + Boolean parity, docs
TimeAverage2/Total2: the interval the data ends inside with no non-Bad time
before its last sample (comms-loss recovery, a single sample) answers that
sample's value (Total2 0), Uncertain + Partial, instead of Bad/BadNoData —
as the SDK. Parity datasets RecoveryEnds, OutageLast, BoolSwitch with
documented masks; ProcessedConformance's reverse TA/Total mask narrowed to
the later-edge-sample interval. Docs: report-by-exception consequence,
outage-last and Boolean differences; CLAUDE.md historian bullet.
2026-10-11 16:02:22 -04:00

415 lines
26 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Conformance;
using static ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests.Fixture;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests;
/// <summary>
/// #677: our processed reads against the pinned SDK's own Part 13 aggregate calculators (<see cref="SdkAggregateOracle"/>),
/// forward AND reverse, bucket by bucket — timestamps, values and status. The per-sample aggregates are bucketed with
/// <see cref="BucketPlan.IndexOf"/> (the C# statement of the backends' SQL bucket math; the integration suites run the
/// same comparison through the real SQL) and evaluated by <see cref="AggregateEvaluator.EvaluateAll"/>; the
/// time-weighted ones by <see cref="TimeWeightedAggregator"/>.
/// </summary>
/// <remarks>
/// Deliberate, documented divergences the datasets avoid (they are not anchoring questions):
/// <list type="bullet">
/// <item>an <b>empty</b> interval inside the data range: the SDK's Count answers 0/Good, ours <c>BadNoData</c>;</item>
/// <item><b>Uncertain</b> samples in a per-sample aggregate: the SDK's Average/Count/Min/Max use Good samples only,
/// ours every usable (Good + Uncertain) sample (TreatUncertainAsBad = false applied consistently);</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><b>Interpolative, reverse, a sample exactly at the point:</b> the SDK's backward <c>UpdateSlice</c> files
/// a sample AT the point as the early bound (<c>&gt;= 0</c>), so it interpolates from it to the next Good sample —
/// same value, but the Interpolated bit, and Uncertain when a Bad sample sits between. Ours returns the raw
/// sample with its own status in both directions (as the SDK itself does forward). Reverse Interpolative
/// therefore compares timestamps and values (the anchoring question) but not status. (The SDK's asymmetry —
/// forward it returns the raw sample — is its defect; ours is the same in both directions.)</item>
/// </list>
/// </remarks>
public sealed class SdkAggregateParityTests
{
private static readonly DateTime Now = At(3600);
/// <summary>Dense: every 2.5 s from −10 to +100, values cycling, one Bad sample at +22.5 — no empty interval.</summary>
private static readonly StoredSample[] Dense = Enumerable.Range(0, 45)
.Select(i => (t: -10 + i * 2.5, i))
.Select(x => S(x.t, HistorianValue.FromDouble(10 + (x.i * 7 % 13)), x.t == 22.5 ? Bad : Good))
.ToArray();
/// <summary>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), 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)
{
try
{
SdkAggregateOracle.ShouldMatch(Ours(agg, from, to, intervalS, Dense), Sdk(agg, from, to, intervalS, Dense),
$"{agg} Dense {from}→{to}/{intervalS}s", compareCalculatedInterpolatedBits: false,
// pre-existing, #680: the SDK's Start/End return the raw first/last sample (any quality) with its own
// timestamp and stored status; ours return the first/last USABLE value stamped with the interval, under the
// per-sample status rule. The SDK's Delta is Good even when a Bad sample sits in the interval; ours is
// Uncertain. Values are compared wherever the SDK's answer is not Bad.
compareTimestamps: !StartOrEnd(agg),
compareCodeBits: !StartOrEnd(agg) && agg != HistoryAggregateType.Delta);
}
catch (Exception ex)
{
failures.Add(ex.Message);
}
}
failures.ShouldBeEmpty(string.Join("\n", failures));
}
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,
];
private static bool StartOrEnd(HistoryAggregateType agg) =>
agg is HistoryAggregateType.Start or HistoryAggregateType.End;
[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,
// pre-existing, #680: Start's raw-sample timestamp/status (see Per_sample_aggregates_match_the_SDK).
compareTimestamps: !StartOrEnd(agg),
compareCodeBits: !StartOrEnd(agg) && agg != HistoryAggregateType.Delta);
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>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();
// ActualTime: the extreme usable value, ties ⇒ earliest (the SQL's ROW_NUMBER order).
var extreme = numeric.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 = numeric.Count == 0 ? null : numeric.Average(s => s.Value.Number),
Min = numeric.Count == 0 ? null : numeric.Min(s => s.Value.Number),
Max = numeric.Count == 0 ? null : numeric.Max(s => s.Value.Number),
Sum = numeric.Count == 0 ? null : numeric.Sum(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,
};
}
}
}