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); } [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); } }