fix(historian): #680 lane B — reverse end-of-data Partial (strictly inside), drop dead per-sample Percent cases
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.
This commit is contained in:
@@ -57,7 +57,7 @@ internal static class QuestDbAggregateQueries
|
||||
private static readonly string EmptyKind = ((byte)HistorianValueKind.Empty).ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
/// <summary>
|
||||
/// Average / Minimum / Maximum / Total / Count / Range / PercentGood / PercentBad.
|
||||
/// Average / Minimum / Maximum / Count / Range (PercentGood / PercentBad and Total are time-weighted, not here).
|
||||
/// Family columns: <c>6 avg_value, 7 min_value, 8 max_value, 9 sum_value</c> (DOUBLE, NULL when no usable sample).
|
||||
/// </summary>
|
||||
/// <param name="sample">Prefixed sample table name.</param>
|
||||
|
||||
@@ -7,14 +7,15 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Per interval: no samples ⇒ <c>BadNoData</c>. PercentGood / PercentBad ⇒ <c>100 × good|bad / all</c> over
|
||||
/// every sample, status Good. Otherwise no usable sample ⇒ <c>BadNoData</c>; a numeric aggregate with a usable
|
||||
/// String/DateTime sample ⇒ <c>BadAggregateInvalidInputs</c>; else the value, Good when every sample is Good,
|
||||
/// <c>UncertainDataSubNormal</c> when any is Bad or Uncertain. Every calculated status — including
|
||||
/// <c>BadNoData</c> — carries the Calculated info bits. Timestamps are the bucket's
|
||||
/// <see cref="BucketPlan.BucketTimestamp"/> (its start forward, its later edge reverse), except
|
||||
/// Minimum/MaximumActualTime (the sample's time; ties ⇒ earliest) and Start/End. Every aggregate but <c>Average</c>
|
||||
/// also carries the Partial bit on a forward read's bucket the data starts or ends inside (<see cref="EvaluateAll"/>).
|
||||
/// Per interval: no samples ⇒ <c>BadNoData</c> (Count: 0 inside the data range). Otherwise no usable sample ⇒
|
||||
/// <c>BadNoData</c>; a numeric aggregate with a usable String/DateTime sample ⇒ <c>BadAggregateInvalidInputs</c>;
|
||||
/// else the value, Good when every sample is Good, <c>UncertainDataSubNormal</c> when any is Bad or Uncertain.
|
||||
/// Every calculated status — including <c>BadNoData</c> — carries the Calculated info bits. Timestamps are the
|
||||
/// bucket's <see cref="BucketPlan.BucketTimestamp"/> (its start forward, its later edge reverse), except
|
||||
/// Minimum/MaximumActualTime (the sample's time; ties ⇒ earliest) and Start/End. Every aggregate but
|
||||
/// <c>Average</c> also carries the Partial bit on the bucket the data starts (forward) or ends inside
|
||||
/// (<see cref="EvaluateAll"/>). PercentGood / PercentBad are duration-based since #680 and evaluated by
|
||||
/// <see cref="TimeWeightedAggregator"/>, not here.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// #680 (Part 13 / the SDK's calculators): <b>Count</b> 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
|
||||
/// <returns>Whether the start-of-data Partial rule applies.</returns>
|
||||
public static bool SetsStartOfDataPartial(HistoryAggregateType aggregate) => aggregate != HistoryAggregateType.Average;
|
||||
|
||||
/// <summary>
|
||||
/// True for the per-sample aggregates the forward end-of-data Partial rule (#680) applies to: the same set as
|
||||
/// <see cref="SetsStartOfDataPartial"/> less PercentGood / PercentBad, which #680 moves to the time-weighted family
|
||||
/// (their per-duration definition) — not changed here.
|
||||
/// </summary>
|
||||
/// <param name="aggregate">Requested aggregate.</param>
|
||||
/// <returns>Whether the end-of-data Partial rule applies.</returns>
|
||||
public static bool SetsEndOfDataPartial(HistoryAggregateType aggregate) =>
|
||||
SetsStartOfDataPartial(aggregate)
|
||||
&& aggregate is not (HistoryAggregateType.PercentGood or HistoryAggregateType.PercentBad);
|
||||
|
||||
/// <summary>
|
||||
/// 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 <b>forward</b>
|
||||
/// read of an aggregate other than <c>Average</c> (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 <c>BadNoData</c>, #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 <c>Average</c> (either direction: the end-of-data rule applies to reverse reads too), and Count (an empty
|
||||
/// interval inside the range is 0, outside it <c>BadNoData</c>, #680). Only a per-sample <c>Average</c> read skips
|
||||
/// both queries.
|
||||
/// </summary>
|
||||
/// <param name="aggregate">Requested aggregate.</param>
|
||||
/// <param name="plan">The bucket plan.</param>
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="BucketPlan.BucketTimestamp"/> (the earlier edge forward, the
|
||||
/// later edge reverse) — Start/End/ActualTime with their sample's own time; empty buckets <c>BadNoData</c>, except a
|
||||
/// Count inside the tag's data range (0, Good — #680); and, on a forward read, for every aggregate but
|
||||
/// <c>Average</c>, the Partial bit on a non-Bad result of the bucket the tag's data starts strictly inside (#677, the
|
||||
/// SDK's <c>m_startOfData</c> rule) or ends inside (#680, <c>m_endOfData</c>, the bucket's earlier edge included).
|
||||
/// A Bad result never carries Partial, and a reverse read none at all (<see cref="NeedsDataRange"/>).
|
||||
/// Count inside the tag's data range (0, Good — #680); and, for every aggregate but <c>Average</c>, the Partial bit
|
||||
/// on a non-Bad result of the bucket the tag's data starts strictly inside (forward only — #677, the SDK's
|
||||
/// <c>m_startOfData</c> rule) or ends inside (#680 — <see cref="BucketPlan.DataEndsInside"/>: forward the SDK's
|
||||
/// <c>m_endOfData</c>, earlier edge included; reverse strictly inside, the SDK's <c>m_startOfData</c> keyed on the
|
||||
/// first value a reverse read is fed — the latest). A Bad result never carries Partial.
|
||||
/// </summary>
|
||||
/// <param name="aggregate">A per-sample (Simple / StartEnd / ActualTime) aggregate.</param>
|
||||
/// <param name="plan">The bucket plan.</param>
|
||||
@@ -147,7 +136,7 @@ public static class AggregateEvaluator
|
||||
ArgumentNullException.ThrowIfNull(rows);
|
||||
var evaluated = new Dictionary<long, DataValueSnapshot>();
|
||||
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);
|
||||
|
||||
@@ -156,15 +156,18 @@ public sealed class BucketPlan
|
||||
earliestSampleUtc is { } e && e.Ticks > LowerTicks(index) && e.Ticks < UpperTicks(index);
|
||||
|
||||
/// <summary>
|
||||
/// True when the latest stored sample of the tag lies in bucket <paramref name="index"/> (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 <c>m_endOfData >= startTime && m_endOfData < endTime</c>).
|
||||
/// True when the latest stored sample of the tag lies in bucket <paramref name="index"/>: 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 <c>m_endOfData >= startTime && m_endOfData < endTime</c>); reverse the sample
|
||||
/// must lie strictly inside (the SDK's reverse read keys <c>m_startOfData</c> — a strict test — on the first value it
|
||||
/// is fed, the latest).
|
||||
/// </summary>
|
||||
/// <param name="index">Zero-based ascending bucket index.</param>
|
||||
/// <param name="latestSampleUtc">The tag's latest stored sample (any quality), or null when it has none.</param>
|
||||
/// <returns>Whether the data ends inside the bucket.</returns>
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// True when bucket <paramref name="index"/> overlaps the tag's data range <c>[earliest, latest]</c> — neither
|
||||
|
||||
@@ -46,7 +46,7 @@ internal static class SqlAggregateQueries
|
||||
private const string NonNumericKinds = "4, 5";
|
||||
|
||||
/// <summary>
|
||||
/// Average / Minimum / Maximum / Total / Count / Range / PercentGood / PercentBad.
|
||||
/// Average / Minimum / Maximum / Count / Range (PercentGood / PercentBad and Total are time-weighted, not here).
|
||||
/// Family columns: <c>6 AvgValue, 7 MinValue, 8 MaxValue, 9 SumValue</c> (float, NULL when no usable
|
||||
/// numeric sample).
|
||||
/// </summary>
|
||||
|
||||
+9
-9
@@ -33,7 +33,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer;
|
||||
/// carries a value; an <c>Empty</c>-kind sample is an outage marker (comms-loss <c>BadNoCommunication</c>
|
||||
/// 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 <see cref="ReadAtTimeAsync"/>).
|
||||
/// <b>An Empty sample is always served Bad:</b> 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 <c>BadNoData</c>,
|
||||
@@ -405,14 +405,14 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource
|
||||
/// returned latest first (<see cref="BucketPlan"/>).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Per interval: no samples ⇒ <c>BadNoData</c>. PercentGood / PercentBad ⇒ <c>100 × good|bad / all</c> over
|
||||
/// every sample, status Good. Otherwise no usable sample ⇒ <c>BadNoData</c>; a numeric aggregate with a usable
|
||||
/// String/DateTime sample ⇒ <c>BadAggregateInvalidInputs</c>; else the value, Good when every sample is Good,
|
||||
/// <c>UncertainDataSubNormal</c> when any is Bad or Uncertain. Every status — including <c>BadNoData</c> —
|
||||
/// 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 ⇒ <c>BadNoData</c>. Otherwise no usable sample ⇒
|
||||
/// <c>BadNoData</c>; a numeric aggregate with a usable String/DateTime sample ⇒ <c>BadAggregateInvalidInputs</c>;
|
||||
/// else the value, Good when every sample is Good, <c>UncertainDataSubNormal</c> when any is Bad or Uncertain.
|
||||
/// Every calculated status — including <c>BadNoData</c> — 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 <see cref="AggregateEvaluator"/>).
|
||||
/// </para>
|
||||
|
||||
+14
-17
@@ -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<HistoryAggregateNotSupportedException>(() =>
|
||||
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);
|
||||
}
|
||||
|
||||
+10
-3
@@ -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();
|
||||
}
|
||||
|
||||
+9
-5
@@ -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<string>();
|
||||
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]);
|
||||
}
|
||||
|
||||
/// <summary>The per-bucket statistics the backends' SQL computes, built in memory with <see cref="BucketPlan.IndexOf"/>.</summary>
|
||||
|
||||
Reference in New Issue
Block a user