Analysis: read a rarely-read meter as coarse, not absent; newest rows first
ci / build-test (push) Successful in 2m41s
ci / build-test (push) Successful in 2m41s
Three things a reported Heizoel page got wrong at once. Its tank is dipped a few times a year and its burner read every few months, which is exactly the shape the coverage rules had not been walked through. "No data" for data that exists. A tank books nothing until the next dipstick closes the interval, so the stretch after the last dipstick is covered by no run at all, and a bucket no run covers was reported missing. The burner, whose run reaches into the window, said "only coarser data" -- the honest answer -- so one card claimed there was nothing while the coverage panel beside it listed years of data. A bucket that no run covers, no gap overlaps and no opening balance explains now reports the meter's resolution when its preceding coverage is within one interval of its own class: it is not silent, it is read rarely. A meter that does book its own buckets and stops -- a dead hourly source, a sheet asked about a later month -- still reads missing. Auto answering twelve months with one bar. Coarse only means "longer than a month", so a dipstick taken each autumn straddles a New Year as surely as a month start: coarsening the chart to years bought nothing and cost every point. The planning resolution now caps coarse at month when a run crosses a local year edge, and a series that cannot resolve the natural size no longer coarsens the whole chart -- it is drawn at that size with its buckets marked, which the chart and table already explain. A page contradicting itself. The comparison line above the ranking was fed the leading measure's matched coverage but worded as if it spoke for the page, directly above a burner row that did compare. It now names the figure it is about. Alongside: the "largest changes" ranking no longer drops a meter whose change is not comparable. It ranks what can be ranked, then lists the rest with their values and the reason -- the tank had been vanishing from its own energy type. And every dated table now reads newest first, as lists are read; charts stay chronological left to right, and the CSV export stays ascending for spreadsheets. A-41 to A-43 in the note record the three rules.
This commit is contained in:
@@ -336,6 +336,7 @@ public sealed class BucketPlannerTests
|
||||
// A monthly import in a daily chart would be nothing but unresolved buckets.
|
||||
var monthly = BucketPlanner.Plan(monthToDate, BucketSize.Auto, ResolutionClass.Month);
|
||||
var weekly = BucketPlanner.Plan(monthToDate, BucketSize.Auto, ResolutionClass.Week);
|
||||
|
||||
var coarse = BucketPlanner.Plan(Preset(PeriodPreset.Last12Months), BucketSize.Auto, ResolutionClass.Coarse);
|
||||
|
||||
var month = Assert.Single(monthly.Buckets);
|
||||
|
||||
@@ -335,6 +335,93 @@ public sealed class CoverageEvaluatorTests
|
||||
Assert.Equal(0d, coverage.CoveredFraction);
|
||||
}
|
||||
|
||||
// ---- Waiting for a measurement coarser than the bucket (A-41) ------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Buckets_after_a_dipstick_wait_for_the_next_one_instead_of_reading_as_no_data()
|
||||
{
|
||||
// A heating-oil tank dipped on 5 October 2024 and 20 September 2025: one interval, coarser than a month.
|
||||
// "Last 12 months" opens eleven days after the last dipstick, so nothing covers it — but the tank is
|
||||
// neither silent nor broken, it is read twice a year. Every bucket says "only coarser data", with that
|
||||
// resolution, and none of them claims a number.
|
||||
var dipsticks = Single(At(2024, 10, 5, 10), At(2025, 9, 20, 10), ResolutionClass.Coarse);
|
||||
|
||||
var october = Evaluate(Month(2025, 10), dipsticks);
|
||||
var august = Evaluate(Month(2026, 8), dipsticks);
|
||||
|
||||
Assert.Equal(BucketStatus.Unresolved, october.Status);
|
||||
Assert.Equal(ValueIssue.CoarseResolution, october.Issue);
|
||||
Assert.Equal(ResolutionClass.Coarse, october.Resolution);
|
||||
Assert.Equal(TimeSpan.Zero, october.Covered);
|
||||
Assert.Null(october.ToValue(0, Provenance.None).Value);
|
||||
Assert.Equal(BucketStatus.Unresolved, august.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_bucket_before_a_meters_first_reading_is_missing_not_waiting()
|
||||
{
|
||||
// Nothing precedes it: the tank had not been dipped once, so there is no measurement on its way.
|
||||
var dipsticks = Single(At(2024, 10, 5, 10), At(2025, 9, 20, 10), ResolutionClass.Coarse);
|
||||
|
||||
Assert.Equal(BucketStatus.Missing, Evaluate(Month(2024, 3), dipsticks).Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_meter_that_books_its_own_buckets_still_reads_as_no_data_where_it_has_none()
|
||||
{
|
||||
// The rule is only for data too coarse for the bucket: an hourly source that went silent in March, and a
|
||||
// monthly sheet that ends in March, both book their buckets as they go, so a bucket they do not cover
|
||||
// really has no data.
|
||||
var hourly = Run(At(2026, 1, 1), At(2026, 3, 15), ResolutionClass.Hour);
|
||||
var sheet = MonthLabels(2026, 1, 2026, 4);
|
||||
|
||||
Assert.Equal(BucketStatus.Missing, Evaluate(Month(2026, 4), hourly).Status);
|
||||
Assert.Equal(BucketStatus.Missing, Evaluate(Day(2026, 4, 2), hourly).Status);
|
||||
Assert.Equal(BucketStatus.Missing, Evaluate(Month(2026, 5), sheet).Status);
|
||||
Assert.Equal(BucketStatus.Missing, Evaluate(Year(2027), sheet).Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_monthly_sheet_waits_a_month_for_its_next_row_and_then_has_no_data()
|
||||
{
|
||||
// Monthly rows cannot fill a day bucket at all, so the days right after the sheet ends are unresolved —
|
||||
// the April row will book them. Two years on, the sheet is not late but over (A-04, LimitOf).
|
||||
var sheet = MonthLabels(2026, 1, 2026, 4);
|
||||
|
||||
Assert.Equal(BucketStatus.Unresolved, Evaluate(Day(2026, 4, 2), sheet).Status);
|
||||
Assert.Equal(ResolutionClass.Month, Evaluate(Day(2026, 4, 2), sheet).Resolution);
|
||||
Assert.Equal(BucketStatus.Missing, Evaluate(Day(2028, 4, 2), sheet).Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_known_hole_keeps_its_own_reason_rather_than_waiting()
|
||||
{
|
||||
// A gap run overlapping the bucket is a hole someone has to explain, not a measurement on its way.
|
||||
CoverageRun[] runs =
|
||||
[
|
||||
Single(At(2024, 10, 5, 10), At(2025, 9, 20, 10), ResolutionClass.Coarse),
|
||||
Gap(At(2025, 9, 20, 10), At(2026, 2, 1), CoverageGapReason.UnexplainedDecrease),
|
||||
];
|
||||
|
||||
var coverage = Evaluate(Month(2025, 11), runs);
|
||||
|
||||
Assert.Equal(BucketStatus.Missing, coverage.Status);
|
||||
Assert.Equal(ValueIssue.RegisterDiscontinuity, coverage.Issue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_series_reads_the_waiting_buckets_exactly_as_one_by_one()
|
||||
{
|
||||
var dipsticks = Single(At(2024, 10, 5, 10), At(2025, 9, 20, 10), ResolutionClass.Coarse);
|
||||
List<AnalysisBucket> months = [.. Enumerable.Range(0, 12).Select(i => Month(2025 + ((9 + i) / 12), (((9 + i) % 12) + 1)))];
|
||||
|
||||
var series = CoverageEvaluator.EvaluateSeries(months, [dipsticks], Berlin, null);
|
||||
|
||||
Assert.Equal(months.Select(m => Evaluate(m, dipsticks).Status), series.Select(c => c.Status));
|
||||
Assert.All(series, c => Assert.Equal(BucketStatus.Unresolved, c.Status));
|
||||
Assert.All(series, c => Assert.Equal(ResolutionClass.Coarse, c.Resolution));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Runs_that_only_touch_the_bucket_edges_do_not_cover_it()
|
||||
{
|
||||
|
||||
@@ -92,6 +92,70 @@ public sealed class ResolutionClassifierTests
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => ResolutionClassifier.CoarsestResolving(BucketSize.Auto));
|
||||
}
|
||||
|
||||
// ---- What a plotted run asks the bucket planner for (A-41) ---------------------------------------
|
||||
|
||||
private static CoverageRun Interval(DateTimeOffset from, DateTimeOffset to, ResolutionClass resolution, bool divided = false) =>
|
||||
new(from, to, resolution, divided, CoverageGapReason.None, from);
|
||||
|
||||
[Fact]
|
||||
public void Quarterly_intervals_inside_one_year_ask_for_years()
|
||||
{
|
||||
// Read on the quarter: every interval lies inside 2025, so year buckets hold each one whole.
|
||||
CoverageRun[] quarters =
|
||||
[
|
||||
Interval(InBerlin(2025, 1, 1), InBerlin(2025, 4, 1), ResolutionClass.Coarse),
|
||||
Interval(InBerlin(2025, 4, 1), InBerlin(2025, 7, 1), ResolutionClass.Coarse),
|
||||
Interval(InBerlin(2025, 10, 1), InBerlin(2026, 1, 1), ResolutionClass.Coarse),
|
||||
];
|
||||
|
||||
Assert.Equal(ResolutionClass.Coarse, ResolutionClassifier.PlanningResolution(quarters, Berlin));
|
||||
Assert.Equal(BucketSize.Year, BucketPlanner.MinimumSizeFor(ResolutionClassifier.PlanningResolution(quarters, Berlin)!.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void One_interval_across_a_new_year_keeps_the_whole_chart_at_months()
|
||||
{
|
||||
// A dipstick read every autumn straddles the New Year as surely as every month start: year buckets leave it
|
||||
// exactly as unresolved as month buckets do, so it must not coarsen the chart to years (A-41). A chart has
|
||||
// one bucket size, so one such interval settles it for all of them.
|
||||
var dipsticks = Interval(InBerlin(2025, 9, 20, 10), InBerlin(2026, 8, 1, 10), ResolutionClass.Coarse);
|
||||
var quarter = Interval(InBerlin(2026, 1, 5), InBerlin(2026, 5, 1), ResolutionClass.Coarse);
|
||||
|
||||
Assert.Equal(ResolutionClass.Month, ResolutionClassifier.PlanningResolution([dipsticks], Berlin));
|
||||
Assert.Equal(ResolutionClass.Coarse, ResolutionClassifier.PlanningResolution([quarter], Berlin));
|
||||
Assert.Equal(ResolutionClass.Month, ResolutionClassifier.PlanningResolution([quarter, dipsticks], Berlin));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_run_divided_at_the_month_edges_asks_for_months_whatever_its_length()
|
||||
{
|
||||
// A-03: a 46-day interval divided at 1 September is booked inside each month it touches.
|
||||
var divided = Interval(InBerlin(2026, 8, 20, 9), InBerlin(2026, 10, 5, 9), ResolutionClass.Coarse, divided: true);
|
||||
|
||||
Assert.Equal(ResolutionClass.Month, ResolutionClassifier.PlanningResolution([divided], Berlin));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gap_runs_and_an_empty_range_ask_for_nothing()
|
||||
{
|
||||
var hole = new CoverageRun(InBerlin(2025, 1, 1), InBerlin(2026, 6, 1), ResolutionClass.Hour, false, CoverageGapReason.SampleGap);
|
||||
|
||||
Assert.Null(ResolutionClassifier.PlanningResolution([], Berlin));
|
||||
Assert.Null(ResolutionClassifier.PlanningResolution([hole], Berlin));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(ResolutionClass.Hour)]
|
||||
[InlineData(ResolutionClass.Day)]
|
||||
[InlineData(ResolutionClass.Week)]
|
||||
[InlineData(ResolutionClass.Month)]
|
||||
public void A_run_a_bucket_size_can_place_asks_for_its_own_class(ResolutionClass resolution)
|
||||
{
|
||||
var run = Interval(InBerlin(2025, 12, 20), InBerlin(2026, 1, 3), resolution);
|
||||
|
||||
Assert.Equal(resolution, ResolutionClassifier.PlanningResolution([run], Berlin));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_coarsest_of_two_classes_is_the_resolution_of_a_combined_value()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user