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:
+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