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;
///
/// 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).
///
public sealed class CoverageEvaluatorTests
{
private static readonly IReadOnlyList 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 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 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(() => 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[] 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[] 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 1–15 March, which on its own looks like a harmless interval inside March.
IReadOnlyList[] 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[] 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 { 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[] 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(() =>
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);
}
}