From b8344bb151d71ed318563b5926ac18f67c95b6d3 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 11 Oct 2026 16:00:13 -0400 Subject: [PATCH] =?UTF-8?q?fix(historian):=20#680=20lane=20B=20=E2=80=94?= =?UTF-8?q?=20reverse=20end-of-data=20Partial=20(strictly=20inside),=20dro?= =?UTF-8?q?p=20dead=20per-sample=20Percent=20cases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-sample aggregates (all but Average) now set Partial on a reverse read's interval that strictly contains the tag's latest sample — the SDK's reverse m_startOfData (verified vs the SDK harness), matching lane A's time-weighted rule. NeedsDataRange therefore applies in both directions for every per-sample aggregate but Average. PercentGood/Bad left the per-sample evaluator (lane A moved them to the time-weighted family); Evaluate refuses non-per-sample aggregates up front. Comments fixed. --- .../QuestDbAggregateQueries.cs | 2 +- .../AggregateEvaluator.cs | 59 ++++++++----------- .../BucketPlan.cs | 11 ++-- .../SqlAggregateQueries.cs | 2 +- .../SqlServerHistorianDataSource.cs | 18 +++--- .../AggregateEvaluatorTests.cs | 31 +++++----- .../BucketPlanTests.cs | 13 +++- .../SdkAggregateParityTests.cs | 14 +++-- 8 files changed, 75 insertions(+), 75 deletions(-) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb/QuestDbAggregateQueries.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb/QuestDbAggregateQueries.cs index fb30fef3..f4ca88b7 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb/QuestDbAggregateQueries.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb/QuestDbAggregateQueries.cs @@ -57,7 +57,7 @@ internal static class QuestDbAggregateQueries private static readonly string EmptyKind = ((byte)HistorianValueKind.Empty).ToString(CultureInfo.InvariantCulture); /// - /// Average / Minimum / Maximum / Total / Count / Range / PercentGood / PercentBad. + /// Average / Minimum / Maximum / Count / Range (PercentGood / PercentBad and Total are time-weighted, not here). /// Family columns: 6 avg_value, 7 min_value, 8 max_value, 9 sum_value (DOUBLE, NULL when no usable sample). /// /// Prefixed sample table name. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/AggregateEvaluator.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/AggregateEvaluator.cs index fb62c0fc..00a81408 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/AggregateEvaluator.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/AggregateEvaluator.cs @@ -7,14 +7,15 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common; /// /// /// -/// Per interval: no samples ⇒ BadNoData. PercentGood / PercentBad ⇒ 100 × good|bad / all over -/// every sample, status Good. Otherwise no usable sample ⇒ BadNoData; a numeric aggregate with a usable -/// String/DateTime sample ⇒ BadAggregateInvalidInputs; else the value, Good when every sample is Good, -/// UncertainDataSubNormal when any is Bad or Uncertain. Every calculated status — including -/// BadNoData — carries the Calculated info bits. Timestamps are the bucket's -/// (its start forward, its later edge reverse), except -/// Minimum/MaximumActualTime (the sample's time; ties ⇒ earliest) and Start/End. Every aggregate but Average -/// also carries the Partial bit on a forward read's bucket the data starts or ends inside (). +/// Per interval: no samples ⇒ BadNoData (Count: 0 inside the data range). Otherwise no usable sample ⇒ +/// BadNoData; a numeric aggregate with a usable String/DateTime sample ⇒ BadAggregateInvalidInputs; +/// else the value, Good when every sample is Good, UncertainDataSubNormal when any is Bad or Uncertain. +/// Every calculated status — including BadNoData — carries the Calculated info bits. Timestamps are the +/// bucket's (its start forward, its later edge reverse), except +/// Minimum/MaximumActualTime (the sample's time; ties ⇒ earliest) and Start/End. Every aggregate but +/// Average also carries the Partial bit on the bucket the data starts (forward) or ends inside +/// (). PercentGood / PercentBad are duration-based since #680 and evaluated by +/// , not here. /// /// /// #680 (Part 13 / the SDK's calculators): Count counts Good samples only, value-based status (all Good ⇒ @@ -45,13 +46,12 @@ public static class AggregateEvaluator DataValueSnapshot Calc(object? value, uint status, DateTime? t = null) => new(value, HistorianInfoBits.WithCalculated(status), t ?? bucketStart, now); + // Refuse a non-per-sample aggregate up front, whatever the bucket holds (an all-Bad bucket would otherwise answer + // BadNoData before the switch's default is reached). + if (AggregateFamilies.FamilyOf(aggregate) is not (AggregateFamily.Simple or AggregateFamily.StartEnd or AggregateFamily.ActualTime)) + throw new HistoryAggregateNotSupportedException(aggregate); if (r.Total == 0) return NoData(bucketStart, now); switch (aggregate) - { - case HistoryAggregateType.PercentGood: return Calc(100d * r.Good / r.Total, HistorianStatusCodes.Good); - case HistoryAggregateType.PercentBad: return Calc(100d * r.Bad / r.Total, HistorianStatusCodes.Good); - } - switch (aggregate) { // #680 (Part 13 Tables 73/74; SDK ComputeStartEnd): the raw first / last sample of ANY quality — its own value // (null when Bad), its own status (Raw: no Calculated bit) and its own timestamp. @@ -96,23 +96,12 @@ public static class AggregateEvaluator /// Whether the start-of-data Partial rule applies. public static bool SetsStartOfDataPartial(HistoryAggregateType aggregate) => aggregate != HistoryAggregateType.Average; - /// - /// True for the per-sample aggregates the forward end-of-data Partial rule (#680) applies to: the same set as - /// less PercentGood / PercentBad, which #680 moves to the time-weighted family - /// (their per-duration definition) — not changed here. - /// - /// Requested aggregate. - /// Whether the end-of-data Partial rule applies. - public static bool SetsEndOfDataPartial(HistoryAggregateType aggregate) => - SetsStartOfDataPartial(aggregate) - && aggregate is not (HistoryAggregateType.PercentGood or HistoryAggregateType.PercentBad); - /// /// True when a per-sample read must fetch the tag's data range — its earliest and latest stored samples of any - /// quality — because the answer can change a result: the start- and end-of-data Partial rules on a forward - /// read of an aggregate other than Average (as in the SDK, which applies both to forward reads only), and - /// Count in either direction (an empty interval inside the range is 0, outside it BadNoData, #680). - /// Otherwise the backend skips both queries. + /// quality — because the answer can change a result: the start/end-of-data Partial rules for an aggregate other + /// than Average (either direction: the end-of-data rule applies to reverse reads too), and Count (an empty + /// interval inside the range is 0, outside it BadNoData, #680). Only a per-sample Average read skips + /// both queries. /// /// Requested aggregate. /// The bucket plan. @@ -120,18 +109,18 @@ public static class AggregateEvaluator public static bool NeedsDataRange(HistoryAggregateType aggregate, BucketPlan plan) { ArgumentNullException.ThrowIfNull(plan); - return plan.BucketCount > 0 - && (aggregate == HistoryAggregateType.Count || (!plan.Reversed && SetsStartOfDataPartial(aggregate))); + return plan.BucketCount > 0 && (aggregate == HistoryAggregateType.Count || SetsStartOfDataPartial(aggregate)); } /// /// Evaluates every non-empty bucket row and assembles one result per interval in the plan's order (descending for a /// reverse read): each value stamped with (the earlier edge forward, the /// later edge reverse) — Start/End/ActualTime with their sample's own time; empty buckets BadNoData, except a - /// Count inside the tag's data range (0, Good — #680); and, on a forward read, for every aggregate but - /// Average, the Partial bit on a non-Bad result of the bucket the tag's data starts strictly inside (#677, the - /// SDK's m_startOfData rule) or ends inside (#680, m_endOfData, the bucket's earlier edge included). - /// A Bad result never carries Partial, and a reverse read none at all (). + /// Count inside the tag's data range (0, Good — #680); and, for every aggregate but Average, the Partial bit + /// on a non-Bad result of the bucket the tag's data starts strictly inside (forward only — #677, the SDK's + /// m_startOfData rule) or ends inside (#680 — : forward the SDK's + /// m_endOfData, earlier edge included; reverse strictly inside, the SDK's m_startOfData keyed on the + /// first value a reverse read is fed — the latest). A Bad result never carries Partial. /// /// A per-sample (Simple / StartEnd / ActualTime) aggregate. /// The bucket plan. @@ -147,7 +136,7 @@ public static class AggregateEvaluator ArgumentNullException.ThrowIfNull(rows); var evaluated = new Dictionary(); var startRule = !plan.Reversed && SetsStartOfDataPartial(aggregate); - var endRule = !plan.Reversed && SetsEndOfDataPartial(aggregate); + var endRule = SetsStartOfDataPartial(aggregate); foreach (var row in rows) { var v = Evaluate(aggregate, row, plan.BucketTimestamp(row.Bucket), now); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/BucketPlan.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/BucketPlan.cs index e2decf89..1d4dd938 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/BucketPlan.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/BucketPlan.cs @@ -156,15 +156,18 @@ public sealed class BucketPlan earliestSampleUtc is { } e && e.Ticks > LowerTicks(index) && e.Ticks < UpperTicks(index); /// - /// True when the latest stored sample of the tag lies in bucket (its earlier edge - /// included): the data ends part-way through the interval, so Part 13 §5.3.3.2 / the SDK set the Partial bit on a - /// forward read (#680, the SDK's m_endOfData >= startTime && m_endOfData < endTime). + /// True when the latest stored sample of the tag lies in bucket : the data ends part-way + /// through the interval, so Part 13 §5.3.3.2 / the SDK set the Partial bit (#680). Forward the earlier edge is + /// included (the SDK's m_endOfData >= startTime && m_endOfData < endTime); reverse the sample + /// must lie strictly inside (the SDK's reverse read keys m_startOfData — a strict test — on the first value it + /// is fed, the latest). /// /// Zero-based ascending bucket index. /// The tag's latest stored sample (any quality), or null when it has none. /// Whether the data ends inside the bucket. public bool DataEndsInside(long index, DateTime? latestSampleUtc) => - latestSampleUtc is { } l && l.Ticks >= LowerTicks(index) && l.Ticks < UpperTicks(index); + latestSampleUtc is { } l + && (Reversed ? l.Ticks > LowerTicks(index) : l.Ticks >= LowerTicks(index)) && l.Ticks < UpperTicks(index); /// /// True when bucket overlaps the tag's data range [earliest, latest] — neither diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlAggregateQueries.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlAggregateQueries.cs index a4cf3aaf..8dcb44dd 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlAggregateQueries.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlAggregateQueries.cs @@ -46,7 +46,7 @@ internal static class SqlAggregateQueries private const string NonNumericKinds = "4, 5"; /// - /// Average / Minimum / Maximum / Total / Count / Range / PercentGood / PercentBad. + /// Average / Minimum / Maximum / Count / Range (PercentGood / PercentBad and Total are time-weighted, not here). /// Family columns: 6 AvgValue, 7 MinValue, 8 MaxValue, 9 SumValue (float, NULL when no usable /// numeric sample). /// diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianDataSource.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianDataSource.cs index ebb362f6..e090a8b4 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianDataSource.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianDataSource.cs @@ -33,7 +33,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer; /// carries a value; an Empty-kind sample is an outage marker (comms-loss BadNoCommunication /// or a driver's Bad publish) and is Bad whatever its code. Raw returns every sample as stored, outage /// markers included. Aggregates exclude Bad samples from the math, report the interval Uncertain when -/// any non-Good sample is present, and count them in PercentBad. AtTime never interpolates across a Bad +/// any non-Good sample is present, and count their duration as Bad time in PercentBad. AtTime never interpolates across a Bad /// neighbour and reports a time inside an outage with the outage's Bad code (see ). /// An Empty sample is always served Bad: a row with no value whose stored status is not Bad (only /// possible for rows written before the writer forced Empty samples Bad) is read back as BadNoData, @@ -405,14 +405,14 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource /// returned latest first (). /// /// - /// Per interval: no samples ⇒ BadNoData. PercentGood / PercentBad ⇒ 100 × good|bad / all over - /// every sample, status Good. Otherwise no usable sample ⇒ BadNoData; a numeric aggregate with a usable - /// String/DateTime sample ⇒ BadAggregateInvalidInputs; else the value, Good when every sample is Good, - /// UncertainDataSubNormal when any is Bad or Uncertain. Every status — including BadNoData — - /// carries the Calculated info bits. Timestamps are the bucket start (its later edge on a reverse read), except - /// Minimum/MaximumActualTime (the sample's time; ties ⇒ earliest). On a forward read every aggregate but Average - /// sets the Partial bit on the bucket the tag's earliest sample lies strictly inside (#677) or its latest sample - /// lies in (#680). #680: Count counts Good samples (value-based status; 0/Good on an empty bucket inside the tag's + /// Per-sample aggregates, per interval: no samples ⇒ BadNoData. Otherwise no usable sample ⇒ + /// BadNoData; a numeric aggregate with a usable String/DateTime sample ⇒ BadAggregateInvalidInputs; + /// else the value, Good when every sample is Good, UncertainDataSubNormal when any is Bad or Uncertain. + /// Every calculated status — including BadNoData — carries the Calculated info bits. Timestamps are the + /// bucket start (its later edge on a reverse read), except Minimum/MaximumActualTime (the sample's time; ties ⇒ + /// earliest) and Start/End. Every aggregate but Average sets the Partial bit on the bucket the tag's earliest + /// sample lies strictly inside (forward, #677) or its latest sample lies in (#680; reverse: strictly inside). + /// PercentGood / PercentBad are duration-based since #680 (time-weighted family, below). #680: Count counts Good samples (value-based status; 0/Good on an empty bucket inside the tag's /// data range), Start/End are the raw first/last sample of any quality, Delta is Uncertain only when the bucket's /// first or last sample is not usable (see ). /// diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/AggregateEvaluatorTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/AggregateEvaluatorTests.cs index 138e70bb..ff5e8458 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/AggregateEvaluatorTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/AggregateEvaluatorTests.cs @@ -158,12 +158,12 @@ public sealed class AggregateEvaluatorTests [GoodCalc | Partial, GoodCalc, GoodCalc, GoodCalc, GoodCalc | Partial, NoDataCalc]); s.Select(x => x.SourceTimestampUtc).ShouldBe([At(0), At(10), At(20), At(30), At(40), At(50)]); - // Reverse (60 → 0): (40,50] is inside the range (its earlier edge +40 < +45); (50,60] is after it. A reverse read - // carries no Partial bits. + // Reverse (60 → 0): (40,50] is inside the range (its earlier edge +40 < +45); (50,60] is after it. The last sample + // (+45) lies strictly inside (40,50] ⇒ Partial there; the first sample's interval is not flagged in reverse. var rev = BucketPlan.Create(At(60), At(0), TimeSpan.FromSeconds(10)); var r = AggregateEvaluator.EvaluateAll(HistoryAggregateType.Count, rev, rows, At(1), At(45), Now); r.Select(x => x.Value).ShouldBe([null, (object?)1, 0, 1, 0, 1]); - r.Select(x => x.StatusCode).ShouldBe([NoDataCalc, GoodCalc, GoodCalc, GoodCalc, GoodCalc, GoodCalc]); + r.Select(x => x.StatusCode).ShouldBe([NoDataCalc, GoodCalc | Partial, GoodCalc, GoodCalc, GoodCalc, GoodCalc]); // Other per-sample aggregates keep BadNoData on an empty interval. AggregateEvaluator.EvaluateAll(HistoryAggregateType.Minimum, plan, rows, At(1), At(45), Now)[1].StatusCode @@ -174,7 +174,7 @@ public sealed class AggregateEvaluatorTests } [Fact] - public void End_of_data_Partial_is_forward_only_and_inclusive_of_the_interval_start() + public void End_of_data_Partial_includes_the_interval_start_forward_and_is_strict_in_reverse() { var row = new BucketRow { Bucket = 2, Total = 1, Usable = 1, Good = 1, Min = 3, Avg = 3 }; var fwd = BucketPlan.Create(At(0), At(40), TimeSpan.FromSeconds(10)); @@ -191,20 +191,15 @@ public sealed class AggregateEvaluatorTests var bad = new BucketRow { Bucket = 2, Total = 1, Bad = 1 }; AggregateEvaluator.EvaluateAll(HistoryAggregateType.Minimum, fwd, [bad], At(-5), At(25), Now)[2].StatusCode .ShouldBe(NoDataCalc); - // Reverse: not applied. + // Reverse (40 → 0; bucket 2 = (20,30], result index 1): the latest sample strictly inside ⇒ Partial (the SDK's + // reverse m_startOfData); on the interval's later edge (+30) or its excluded earlier edge (+20) ⇒ nothing. var rev = BucketPlan.Create(At(40), At(0), TimeSpan.FromSeconds(10)); AggregateEvaluator.EvaluateAll(HistoryAggregateType.Minimum, rev, [row], At(-5), At(25), Now) + .Select(x => x.StatusCode & Partial).ShouldBe([0u, Partial, 0u, 0u]); + AggregateEvaluator.EvaluateAll(HistoryAggregateType.Minimum, rev, [row], At(-5), At(30), Now) + .ShouldAllBe(x => (x.StatusCode & Partial) == 0); + AggregateEvaluator.EvaluateAll(HistoryAggregateType.Average, rev, [row], At(-5), At(25), Now) .ShouldAllBe(x => (x.StatusCode & Partial) == 0); - } - - [Theory] - [InlineData(HistoryAggregateType.PercentGood, 100d, 50d, 100d)] - [InlineData(HistoryAggregateType.PercentBad, 0d, 50d, 0d)] - public void Percent_aggregates_count_every_sample_and_stay_good(HistoryAggregateType agg, double b0, double b1, double b2) - { - var s = ReadA(agg); - s.Select(x => x.Value).ShouldBe([b0, b1, b2, null]); - s.Select(x => x.StatusCode).ShouldBe([GoodCalc, GoodCalc, GoodCalc, NoDataCalc]); } [Fact] @@ -232,13 +227,15 @@ public sealed class AggregateEvaluatorTests } [Fact] - public void A_bucket_with_only_bad_samples_is_no_data_except_for_the_percentages() + public void A_bucket_with_only_bad_samples_is_no_data_except_for_count() { var row = new BucketRow { Total = 2, Usable = 0, Good = 0, Bad = 2 }; var avg = AggregateEvaluator.Evaluate(HistoryAggregateType.Average, row, At(0), Now); avg.Value.ShouldBeNull(); avg.StatusCode.ShouldBe(NoDataCalc); - AggregateEvaluator.Evaluate(HistoryAggregateType.PercentBad, row, At(0), Now).Value.ShouldBe(100d); + // PercentGood / PercentBad are duration-based (time-weighted family) since #680: not served here. + Should.Throw(() => + AggregateEvaluator.Evaluate(HistoryAggregateType.PercentBad, row, At(0), Now)); // #680: Count of an all-Bad interval is the generic Bad (the SDK's value-based status), not BadNoData. AggregateEvaluator.Evaluate(HistoryAggregateType.Count, row, At(0), Now).StatusCode.ShouldBe(BadCalc); } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/BucketPlanTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/BucketPlanTests.cs index d32efdea..d009e9a3 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/BucketPlanTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/BucketPlanTests.cs @@ -141,6 +141,12 @@ public sealed class BucketPlanTests p.DataEndsInside(0, At(20)).ShouldBeFalse(); p.DataEndsInside(1, At(40)).ShouldBeFalse(); p.DataEndsInside(0, null).ShouldBeFalse(); + // Reverse (40 → 0): (0,20], (20,40] — strictly inside only (the SDK's reverse m_startOfData test). + var r = BucketPlan.Create(At(40), At(0), TimeSpan.FromSeconds(20)); + r.DataEndsInside(1, At(30)).ShouldBeTrue(); + r.DataEndsInside(1, At(40)).ShouldBeFalse(); + r.DataEndsInside(1, At(20)).ShouldBeFalse(); + r.DataEndsInside(0, At(20)).ShouldBeFalse(); } [Fact] @@ -158,10 +164,10 @@ public sealed class BucketPlanTests } [Fact] - public void The_data_range_is_needed_for_forward_reads_of_aggregates_other_than_Average_and_for_Count() + public void The_data_range_is_needed_for_every_per_sample_aggregate_but_Average() { // Review F2 (#677) / #680: backends skip the earliest/latest-sample queries when their answer cannot change a - // result — the start/end-of-data Partial rules are forward-only and not Average's; Count's in-range empty interval + // result — the start/end-of-data Partial rules are not Average's (end-of-data applies in reverse too); Count's in-range empty interval // (0, not BadNoData) applies in both directions. var fwd = BucketPlan.Create(At(0), At(40), TimeSpan.FromSeconds(10)); var rev = BucketPlan.Create(At(40), At(0), TimeSpan.FromSeconds(10)); @@ -169,7 +175,8 @@ public sealed class BucketPlanTests AggregateEvaluator.NeedsDataRange(HistoryAggregateType.Minimum, fwd).ShouldBeTrue(); AggregateEvaluator.NeedsDataRange(HistoryAggregateType.Average, fwd).ShouldBeFalse(); AggregateEvaluator.NeedsDataRange(HistoryAggregateType.Count, rev).ShouldBeTrue(); - AggregateEvaluator.NeedsDataRange(HistoryAggregateType.Minimum, rev).ShouldBeFalse(); + AggregateEvaluator.NeedsDataRange(HistoryAggregateType.Minimum, rev).ShouldBeTrue(); // reverse end-of-data + AggregateEvaluator.NeedsDataRange(HistoryAggregateType.Average, rev).ShouldBeFalse(); AggregateEvaluator.NeedsDataRange(HistoryAggregateType.Count, BucketPlan.Create(At(0), At(0), TimeSpan.Zero)) .ShouldBeFalse(); } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/SdkAggregateParityTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/SdkAggregateParityTests.cs index 9c6c00fa..a2b6a6d6 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/SdkAggregateParityTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/SdkAggregateParityTests.cs @@ -461,15 +461,16 @@ public sealed class SdkAggregateParityTests [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; SDK m_endOfData): forward, the interval holding the last sample (its earlier edge - // included) is Partial for every per-sample aggregate but Average. Reverse 40 → 0 over EndOnEdge: the SDK's - // reverse rule (strictly inside, see the remarks) does not fire on an edge, and ours sets nothing. + // #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(); - var datasets = from > to ? new[] { ("EndOnEdge", EndOnEdge) } : new[] { ("EarlyEnd", EarlyEnd), ("EndOnEdge", EndOnEdge) }; foreach (var agg in PerSample) - foreach (var (name, data) in datasets) + foreach (var (name, data) in new[] { ("EarlyEnd", EarlyEnd), ("EndOnEdge", EndOnEdge) }) { try { @@ -485,6 +486,9 @@ public sealed class SdkAggregateParityTests // 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]); } /// The per-bucket statistics the backends' SQL computes, built in memory with .