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; /// /// #677: our processed reads against the pinned SDK's own Part 13 aggregate calculators (), /// forward AND reverse, bucket by bucket — timestamps, values and status. The per-sample aggregates are bucketed with /// (the C# statement of the backends' SQL bucket math; the integration suites run the /// same comparison through the real SQL) and evaluated by ; the /// time-weighted ones by . /// /// /// Deliberate, documented divergences the datasets avoid (they are not anchoring questions): /// /// Uncertain samples in a per-sample aggregate: the SDK's Average/Min/Max use Good samples only, /// ours every usable (Good + Uncertain) sample (TreatUncertainAsBad = false applied consistently). Count matches /// the SDK since #680 (Good samples only); /// the per-sample Calculated / MultipleValues 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; /// a truncated 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; /// a time-weighted BadNoData carries the Calculated bit in ours (every processed status does), not in the /// SDK's — masked by ; /// TimeAverage2 / Total2 over an interval wholly inside an outage: ours generic Bad (data, but no /// non-Bad time — Part 13's PercentData rule), the SDK BadNoData — masked by ; /// the end-of-data interval (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 /// GetRegionsInValueSet); 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 TimeWeightedAggregatorTests). That /// interval's value is not compared; its status, Partial bit and timestamp are. /// an outage marker as the last sample (): ours runs the outage to the window /// end — TimeAverage2/Total2 Bad, the percentages 100 % Bad, no end-of-data Partial on the interval holding /// it; the SDK treats the data as ended (BadNoData 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 /// ; /// Booleans (): 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; /// TimeAverage / Total, reverse, a sample exactly on an interval's later edge: the SDK's backward /// UpdateSlice 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 /// ; /// a Bad 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; /// Interpolative, reverse, a sample exactly at the point: the SDK's backward UpdateSlice files /// a sample AT the point as the early bound (>= 0), 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.) /// /// public sealed class SdkAggregateParityTests { private static readonly DateTime Now = At(3600); /// Dense: every 2.5 s from −10 to +100, values cycling, one Bad sample at +22.5 — no empty interval. 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(); /// Tag A with Good bounds on both sides, plus an Uncertain sample and an outage marker further on. 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), ]; /// /// #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). /// 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(); /// /// #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. /// 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), ]; /// /// #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. /// private static readonly StoredSample[] RecoveryEnds = [ S(-10, HistorianValue.FromDouble(10), Good), S(5, HistorianValue.Empty, BadNoCommunication), S(27.5, HistorianValue.FromDouble(20), Good), ]; /// /// #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. /// private static readonly StoredSample[] OutageLast = [ S(-10, HistorianValue.FromDouble(10), Good), S(25, HistorianValue.FromDouble(20), Good), S(31, HistorianValue.Empty, BadNoCommunication), ]; /// /// #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 ). /// 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), ]; /// Data that starts inside the window (no sample before +7) and runs past its end. 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 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 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 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))); } /// Masks the Partial bit of our one truncated interval (a documented divergence: ours flags it, the SDK does not). private static IReadOnlyList MaskTruncation(IReadOnlyList 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; } /// /// Applies the documented divergences listed on the class (other than truncation): strips the Calculated bit from our /// BadNoData; maps our generic-Bad TimeAverage2/Total2 outage interval to the SDK's BadNoData; takes the /// SDK's value for the end-of-data interval of TimeAverage2/Total2/PercentGood/PercentBad (its 1 ms artifact). /// private static IReadOnlyList MaskDocumented(HistoryAggregateType agg, IReadOnlyList ours, IReadOnlyList 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(); 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(); 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, compareValues: true); } 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, ]; [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); } /// /// #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. /// 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), ]; /// #680: Bad samples leading, trailing and mid-interval, plus an Uncertain one (Start / End / Delta). 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 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(); 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"); } /// #680: data ending inside the window (−5, +3, +25) and ending exactly on an interval edge (+20). 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(); 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]); } /// #680 review: the data ends on an OUTAGE MARKER (+25) — the interval holding it is all-Bad. 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(); 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"); } /// The per-bucket statistics the backends' SQL computes, built in memory with . internal static IEnumerable InMemoryRows(BucketPlan plan, IEnumerable 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().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, FirstAny = all[0], LastAny = all[^1], }; } } }