Files
MeterVault/tests/Core.Tests/Analysis/CoverageEvaluatorTests.cs
T
Florian Schmidt a08e9f781f
ci / build-test (push) Successful in 2m41s
Analysis: read a rarely-read meter as coarse, not absent; newest rows first
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.
2026-09-20 12:44:32 +02:00

897 lines
41 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Coverage;
using MeterVault.Core.Domain;
using MeterVault.Core.Normalization;
using static MeterVault.Core.Tests.Analysis.CoverageTestData;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>
/// A bucket's status comes from coverage runs, not from sums (D-14). What these pin down: monthly data stays
/// monthly — it resolves months and years and nothing finer; an interval divided at month edges resolves
/// months but not weeks; a long undivided interval leaves every bucket whose edge it crosses unresolved,
/// within the 5 % edge tolerance; outages make exactly the buckets they touch partial or missing; an
/// opening balance, which the caller reports from the rollup flag, keeps its bucket partial (A-01); and a
/// bucket that ends at now is judged on what has happened by then (A-04).
/// </summary>
public sealed class CoverageEvaluatorTests
{
private static readonly IReadOnlyList<CoverageGapReason> NoGaps = [];
private static BucketCoverage Evaluate(AnalysisBucket bucket, params CoverageRun[] runs) =>
CoverageEvaluator.Evaluate(bucket, runs, Berlin, openingBalanceInBucket: false);
// ---- Seeded water meter: imported monthly table rows ---------------------------------------------
[Fact]
public void Monthly_label_data_resolves_its_months_and_years()
{
var water = MonthLabels(2022, 1, 2023, 1);
var december = Evaluate(Month(2022, 12), water);
var year = Evaluate(Year(2022), water);
Assert.Equal(BucketStatus.Available, december.Status);
Assert.Equal(ValueIssue.None, december.Issue);
Assert.Equal(ResolutionClass.Month, december.Resolution);
Assert.Equal(BucketStatus.Available, year.Status);
}
[Fact]
public void Monthly_label_data_is_unresolved_for_days_and_weeks_although_it_covers_them()
{
var water = MonthLabels(2022, 1, 2023, 1);
foreach (var bucket in new[] { Day(2022, 12, 15), Week(2022, 12, 14), Week(2022, 6, 1), Day(2022, 12, 31), Day(2022, 1, 1) })
{
var coverage = Evaluate(bucket, water);
Assert.Equal(BucketStatus.Unresolved, coverage.Status);
Assert.Equal(ValueIssue.CoarseResolution, coverage.Issue);
Assert.Equal(1d, coverage.CoveredFraction, 9);
}
}
// ---- A counter read on 1 August and 16 September -------------------------------------------------
public static TheoryData<string> DividedIntervalShapes => ["one coarse divided run", "month shares (D-10 segment bounds)"];
private static CoverageRun[] CounterReadAugustToSeptember(string shape)
{
// As one run the 46-day interval is coarse. As D-10's month shares (1 Aug 09:00 - 1 Sep and
// 1 Sep - 16 Sep 18:00) both shares are month-class and merge into one month-class run. Either way
// the normalizer divided it at the month edge; daily readings surround it.
var interval = shape == "one coarse divided run"
? Run(At(2026, 8, 1, 9), At(2026, 9, 16, 18), ResolutionClass.Coarse, divided: true)
: Run(At(2026, 8, 1, 9), At(2026, 9, 16, 18), ResolutionClass.Month, divided: true);
return
[
Run(At(2026, 7, 1), At(2026, 8, 1, 9), ResolutionClass.Day),
interval,
Run(At(2026, 9, 16, 18), At(2026, 10, 1), ResolutionClass.Day),
];
}
[Theory]
[MemberData(nameof(DividedIntervalShapes))]
public void A_46_day_interval_divided_at_the_month_edge_resolves_both_months(string shape)
{
var runs = CounterReadAugustToSeptember(shape);
Assert.Equal(BucketStatus.Available, Evaluate(Month(2026, 8), runs).Status);
Assert.Equal(BucketStatus.Available, Evaluate(Month(2026, 9), runs).Status);
}
[Theory]
[MemberData(nameof(DividedIntervalShapes))]
public void A_46_day_interval_divided_at_the_month_edge_still_cannot_say_which_week_it_was_used_in(string shape)
{
var runs = CounterReadAugustToSeptember(shape);
// Mid-August, the week across the month edge (Mon 31 Aug), and the week the closing reading lies in.
foreach (var bucket in new[] { Week(2026, 8, 12), Week(2026, 8, 31), Week(2026, 9, 16), Day(2026, 8, 20) })
{
Assert.Equal(BucketStatus.Unresolved, Evaluate(bucket, runs).Status);
}
// The weeks around it are resolved by the daily readings on either side.
Assert.Equal(BucketStatus.Available, Evaluate(Week(2026, 7, 15), runs).Status);
Assert.Equal(BucketStatus.Available, Evaluate(Week(2026, 9, 23), runs).Status);
}
// ---- Seeded burner runtime: one 12-year undivided interval ---------------------------------------
[Fact]
public void A_twelve_year_undivided_runtime_interval_leaves_its_months_and_years_unresolved()
{
var brenner = Run(At(2013, 10, 1), At(2025, 10, 1), ResolutionClass.Coarse);
var afterwards = Run(At(2025, 10, 1), At(2026, 6, 1), ResolutionClass.Month, divided: true);
Assert.Equal(BucketStatus.Unresolved, Evaluate(Year(2022), brenner, afterwards).Status);
Assert.Equal(BucketStatus.Unresolved, Evaluate(Month(2022, 3), brenner, afterwards).Status);
// The year it closes in would receive twelve years of runtime.
Assert.Equal(BucketStatus.Unresolved, Evaluate(Year(2025), brenner, afterwards).Status);
Assert.Equal(BucketStatus.Available, Evaluate(Month(2026, 2), brenner, afterwards).Status);
}
// ---- Seeded oil tank: dipstick levels weeks apart, never divided ---------------------------------
private static readonly CoverageRun[] Tank =
[
Run(At(2025, 9, 20), At(2025, 10, 14), ResolutionClass.Month),
Run(At(2025, 10, 14), At(2025, 11, 28), ResolutionClass.Coarse),
Run(At(2025, 11, 28), At(2025, 12, 20), ResolutionClass.Month),
];
[Fact]
public void A_45_day_undivided_tank_interval_leaves_both_months_it_spans_unresolved()
{
var october = Evaluate(Month(2025, 10), Tank);
var november = Evaluate(Month(2025, 11), Tank);
Assert.Equal(BucketStatus.Unresolved, october.Status);
Assert.Equal(ValueIssue.CoarseResolution, october.Issue);
Assert.Equal(ResolutionClass.Coarse, october.Resolution);
Assert.Equal(BucketStatus.Unresolved, november.Status);
}
[Fact]
public void A_45_day_tank_interval_inside_one_year_does_not_make_the_year_unresolved()
{
// D-14: only an interval crossing a bucket edge misbooks. This one starts and ends in 2025, so the
// year total is right — merely partial, because the tank was only read from 20 September.
var year = Evaluate(Year(2025), Tank);
Assert.Equal(BucketStatus.Partial, year.Status);
Assert.Equal(ValueIssue.PartialCoverage, year.Issue);
Assert.Equal(At(2025, 9, 20), year.FirstCovered);
Assert.Equal(At(2025, 12, 20), year.LastCovered);
}
[Fact]
public void A_month_class_tank_interval_crossing_a_month_edge_leaves_that_month_unresolved()
{
// D-14: the 20 Sep - 14 Oct dipstick interval is booked on 14 October, although 11 of its days lie in
// September. Being no longer than a month does not make an undivided interval land in the right month.
var september = Evaluate(Month(2025, 9), Tank);
Assert.Equal(BucketStatus.Unresolved, september.Status);
Assert.Equal(ValueIssue.CoarseResolution, september.Issue);
Assert.Equal(At(2025, 9, 20), september.FirstCovered);
}
[Fact]
public void Undivided_month_class_intervals_read_mid_month_leave_their_months_unresolved()
{
// Review F1: dipsticks (or burner hours) read on 15 Jan, 14 Feb and 16 Mar. February would receive the
// 15 Jan - 14 Feb draw, 17 of whose 30 days lie in January, so neither month can be told apart.
CoverageRun[] runs =
[
Single(At(2026, 1, 15, 10), At(2026, 2, 14, 10), ResolutionClass.Month),
Single(At(2026, 2, 14, 10), At(2026, 3, 16, 10), ResolutionClass.Month),
];
var february = CoverageEvaluator.Evaluate(Month(2026, 2), runs, Berlin, openingBalanceInBucket: false);
Assert.Equal(BucketStatus.Unresolved, february.Status);
Assert.Equal(ValueIssue.CoarseResolution, february.Issue);
Assert.Equal(BucketStatus.Unresolved, Evaluate(Month(2026, 1), runs).Status);
// The year holds both intervals whole: it is resolved, merely partial (read from 15 January only).
Assert.Equal(BucketStatus.Partial, Evaluate(Year(2026), runs).Status);
}
[Fact]
public void Undivided_month_class_intervals_that_cross_a_month_edge_by_less_than_5_percent_stay_resolved()
{
// The seeded oil tank's dipsticks sit on month-end days: 30 Nov 01:00 - 31 Dec 01:00 crosses into
// December by 23 hours of 31 days (3 %), which D-14 tolerates.
CoverageRun[] runs =
[
Single(At(2025, 11, 30, 1), At(2025, 12, 31, 1), ResolutionClass.Month),
Single(At(2025, 12, 31, 1), At(2026, 1, 31, 1), ResolutionClass.Month),
Single(At(2026, 1, 31, 1), At(2026, 2, 28, 1), ResolutionClass.Month),
];
Assert.Equal(BucketStatus.Available, Evaluate(Month(2025, 12), runs).Status);
Assert.Equal(BucketStatus.Available, Evaluate(Month(2026, 1), runs).Status);
}
[Fact]
public void Day_class_intervals_across_a_month_edge_still_resolve_months_on_the_class_rule()
{
// Daily readings at 06:00 cross each month edge by six hours, far inside the tolerance.
var run = Single(At(2026, 1, 31, 6), At(2026, 2, 1, 6), ResolutionClass.Day);
Assert.True(CoverageEvaluator.Resolves(run, Month(2026, 1), Berlin));
Assert.True(CoverageEvaluator.Resolves(run, Month(2026, 2), Berlin));
}
[Fact]
public void An_undivided_interval_crossing_new_year_by_less_than_5_percent_of_a_year_keeps_both_years_resolved()
{
CoverageRun[] runs =
[
Run(At(2024, 1, 1), At(2024, 12, 20), ResolutionClass.Month),
Run(At(2024, 12, 20), At(2025, 2, 5), ResolutionClass.Coarse),
Run(At(2025, 2, 5), At(2026, 1, 1), ResolutionClass.Month),
];
Assert.Equal(BucketStatus.Available, Evaluate(Year(2024), runs).Status);
Assert.Equal(BucketStatus.Available, Evaluate(Year(2025), runs).Status);
Assert.Equal(BucketStatus.Unresolved, Evaluate(Month(2024, 12), runs).Status);
Assert.Equal(BucketStatus.Unresolved, Evaluate(Month(2025, 1), runs).Status);
}
[Fact]
public void An_undivided_interval_crossing_new_year_by_more_than_5_percent_of_a_year_leaves_both_years_unresolved()
{
// 22 days on each side of a 365-day bucket is 6 %.
CoverageRun[] runs =
[
Run(At(2024, 1, 1), At(2024, 12, 10), ResolutionClass.Month),
Run(At(2024, 12, 10), At(2025, 1, 23), ResolutionClass.Coarse),
Run(At(2025, 1, 23), At(2026, 1, 1), ResolutionClass.Month),
];
Assert.Equal(BucketStatus.Unresolved, Evaluate(Year(2024), runs).Status);
Assert.Equal(BucketStatus.Unresolved, Evaluate(Year(2025), runs).Status);
}
[Fact]
public void Unresolved_wins_over_partial_because_a_value_too_coarse_to_divide_is_not_a_partial_total()
{
var coverage = Evaluate(Month(2026, 3), Run(At(2026, 3, 10), At(2026, 4, 20), ResolutionClass.Coarse));
Assert.Equal(BucketStatus.Unresolved, coverage.Status);
Assert.True(coverage.CoveredFraction < 1);
}
// ---- Hourly samples with a three-day outage ------------------------------------------------------
private static readonly CoverageRun[] HourlyWithOutage =
[
Run(At(2026, 1, 1), At(2026, 3, 10, 14), ResolutionClass.Hour),
Gap(At(2026, 3, 10, 14), At(2026, 3, 13, 9), CoverageGapReason.SampleGap),
Run(At(2026, 3, 13, 9), At(2026, 5, 1), ResolutionClass.Hour),
];
[Fact]
public void An_outage_leaves_exactly_the_days_it_touches_partial_or_missing()
{
var notAvailable = new List<(DateOnly Day, BucketStatus Status)>();
for (var day = new DateOnly(2026, 3, 1); day < new DateOnly(2026, 5, 1); day = day.AddDays(1))
{
var coverage = Evaluate(Day(day.Year, day.Month, day.Day), HourlyWithOutage);
if (coverage.Status != BucketStatus.Available)
{
notAvailable.Add((day, coverage.Status));
Assert.Equal(ValueIssue.SampleGap, coverage.Issue);
}
}
Assert.Equal(
[
(new DateOnly(2026, 3, 10), BucketStatus.Partial),
(new DateOnly(2026, 3, 11), BucketStatus.Missing),
(new DateOnly(2026, 3, 12), BucketStatus.Missing),
(new DateOnly(2026, 3, 13), BucketStatus.Partial),
],
notAvailable);
}
[Fact]
public void An_outage_makes_its_week_and_month_partial_and_leaves_their_neighbours_available()
{
var outageWeek = Evaluate(Week(2026, 3, 11), HourlyWithOutage);
Assert.Equal(BucketStatus.Partial, outageWeek.Status);
Assert.Equal(ValueIssue.SampleGap, outageWeek.Issue);
Assert.Equal([CoverageGapReason.SampleGap], outageWeek.Gaps);
Assert.Equal(TimeSpan.FromDays(7) - (At(2026, 3, 13, 9) - At(2026, 3, 10, 14)), outageWeek.Covered);
Assert.Equal(BucketStatus.Available, Evaluate(Week(2026, 3, 4), HourlyWithOutage).Status);
Assert.Equal(BucketStatus.Available, Evaluate(Week(2026, 3, 18), HourlyWithOutage).Status);
Assert.Equal(BucketStatus.Partial, Evaluate(Month(2026, 3), HourlyWithOutage).Status);
Assert.Equal(BucketStatus.Available, Evaluate(Month(2026, 2), HourlyWithOutage).Status);
Assert.Equal(BucketStatus.Available, Evaluate(Month(2026, 4), HourlyWithOutage).Status);
}
[Fact]
public void The_first_outage_day_reports_what_is_covered_and_until_when()
{
var coverage = Evaluate(Day(2026, 3, 10), HourlyWithOutage);
Assert.Equal(TimeSpan.FromHours(14), coverage.Covered);
Assert.Equal(14d / 24, coverage.CoveredFraction, 9);
Assert.Equal(At(2026, 3, 10), coverage.FirstCovered);
Assert.Equal(At(2026, 3, 10, 14), coverage.LastCovered);
Assert.Equal(ResolutionClass.Hour, coverage.Resolution);
}
[Fact]
public void Hourly_data_resolves_the_23_and_25_hour_DST_days()
{
var hourly = Run(At(2026, 3, 1), At(2026, 11, 1), ResolutionClass.Hour);
Assert.Equal(BucketStatus.Available, Evaluate(Day(2026, 3, 29), hourly).Status);
Assert.Equal(BucketStatus.Available, Evaluate(Day(2026, 10, 25), hourly).Status);
}
// ---- Edges and tolerances --------------------------------------------------------------------------
[Fact]
public void A_bucket_nothing_covers_is_missing_not_zero()
{
var coverage = Evaluate(Month(2026, 3));
Assert.Equal(BucketStatus.Missing, coverage.Status);
Assert.Equal(ValueIssue.NoCoverage, coverage.Issue);
Assert.Null(coverage.FirstCovered);
Assert.Null(coverage.Resolution);
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()
{
var march = Month(2026, 3);
var coverage = Evaluate(
march,
Run(At(2026, 2, 1), march.From, ResolutionClass.Hour),
Run(march.To, At(2026, 5, 1), ResolutionClass.Hour));
Assert.Equal(BucketStatus.Missing, coverage.Status);
}
[Fact]
public void Coverage_short_by_less_than_a_minute_is_complete_and_by_two_minutes_is_partial()
{
var day = Day(2026, 6, 10);
var jitter = Evaluate(day, Run(day.From.AddSeconds(59), day.To, ResolutionClass.Hour));
var shortfall = Evaluate(day, Run(day.From.AddMinutes(2), day.To, ResolutionClass.Hour));
Assert.Equal(BucketStatus.Available, jitter.Status);
Assert.Equal(BucketStatus.Partial, shortfall.Status);
Assert.Equal(ValueIssue.PartialCoverage, shortfall.Issue);
}
[Fact]
public void Overlapping_runs_are_unioned_rather_than_counted_twice()
{
var day = Day(2026, 6, 10);
var coverage = Evaluate(
day,
Run(day.From, day.From.AddHours(16), ResolutionClass.Hour),
Run(day.From.AddHours(8), day.From.AddHours(20), ResolutionClass.Hour));
Assert.Equal(TimeSpan.FromHours(20), coverage.Covered);
Assert.Equal(BucketStatus.Partial, coverage.Status);
}
[Fact]
public void An_empty_clipped_bucket_is_missing()
{
var instant = At(2026, 9, 1);
var empty = new AnalysisBucket(new DateOnly(2026, 9, 1), new DateOnly(2026, 9, 1), instant, instant, BucketSize.Day);
Assert.Equal(BucketStatus.Missing, Evaluate(empty, Run(At(2026, 8, 1), At(2026, 10, 1), ResolutionClass.Hour)).Status);
}
[Fact]
public void A_bucket_that_ends_before_it_starts_is_a_caller_error()
{
var reversed = new AnalysisBucket(new DateOnly(2026, 9, 2), new DateOnly(2026, 9, 1), At(2026, 9, 2), At(2026, 9, 1), BucketSize.Day);
Assert.Throws<ArgumentException>(() => Evaluate(reversed));
}
// ---- Opening balances and register discontinuities ------------------------------------------------
[Fact]
public void An_opening_balance_keeps_its_bucket_partial_even_when_readings_cover_the_rest()
{
var firstReading = At(2026, 3, 10, 14);
CoverageRun[] runs = [Run(firstReading, At(2026, 5, 1), ResolutionClass.Hour, divided: true)];
var day = CoverageEvaluator.Evaluate(Day(2026, 3, 10), runs, Berlin, openingBalanceInBucket: true);
var month = CoverageEvaluator.Evaluate(Month(2026, 3), runs, Berlin, openingBalanceInBucket: true);
var nextDay = CoverageEvaluator.Evaluate(Day(2026, 3, 11), runs, Berlin, openingBalanceInBucket: false);
Assert.Equal(BucketStatus.Partial, day.Status);
Assert.Equal(ValueIssue.OpeningBalance, day.Issue);
Assert.True(day.OpeningBalance);
Assert.Equal(ValueIssue.OpeningBalance, month.Issue);
Assert.Equal(BucketStatus.Available, nextDay.Status);
Assert.False(nextDay.OpeningBalance);
}
[Fact]
public void A_bucket_holding_only_an_opening_balance_is_partial_not_missing()
{
var coverage = CoverageEvaluator.Evaluate(Day(2026, 3, 10), [], Berlin, openingBalanceInBucket: true);
Assert.Equal(BucketStatus.Partial, coverage.Status);
Assert.Equal(ValueIssue.OpeningBalance, coverage.Issue);
Assert.Equal(TimeSpan.Zero, coverage.Covered);
}
[Fact]
public void An_opening_balance_read_at_midnight_marks_the_day_its_row_is_booked_in_and_not_the_day_before()
{
// A-01: the flag follows the booked row. A first reading describes no time before its midnight, so
// its row stays on 10 March 00:00 (D-11 only moves rows that close an interval), and [From, To)
// files that stamp under 10 March, the day the rollup flags.
var midnight = At(2026, 3, 10);
var rows = NormalizationEngine.CreateDefault().Normalize(new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "kWh" },
Readings =
[
new Reading { MeterId = 1, Time = midnight, Value = 100, Quality = ReadingQuality.Measured },
new Reading { MeterId = 1, Time = At(2026, 3, 10, 12), Value = 107, Quality = ReadingQuality.Measured },
],
TimeZone = Berlin,
});
var runs = CoverageBuilder.Build(rows, Berlin);
var opening = Assert.Single(rows, r => r.OpeningBalance);
bool BookedIn(AnalysisBucket bucket) => opening.Time >= bucket.From && opening.Time < bucket.To;
var dayBefore = CoverageEvaluator.Evaluate(Day(2026, 3, 9), runs, Berlin, BookedIn(Day(2026, 3, 9)));
var firstDay = CoverageEvaluator.Evaluate(Day(2026, 3, 10), runs, Berlin, BookedIn(Day(2026, 3, 10)));
Assert.Equal(midnight, opening.Time);
Assert.False(dayBefore.OpeningBalance);
Assert.Equal(BucketStatus.Missing, dayBefore.Status);
Assert.True(firstDay.OpeningBalance);
Assert.Equal(BucketStatus.Partial, firstDay.Status);
Assert.Equal(ValueIssue.OpeningBalance, firstDay.Issue);
}
[Theory]
[InlineData(CoverageGapReason.UnexplainedDecrease)]
[InlineData(CoverageGapReason.ResetWithoutPrevious)]
public void A_register_discontinuity_is_named_as_the_reason_a_bucket_is_partial_or_missing(CoverageGapReason reason)
{
CoverageRun[] runs =
[
Run(At(2026, 1, 1), At(2026, 3, 5, 12), ResolutionClass.Day),
Gap(At(2026, 3, 5, 12), At(2026, 3, 9, 12), reason),
Run(At(2026, 3, 9, 12), At(2026, 5, 1), ResolutionClass.Day),
];
var month = Evaluate(Month(2026, 3), runs);
var inside = Evaluate(Day(2026, 3, 7), runs);
Assert.Equal(BucketStatus.Partial, month.Status);
Assert.Equal(ValueIssue.RegisterDiscontinuity, month.Issue);
Assert.Equal([reason], month.Gaps);
Assert.Equal(BucketStatus.Missing, inside.Status);
Assert.Equal(ValueIssue.RegisterDiscontinuity, inside.Issue);
}
[Fact]
public void Gap_runs_never_count_as_coverage_and_never_make_a_bucket_unresolved()
{
var coverage = Evaluate(Month(2026, 3), Gap(At(2026, 1, 1), At(2026, 6, 1), CoverageGapReason.SampleGap));
Assert.Equal(BucketStatus.Missing, coverage.Status);
Assert.Equal(ValueIssue.SampleGap, coverage.Issue);
Assert.Equal(TimeSpan.Zero, coverage.Covered);
}
[Fact]
public void Several_gap_reasons_are_listed_once_each_most_significant_first()
{
var coverage = Evaluate(
Month(2026, 3),
Run(At(2026, 3, 1), At(2026, 3, 5), ResolutionClass.Hour),
Gap(At(2026, 3, 5), At(2026, 3, 6), CoverageGapReason.SampleGap),
Run(At(2026, 3, 6), At(2026, 3, 10), ResolutionClass.Hour),
Gap(At(2026, 3, 10), At(2026, 3, 11), CoverageGapReason.UnexplainedDecrease),
Run(At(2026, 3, 11), At(2026, 3, 20), ResolutionClass.Hour),
Gap(At(2026, 3, 20), At(2026, 3, 21), CoverageGapReason.SampleGap),
Run(At(2026, 3, 21), At(2026, 4, 1), ResolutionClass.Hour));
Assert.Equal([CoverageGapReason.UnexplainedDecrease, CoverageGapReason.SampleGap], coverage.Gaps);
Assert.Equal(ValueIssue.RegisterDiscontinuity, coverage.Issue);
}
[Fact]
public void Only_available_and_partial_buckets_carry_a_number()
{
var water = MonthLabels(2022, 1, 2023, 1);
var available = Evaluate(Month(2022, 12), water).ToValue(14, Provenance.Imported);
var partial = Evaluate(Day(2026, 3, 10), HourlyWithOutage).ToValue(3.5, Provenance.Measured);
var unresolved = Evaluate(Day(2022, 12, 15), water).ToValue(14, Provenance.Imported);
var missing = Evaluate(Month(2030, 1), water).ToValue(0, Provenance.None);
Assert.Equal(new BucketValue(14, BucketStatus.Available, Provenance.Imported), available);
Assert.Equal(new BucketValue(3.5, BucketStatus.Partial, Provenance.Measured, ValueIssue.SampleGap), partial);
Assert.Equal(new BucketValue(null, BucketStatus.Unresolved, Provenance.Imported, ValueIssue.CoarseResolution), unresolved);
Assert.Equal(BucketValue.Missing(), missing);
}
// ---- Virtual meters: joint evaluation -----------------------------------------------------------------
private static readonly IReadOnlyList<CoverageRun>[] MonthlyAndHourlySources =
[
[MonthLabels(2026, 1, 2026, 8)],
[Run(At(2026, 3, 15, 12), At(2026, 9, 1), ResolutionClass.Hour)],
];
[Fact]
public void A_virtual_bucket_is_as_coarse_as_its_coarsest_source()
{
Assert.Equal(BucketStatus.Available, CoverageEvaluator.EvaluateJoint(Month(2026, 4), MonthlyAndHourlySources, Berlin, false).Status);
var day = CoverageEvaluator.EvaluateJoint(Day(2026, 4, 10), MonthlyAndHourlySources, Berlin, false);
Assert.Equal(BucketStatus.Unresolved, day.Status);
Assert.Equal(ResolutionClass.Month, day.Resolution);
}
[Fact]
public void A_virtual_bucket_is_partial_where_its_sources_only_partly_overlap()
{
var march = CoverageEvaluator.EvaluateJoint(Month(2026, 3), MonthlyAndHourlySources, Berlin, false);
Assert.Equal(BucketStatus.Partial, march.Status);
Assert.Equal(At(2026, 3, 15, 12), march.FirstCovered);
}
[Fact]
public void A_missing_source_makes_the_virtual_bucket_missing()
{
var february = CoverageEvaluator.EvaluateJoint(Month(2026, 2), MonthlyAndHourlySources, Berlin, false);
var august = CoverageEvaluator.EvaluateJoint(Month(2026, 8), MonthlyAndHourlySources, Berlin, false);
Assert.Equal(BucketStatus.Missing, february.Status);
Assert.Equal(ValueIssue.MissingSource, february.Issue);
Assert.Equal(BucketStatus.Missing, august.Status);
Assert.Equal(ValueIssue.MissingSource, august.Issue);
}
[Fact]
public void Sources_covering_different_halves_of_a_bucket_cover_none_of_it_jointly()
{
IReadOnlyList<CoverageRun>[] sources =
[
[Run(At(2026, 6, 1), At(2026, 6, 16), ResolutionClass.Hour)],
[Run(At(2026, 6, 16), At(2026, 7, 1), ResolutionClass.Hour)],
];
var june = CoverageEvaluator.EvaluateJoint(Month(2026, 6), sources, Berlin, false);
Assert.Equal(BucketStatus.Missing, june.Status);
Assert.Equal(ValueIssue.NoCoverage, june.Issue);
}
[Fact]
public void A_source_whose_long_interval_is_clipped_by_the_other_sources_start_still_makes_the_bucket_unresolved()
{
// A books 1 January 15 March in March. B only starts on 1 March, so the joint run is the clipped
// piece 115 March, which on its own looks like a harmless interval inside March.
IReadOnlyList<CoverageRun>[] sources =
[
[Run(At(2026, 1, 1), At(2026, 3, 15), ResolutionClass.Coarse), Run(At(2026, 3, 15), At(2026, 6, 1), ResolutionClass.Day)],
[Run(At(2026, 3, 1), At(2026, 6, 1), ResolutionClass.Hour)],
];
var march = CoverageEvaluator.EvaluateJoint(Month(2026, 3), sources, Berlin, false);
Assert.Equal(BucketStatus.Unresolved, march.Status);
Assert.Equal(BucketStatus.Available, CoverageEvaluator.EvaluateJoint(Month(2026, 4), sources, Berlin, false).Status);
}
[Fact]
public void A_sources_opening_balance_reaches_the_virtual_bucket()
{
var firstReading = At(2026, 4, 10, 8);
IReadOnlyList<CoverageRun>[] sources =
[
[Run(firstReading, At(2026, 6, 1), ResolutionClass.Hour)],
[Run(At(2026, 1, 1), At(2026, 6, 1), ResolutionClass.Hour)],
];
var april = CoverageEvaluator.EvaluateJoint(Month(2026, 4), sources, Berlin, openingBalanceInBucket: true);
var may = CoverageEvaluator.EvaluateJoint(Month(2026, 5), sources, Berlin, openingBalanceInBucket: false);
Assert.True(april.OpeningBalance);
Assert.Equal(BucketStatus.Partial, april.Status);
Assert.Equal(ValueIssue.OpeningBalance, april.Issue);
Assert.Equal(BucketStatus.Available, may.Status);
}
[Fact]
public void A_virtual_meter_without_sources_covers_nothing()
{
Assert.Equal(BucketStatus.Missing, CoverageEvaluator.EvaluateJoint(Month(2026, 4), [], Berlin, false).Status);
}
// ---- Zero-length runs, series, now ------------------------------------------------------------------
[Fact]
public void A_zero_length_run_inside_a_bucket_covers_nothing_and_leaves_it_missing()
{
var instant = At(2026, 3, 10, 14);
var coverage = Evaluate(Day(2026, 3, 10), Run(instant, instant, ResolutionClass.Day));
Assert.Equal(BucketStatus.Missing, coverage.Status);
Assert.Equal(ValueIssue.NoCoverage, coverage.Issue);
Assert.Null(coverage.Resolution);
}
[Fact]
public void A_series_is_evaluated_bucket_for_bucket_exactly_as_one_bucket_at_a_time()
{
// Unsorted, overlapping runs with holes and gaps, and buckets of every size in no particular order.
CoverageRun[] runs =
[
Run(At(2026, 3, 13, 9), At(2026, 5, 1), ResolutionClass.Hour),
Gap(At(2026, 3, 10, 14), At(2026, 3, 13, 9), CoverageGapReason.SampleGap),
MonthLabels(2025, 1, 2026, 1),
Single(At(2026, 1, 1), At(2026, 2, 20), ResolutionClass.Coarse),
Run(At(2026, 2, 20), At(2026, 3, 10, 14), ResolutionClass.Day, divided: true),
Run(At(2026, 4, 1), At(2026, 4, 3), ResolutionClass.Hour),
Run(At(2026, 6, 5), At(2026, 6, 5), ResolutionClass.Hour),
];
var buckets = new List<AnalysisBucket> { Year(2025), Month(2026, 3), Week(2026, 3, 11) };
for (var day = new DateOnly(2026, 6, 10); day >= new DateOnly(2025, 12, 20); day = day.AddDays(-1))
{
buckets.Add(Day(day.Year, day.Month, day.Day));
}
buckets.Add(Month(2026, 1));
var flags = buckets.Select(b => b.FirstDay == new DateOnly(2026, 3, 13)).ToList();
var series = CoverageEvaluator.EvaluateSeries(buckets, runs, Berlin, flags);
Assert.Equal(buckets.Count, series.Count);
for (var i = 0; i < buckets.Count; i++)
{
var one = CoverageEvaluator.Evaluate(buckets[i], runs, Berlin, flags[i]);
Assert.Equal(one with { Gaps = NoGaps }, series[i] with { Gaps = NoGaps });
Assert.Equal(one.Gaps, series[i].Gaps);
}
}
[Fact]
public void A_joint_series_is_evaluated_bucket_for_bucket_exactly_as_one_bucket_at_a_time()
{
IReadOnlyList<CoverageRun>[] sources =
[
[MonthLabels(2026, 1, 2026, 8), Gap(At(2026, 8, 1), At(2026, 8, 3), CoverageGapReason.ResetWithoutPrevious)],
[Run(At(2026, 3, 15, 12), At(2026, 9, 1), ResolutionClass.Hour)],
];
var buckets = Enumerable.Range(1, 9).Select(m => Month(2026, m)).Concat([Day(2026, 4, 10), Week(2026, 3, 16)]).Reverse().ToList();
var series = CoverageEvaluator.EvaluateJointSeries(buckets, sources, Berlin, openingBalanceInBucket: null);
for (var i = 0; i < buckets.Count; i++)
{
var one = CoverageEvaluator.EvaluateJoint(buckets[i], sources, Berlin, openingBalanceInBucket: false);
Assert.Equal(one with { Gaps = NoGaps }, series[i] with { Gaps = NoGaps });
Assert.Equal(one.Gaps, series[i].Gaps);
}
}
[Fact]
public void A_series_needs_one_opening_balance_flag_per_bucket()
{
Assert.Throws<ArgumentException>(() =>
CoverageEvaluator.EvaluateSeries([Day(2026, 3, 1), Day(2026, 3, 2)], [], Berlin, [true]));
}
[Fact]
public void A_live_meters_day_and_month_to_date_are_complete_up_to_its_last_sample()
{
// m2 #9: five-minute samples, the last at 09:55, now 10:00. The five minutes since cannot have a row yet.
var now = At(2026, 9, 19, 10);
CoverageRun[] samples = [Run(At(2026, 8, 1), At(2026, 9, 19, 9, 55), ResolutionClass.Hour, divided: true)];
var today = CoverageEvaluator.Evaluate(CutAt(Day(2026, 9, 19), now), samples, Berlin, false, now);
var monthToDate = CoverageEvaluator.Evaluate(CutAt(Month(2026, 9), now), samples, Berlin, false, now);
Assert.Equal(BucketStatus.Available, today.Status);
Assert.Equal(BucketStatus.Available, monthToDate.Status);
Assert.Equal(At(2026, 9, 19, 9, 55), today.LastCovered);
}
[Fact]
public void Daily_readings_leave_today_complete_until_the_next_reading_is_due()
{
// Read at 06:00 each day: today's bucket ends at now (10:00), four hours after the last reading.
var now = At(2026, 9, 19, 10);
CoverageRun[] daily = [Run(At(2026, 9, 1, 6), At(2026, 9, 19, 6), ResolutionClass.Day, divided: true)];
Assert.Equal(BucketStatus.Available, CoverageEvaluator.Evaluate(CutAt(Day(2026, 9, 19), now), daily, Berlin, false, now).Status);
}
[Fact]
public void Coverage_that_stops_more_than_one_interval_before_now_is_partial()
{
var now = At(2026, 9, 19, 10);
CoverageRun[] hourly = [Run(At(2026, 9, 1), At(2026, 9, 19, 8, 30), ResolutionClass.Hour, divided: true)];
var today = CoverageEvaluator.Evaluate(CutAt(Day(2026, 9, 19), now), hourly, Berlin, false, now);
Assert.Equal(BucketStatus.Partial, today.Status);
Assert.Equal(ValueIssue.PartialCoverage, today.Issue);
}
[Fact]
public void A_known_hole_before_now_is_a_shortfall_not_lag()
{
var now = At(2026, 9, 19, 10);
CoverageRun[] runs =
[
Run(At(2026, 9, 1), At(2026, 9, 19, 9, 30), ResolutionClass.Hour, divided: true),
Gap(At(2026, 9, 19, 9, 30), At(2026, 9, 19, 9, 55), CoverageGapReason.SampleGap),
];
var today = CoverageEvaluator.Evaluate(CutAt(Day(2026, 9, 19), now), runs, Berlin, false, now);
Assert.Equal(BucketStatus.Partial, today.Status);
Assert.Equal(ValueIssue.SampleGap, today.Issue);
}
[Fact]
public void The_up_to_date_tolerance_applies_only_to_a_bucket_that_ends_at_now()
{
// The same shortfall in a finished day is a shortfall.
var now = At(2026, 9, 19, 10);
CoverageRun[] hourly = [Run(At(2026, 9, 1), At(2026, 9, 18, 23, 55), ResolutionClass.Hour, divided: true)];
Assert.Equal(BucketStatus.Partial, CoverageEvaluator.Evaluate(Day(2026, 9, 18), hourly, Berlin, false, now).Status);
Assert.Equal(BucketStatus.Partial, CoverageEvaluator.Evaluate(Day(2026, 9, 18), hourly, Berlin, false).Status);
}
[Fact]
public void Monthly_readings_leave_the_month_to_date_complete_until_the_next_one_is_due()
{
// Read on the 15th: September to date is covered to the 15th, and the next reading is not due until October.
var now = At(2026, 9, 19, 10);
CoverageRun[] monthly = [Run(At(2026, 5, 15), At(2026, 9, 15), ResolutionClass.Month, divided: true, last: At(2026, 9, 1))];
var september = CoverageEvaluator.Evaluate(CutAt(Month(2026, 9), now), monthly, Berlin, false, now);
Assert.Equal(BucketStatus.Available, september.Status);
}
[Fact]
public void The_running_months_label_row_is_recorded_after_now_so_the_month_to_date_is_missing()
{
// A-04, evaluated from stored runs: the September row closes on 1 October, so nothing covers September
// yet, and a zero would not be a true zero.
var now = At(2026, 9, 19, 10);
CoverageRun[] stored = [MonthLabels(2026, 1, 2026, 10)];
var september = CoverageEvaluator.Evaluate(CutAt(Month(2026, 9), now), stored, Berlin, false, now);
var august = CoverageEvaluator.Evaluate(Month(2026, 8), stored, Berlin, false, now);
Assert.Equal(BucketStatus.Missing, september.Status);
Assert.Equal(BucketStatus.Available, august.Status);
}
[Fact]
public void A_future_stamped_daily_reading_does_not_cost_yesterday_its_coverage()
{
// m2 #5: readings at 06:00 with one already stamped tomorrow. Only the interval containing now is lost.
var now = At(2026, 9, 19, 10);
CoverageRun[] stored = [Run(At(2026, 9, 1, 6), At(2026, 9, 20, 6), ResolutionClass.Day, divided: true, last: At(2026, 9, 19, 6))];
Assert.Equal(BucketStatus.Available, CoverageEvaluator.Evaluate(Day(2026, 9, 18), stored, Berlin, false, now).Status);
Assert.Equal(BucketStatus.Available, CoverageEvaluator.Evaluate(CutAt(Day(2026, 9, 19), now), stored, Berlin, false, now).Status);
Assert.Equal(At(2026, 9, 19, 6), CoverageEvaluator.Evaluate(CutAt(Day(2026, 9, 19), now), stored, Berlin, false, now).LastCovered);
}
// ---- Another zone ------------------------------------------------------------------------------------
[Fact]
public void In_New_York_monthly_labels_resolve_New_York_months_and_not_their_days()
{
var water = MonthLabels(NewYork, 2026, 1, 2026, 7);
var march = CoverageEvaluator.Evaluate(Month(NewYork, 2026, 3), [water], NewYork, false);
var dstDay = CoverageEvaluator.Evaluate(Day(NewYork, 2026, 3, 8), [water], NewYork, false);
Assert.Equal(BucketStatus.Available, march.Status);
Assert.Equal(TimeSpan.FromDays(31) - TimeSpan.FromHours(1), march.Length);
Assert.Equal(BucketStatus.Unresolved, dstDay.Status);
Assert.Equal(TimeSpan.FromHours(23), dstDay.Length);
}
}