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 .