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): /// /// an empty interval inside the data range: the SDK's Count answers 0/Good, ours BadNoData; /// Uncertain 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); /// 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 (the SDK does not), so the /// non-multiple windows mask that one bucket's Partial bit; /// the SDK's end-of-data Partial rule (forward, the interval holding the last sample when nothing follows /// and nothing was extrapolated) is not implemented: every dataset carries data past the window end. /// /// Pre-existing forward divergences this parity test surfaced (#671 behaviour, unchanged here — forward results were /// to stay as they are; recorded for a follow-up rather than fixed in place): /// /// TimeAverage / Total status: the SDK (interpolated bounds) reports UncertainDataSubNormal when /// any region of the interval is non-Good (e.g. a segment bridged across a Bad sample); ours applies the /// time-based rule (Uncertain time counts as Good) to these two as well, so a bridged-but-fully-covered interval /// is Good. Values and timestamps agree; the status code bits are not compared for these two. /// Total over an interval the data starts inside: the SDK integrates only the defined part /// (value·s over the covered seconds); ours is TimeAverage × interval seconds. The Partial bit and timestamp /// agree; that value is not compared. /// 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. /// /// 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), ]; /// 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), 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; } [Theory] [MemberData(nameof(Windows))] public void Time_weighted_aggregates_match_the_SDK(double from, double to, double intervalS) { foreach (var agg in new[] { HistoryAggregateType.TimeAverage, HistoryAggregateType.TimeAverage2, HistoryAggregateType.Total, HistoryAggregateType.Total2, HistoryAggregateType.Interpolative, }) foreach (var (name, data) in new[] { ("Bounded", Bounded), ("Dense", Dense) }) SdkAggregateOracle.ShouldMatch(MaskTruncation(Ours(agg, from, to, intervalS, data), from, to, intervalS), Sdk(agg, from, to, intervalS, data), $"{agg} {name} {from}→{to}/{intervalS}s", compareCalculatedInterpolatedBits: !(agg == HistoryAggregateType.Interpolative && from > to), compareCodeBits: agg is not (HistoryAggregateType.TimeAverage or HistoryAggregateType.Total) && !(agg == HistoryAggregateType.Interpolative && from > to)); } [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: !PercentAggregate(agg), // 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, HistoryAggregateType.PercentGood, HistoryAggregateType.PercentBad, ]; /// /// pre-existing, #680: ours is the per-SAMPLE percentage (100 × Good samples / all samples); the SDK's /// StatusAggregateCalculator is the per-DURATION one (Good time / interval time). Different definitions, so the /// values are not compared; timestamp, status and Partial still are. /// private static bool StartOrEnd(HistoryAggregateType agg) => agg is HistoryAggregateType.Start or HistoryAggregateType.End; private static bool PercentAggregate(HistoryAggregateType agg) => agg is HistoryAggregateType.PercentGood or HistoryAggregateType.PercentBad; [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: Total over the interval the data starts inside (TimeAverage × interval vs the SDK's // integral of the covered part); Start's raw-sample timestamp/status (see Per_sample_aggregates_match_the_SDK). compareValues: agg != HistoryAggregateType.Total, 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, compareValues: agg != HistoryAggregateType.Total); (ours[0].StatusCode & HistorianInfoBits.HistorianPartial).ShouldBe( agg == HistoryAggregateType.Average ? 0u : HistorianInfoBits.HistorianPartial); ours.Skip(1).ShouldAllBe(x => (x.StatusCode & HistorianInfoBits.HistorianPartial) == 0); } /// 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, }; } } }