using MeterVault.Core.Analysis; using MeterVault.Core.Analysis.Coverage; using MeterVault.Core.Domain; using MeterVault.Core.Normalization; using static MeterVault.Core.Tests.Analysis.AnalysisTestTime; using static MeterVault.Core.Tests.TestData; namespace MeterVault.Core.Tests.Analysis; /// /// Coverage runs (D-13): which stretches of time a meter's data covers, at what resolution, and where /// it is a known hole. What a bucket may claim — available, partial, unresolved, missing — is decided /// from these runs, so the shapes the seeded instance really has are pinned here: monthly sheets, a /// burner read once in twelve years, live snapshots, and the holes a register or sensor can leave. Runs are /// stored uncapped and say where their last interval starts, so any reader can cut them at its own now (A-04). /// public sealed class CoverageBuilderTests { private readonly INormalizationEngine _engine = NormalizationEngine.CreateDefault(); private static Reading Measured(DateTimeOffset time, double value) => new() { MeterId = 1, Time = time, Value = value, Quality = ReadingQuality.Measured }; private static TimeZoneInfo Zone(string id) => id == "UTC" ? TimeZoneInfo.Utc : TimeZoneInfo.FindSystemTimeZoneById(id); private static CoverageRun Run( DateTimeOffset from, DateTimeOffset to, ResolutionClass resolution, bool divided, DateTimeOffset last, CoverageGapReason gap = CoverageGapReason.None) => new(from.ToUniversalTime(), to.ToUniversalTime(), resolution, divided, gap, last.ToUniversalTime()); private IReadOnlyList Coverage(MeterMode mode, TimeZoneInfo zone, IReadOnlyList readings, IReadOnlyList? events = null) => CoverageBuilder.Build( _engine.Normalize(new NormalizationContext { Meter = new MeterConfig { MeterId = 1, Mode = mode, Unit = "kWh" }, Readings = readings, Events = events ?? [], TimeZone = zone, }), zone); [Theory] [InlineData("UTC")] [InlineData("Europe/Berlin")] [InlineData("America/New_York")] public void A_monthly_sheet_is_one_month_run_divided_at_months(string zoneId) { var zone = Zone(zoneId); var readings = Enumerable.Range(0, 12).Select(i => Reading(1, Month(2022, 9).AddMonths(i), 100 * i)).ToList(); var run = Assert.Single(Coverage(MeterMode.CumulativeCounter, zone, readings)); Assert.Equal(GapAttribution.LocalMidnight(new DateOnly(2022, 9, 1), zone), run.From); Assert.Equal(GapAttribution.LocalMidnight(new DateOnly(2023, 9, 1), zone), run.To); Assert.Equal(GapAttribution.LocalMidnight(new DateOnly(2023, 8, 1), zone), run.LastIntervalStart); Assert.Equal(ResolutionClass.Month, run.Resolution); Assert.True(run.DividedAtMonths); Assert.False(run.IsGap); } [Fact] public void A_twelve_year_burner_interval_is_its_own_coarse_run_followed_by_a_month_run() { // The seeded burner shape: 0 h on 18.10.2010, then monthly rows from "Oktober 2022" on. The twelve // years of hours cannot be placed in any month; the months after can. var runs = Coverage(MeterMode.RuntimeCounter, Berlin, [ DayReading(1, Utc(2010, 10, 18), 0), Reading(1, Month(2022, 10), 7758), Reading(1, Month(2022, 11), 7785), Reading(1, Month(2022, 12), 7952), Reading(1, Month(2023, 1), 8127), ]); Assert.Equal( [ Run(Utc(2010, 10, 18), InBerlin(2022, 11, 1), ResolutionClass.Coarse, divided: false, last: Utc(2010, 10, 18)), Run(InBerlin(2022, 11, 1), InBerlin(2023, 2, 1), ResolutionClass.Month, divided: true, last: InBerlin(2023, 1, 1)), ], runs); } [Fact] public void Consecutive_intervals_longer_than_a_month_are_each_their_own_run() { var runs = Coverage(MeterMode.RuntimeCounter, Berlin, [DayReading(1, Utc(2022, 1, 10), 0), DayReading(1, Utc(2022, 3, 10), 100), DayReading(1, Utc(2022, 5, 10), 180)]); Assert.Equal( [ Run(Utc(2022, 1, 10), Utc(2022, 3, 10), ResolutionClass.Coarse, divided: false, last: Utc(2022, 1, 10)), Run(Utc(2022, 3, 10), Utc(2022, 5, 10), ResolutionClass.Coarse, divided: false, last: Utc(2022, 3, 10)), ], runs); } [Fact] public void A_divided_interval_longer_than_a_month_is_month_class_so_month_charts_stay_monthly() { // m7 #3, A-03: two six-week gaps between hand readings, each divided at the month starts it crosses. // Month buckets can place every share, so the data is month-resolution — not coarse, which would make // auto bucketing jump to years — and no share is finer than the six weeks it was estimated from. var runs = Coverage(MeterMode.CumulativeCounter, Berlin, [ Measured(InBerlin(2026, 8, 1, 9), 700), Measured(InBerlin(2026, 9, 16, 18), 746), Measured(InBerlin(2026, 11, 2, 12), 800), ]); var run = Assert.Single(runs); Assert.Equal(Run(InBerlin(2026, 8, 1, 9), InBerlin(2026, 11, 2, 12), ResolutionClass.Month, divided: true, last: InBerlin(2026, 11, 1)), run); Assert.Equal(BucketSize.Month, BucketPlanner.MinimumSizeFor(run.Resolution)); } [Fact] public void Two_divided_intervals_meeting_at_a_month_start_are_classified_each_by_its_own_length() { // m7 #3: read on 31 January 12:00, at 00:00 on 1 March, and on 2 April 12:00. Both intervals are // divided and their shares meet at 1 March like the shares of one interval do; told apart by their // source intervals they are 29.5 and 32.5 days — month-class, never one 61-day coarse interval. var rows = _engine.Normalize(new NormalizationContext { Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "kWh" }, Readings = [Measured(InBerlin(2026, 1, 31, 12), 100), Measured(InBerlin(2026, 3, 1), 130), Measured(InBerlin(2026, 4, 2, 12), 170)], TimeZone = Berlin, }); var run = Assert.Single(CoverageBuilder.Build(rows, Berlin)); Assert.Equal(ResolutionClass.Month, run.Resolution); Assert.True(run.DividedAtMonths); Assert.Equal( [(InBerlin(2026, 1, 31, 12), InBerlin(2026, 3, 1)), (InBerlin(2026, 3, 1), InBerlin(2026, 4, 2, 12))], rows.Where(r => r.Divided).Select(r => (r.SourceStart!.Value, r.SourceEnd!.Value)).Distinct()); } [Fact] public void Month_long_readings_on_the_twentieth_are_divided_and_merge_into_one_month_run() { var runs = Coverage(MeterMode.CumulativeCounter, Berlin, [ Measured(InBerlin(2026, 6, 20, 12), 100), Measured(InBerlin(2026, 7, 20, 12), 130), Measured(InBerlin(2026, 8, 20, 12), 170), Measured(InBerlin(2026, 9, 20, 12), 200), ]); Assert.Equal([Run(InBerlin(2026, 6, 20, 12), InBerlin(2026, 9, 20, 12), ResolutionClass.Month, divided: true, last: InBerlin(2026, 9, 1))], runs); } [Fact] public void A_register_that_stands_still_across_a_month_boundary_stays_in_the_month_aligned_run() { // m7 #2: the wallbox read on the 20th did not move from 20 June to 20 July. Zero is zero in both months. var runs = Coverage(MeterMode.CumulativeCounter, Berlin, [ Measured(InBerlin(2026, 5, 20, 12), 100), Measured(InBerlin(2026, 6, 20, 12), 130), Measured(InBerlin(2026, 7, 20, 12), 130), Measured(InBerlin(2026, 8, 20, 12), 160), ]); var run = Assert.Single(runs); Assert.True(run.DividedAtMonths); Assert.Equal(BucketStatus.Available, CoverageEvaluator.Evaluate(CoverageTestData.Month(2026, 6), runs, Berlin, false).Status); Assert.Equal(BucketStatus.Available, CoverageEvaluator.Evaluate(CoverageTestData.Month(2026, 7), runs, Berlin, false).Status); } [Fact] public void Burner_hours_that_stand_still_across_a_month_boundary_are_month_aligned_but_moving_hours_are_not() { var runs = Coverage(MeterMode.RuntimeCounter, Berlin, [ Measured(InBerlin(2026, 5, 20, 12), 100), Measured(InBerlin(2026, 6, 20, 12), 100), Measured(InBerlin(2026, 7, 20, 12), 140), ]); Assert.Equal( [ Run(InBerlin(2026, 5, 20, 12), InBerlin(2026, 6, 20, 12), ResolutionClass.Month, divided: true, last: InBerlin(2026, 5, 20, 12)), Run(InBerlin(2026, 6, 20, 12), InBerlin(2026, 7, 20, 12), ResolutionClass.Month, divided: false, last: InBerlin(2026, 6, 20, 12)), ], runs); } [Fact] public void Undivided_monthly_intervals_that_straddle_month_starts_are_each_their_own_run() { // Burner hours read on the 20th: every interval straddles a month start undivided, so its own two // readings are the only places it can be cut — a run keeps only its ends, so each interval is one. var runs = Coverage(MeterMode.RuntimeCounter, Berlin, [ Measured(InBerlin(2026, 6, 20, 12), 100), Measured(InBerlin(2026, 7, 20, 12), 130), Measured(InBerlin(2026, 8, 20, 12), 170), ]); Assert.Equal( [ Run(InBerlin(2026, 6, 20, 12), InBerlin(2026, 7, 20, 12), ResolutionClass.Month, divided: false, last: InBerlin(2026, 6, 20, 12)), Run(InBerlin(2026, 7, 20, 12), InBerlin(2026, 8, 20, 12), ResolutionClass.Month, divided: false, last: InBerlin(2026, 7, 20, 12)), ], runs); } [Fact] public void Undivided_weekly_hours_that_straddle_a_month_start_are_not_divided_at_months() { // Burner hours read every Monday. The week across 1 September is one undivided interval, so a // month bucket cannot split it; a register read on the same days divides that week and stays whole. Reading[] mondays = [ Measured(InBerlin(2026, 8, 17, 10), 100), Measured(InBerlin(2026, 8, 24, 10), 110), Measured(InBerlin(2026, 8, 31, 10), 125), Measured(InBerlin(2026, 9, 7, 10), 131), Measured(InBerlin(2026, 9, 14, 10), 140), ]; var hours = Coverage(MeterMode.RuntimeCounter, Berlin, mondays); var register = Coverage(MeterMode.CumulativeCounter, Berlin, mondays); Assert.Equal( [ Run(InBerlin(2026, 8, 17, 10), InBerlin(2026, 8, 31, 10), ResolutionClass.Week, divided: true, last: InBerlin(2026, 8, 24, 10)), Run(InBerlin(2026, 8, 31, 10), InBerlin(2026, 9, 7, 10), ResolutionClass.Week, divided: false, last: InBerlin(2026, 8, 31, 10)), Run(InBerlin(2026, 9, 7, 10), InBerlin(2026, 9, 14, 10), ResolutionClass.Week, divided: true, last: InBerlin(2026, 9, 7, 10)), ], hours); Assert.Equal([Run(InBerlin(2026, 8, 17, 10), InBerlin(2026, 9, 14, 10), ResolutionClass.Week, divided: true, last: InBerlin(2026, 9, 7, 10))], register); } [Fact] public void Midnight_snapshots_are_one_day_run() { var snapshots = Enumerable.Range(0, 10).Select(i => Measured(InBerlin(2026, 8, 25).AddDays(i), 100 + i)).ToList(); var run = Assert.Single(Coverage(MeterMode.CumulativeCounter, Berlin, snapshots)); Assert.Equal(Run(InBerlin(2026, 8, 25), InBerlin(2026, 9, 3), ResolutionClass.Day, divided: true, last: InBerlin(2026, 9, 2)), run); } [Fact] public void An_unexplained_decrease_is_a_gap_between_covered_runs() { var runs = Coverage(MeterMode.CumulativeCounter, Berlin, [ Measured(InBerlin(2026, 9, 1), 10), Measured(InBerlin(2026, 9, 2), 11), Measured(InBerlin(2026, 9, 3), 12), Measured(InBerlin(2026, 9, 4), 5), Measured(InBerlin(2026, 9, 5), 6), Measured(InBerlin(2026, 9, 6), 7), ]); Assert.Equal( [ Run(InBerlin(2026, 9, 1), InBerlin(2026, 9, 3), ResolutionClass.Day, divided: true, last: InBerlin(2026, 9, 2)), Run(InBerlin(2026, 9, 3), InBerlin(2026, 9, 4), ResolutionClass.Day, divided: false, last: InBerlin(2026, 9, 3), CoverageGapReason.UnexplainedDecrease), Run(InBerlin(2026, 9, 4), InBerlin(2026, 9, 6), ResolutionClass.Day, divided: true, last: InBerlin(2026, 9, 5)), ], runs); Assert.True(runs[1].IsGap); } [Fact] public void Consecutive_decreases_are_one_gap_run() { var runs = Coverage(MeterMode.CumulativeCounter, Berlin, [Measured(InBerlin(2026, 9, 1), 10), Measured(InBerlin(2026, 9, 2), 9), Measured(InBerlin(2026, 9, 3), 8)]); Assert.Equal( [Run(InBerlin(2026, 9, 1), InBerlin(2026, 9, 3), ResolutionClass.Day, divided: false, last: InBerlin(2026, 9, 2), CoverageGapReason.UnexplainedDecrease)], runs); } [Fact] public void A_reset_that_does_not_say_where_the_register_stopped_is_a_gap_run() { var runs = CoverageBuilder.Build( _engine.Normalize(new NormalizationContext { Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "kWh" }, Readings = [Reading(1, Month(2023, 1), 100), Reading(1, Month(2023, 2), 150), Reading(1, Month(2023, 3), 30), Reading(1, Month(2023, 4), 80)], Events = [Reset(1, Month(2023, 3), newValue: 0)], }), TimeZoneInfo.Utc); Assert.Equal( [ Run(Month(2023, 1), Month(2023, 3), ResolutionClass.Month, divided: true, last: Month(2023, 2)), Run(Month(2023, 3), Month(2023, 4), ResolutionClass.Month, divided: false, last: Month(2023, 3), CoverageGapReason.ResetWithoutPrevious), Run(Month(2023, 4), Month(2023, 5), ResolutionClass.Month, divided: true, last: Month(2023, 4)), ], runs); } [Fact] public void A_sensor_silence_is_a_gap_run_between_hourly_runs() { var samples = Enumerable.Range(0, 13).Select(i => Measured(Utc(2024, 6, 1, 10).AddMinutes(5 * i), 2)) .Append(Measured(Utc(2024, 6, 1, 14), 2)) .Append(Measured(Utc(2024, 6, 1, 14, 5), 2)) .ToList(); var runs = Coverage(MeterMode.InstantRate, TimeZoneInfo.Utc, samples); Assert.Equal( [ Run(Utc(2024, 6, 1, 10), Utc(2024, 6, 1, 11), ResolutionClass.Hour, divided: true, last: Utc(2024, 6, 1, 10, 55)), Run(Utc(2024, 6, 1, 11), Utc(2024, 6, 1, 14), ResolutionClass.Day, divided: false, last: Utc(2024, 6, 1, 11), CoverageGapReason.SampleGap), Run(Utc(2024, 6, 1, 14), Utc(2024, 6, 1, 14, 5), ResolutionClass.Hour, divided: true, last: Utc(2024, 6, 1, 14)), ], runs); } [Fact] public void Stored_runs_describe_rows_recorded_after_now_until_a_reader_caps_them() { // m7 #4, A-04: "September 2026" imported on the 19th. The stored run still covers September — the // rebuild's "now" must not be frozen into it — and capping at the reader's now gives the month up. var now = InBerlin(2026, 9, 19, 12); var runs = Coverage(MeterMode.CumulativeCounter, Berlin, [Reading(1, Month(2026, 7), 100), Reading(1, Month(2026, 8), 130), Reading(1, Month(2026, 9), 170)]); Assert.Equal([Run(InBerlin(2026, 7, 1), InBerlin(2026, 10, 1), ResolutionClass.Month, divided: true, last: InBerlin(2026, 9, 1))], runs); Assert.Equal( [new CoverageRun(InBerlin(2026, 7, 1).ToUniversalTime(), InBerlin(2026, 9, 1).ToUniversalTime(), ResolutionClass.Month, DividedAtMonths: true)], CoverageRuns.CapAt(runs, now, Berlin)); } [Fact] public void A_future_reading_costs_only_the_share_that_closes_after_now() { // m2 #5: read on the 15th, the 15 September reading stamped ahead (now: 10 September). The August // share of its interval closed on 1 September and stays covered. var runs = Coverage(MeterMode.CumulativeCounter, Berlin, [ Measured(InBerlin(2026, 7, 15, 8), 100), Measured(InBerlin(2026, 8, 15, 8), 130), Measured(InBerlin(2026, 9, 15, 8), 170), ]); var capped = Assert.Single(CoverageRuns.CapAt(runs, InBerlin(2026, 9, 10, 12), Berlin)); Assert.Equal(InBerlin(2026, 9, 1), capped.To); } [Fact] public void A_tank_that_was_not_drawn_from_across_a_month_boundary_is_month_aligned() { var rows = _engine.Normalize(new NormalizationContext { Meter = new MeterConfig { MeterId = 30, Mode = MeterMode.ConsumableBalance, Unit = "L" }, Events = [ new MeterEvent { MeterId = 30, Time = InBerlin(2026, 6, 20, 10), EventType = MeterEventType.TankLevel, Amount = 3000, Unit = "L" }, new MeterEvent { MeterId = 30, Time = InBerlin(2026, 7, 20, 10), EventType = MeterEventType.TankLevel, Amount = 3000, Unit = "L" }, new MeterEvent { MeterId = 30, Time = InBerlin(2026, 8, 20, 10), EventType = MeterEventType.TankLevel, Amount = 2800, Unit = "L" }, ], TimeZone = Berlin, }); var runs = CoverageBuilder.Build(rows, Berlin); Assert.Equal([true, false], runs.Select(r => r.DividedAtMonths)); } [Fact] public void An_opening_balance_and_rows_without_an_interval_cover_nothing() { Assert.Empty(Coverage(MeterMode.CumulativeCounter, Berlin, [Measured(InBerlin(2026, 9, 2, 8), 300)])); Assert.Empty(CoverageBuilder.Build([new Consumption { MeterId = 1, Time = Utc(2026, 9, 1), Amount = 5 }], Berlin)); } [Fact] public void Overlapping_rows_never_produce_overlapping_runs() { // A nine-day and a seven-day interval that overlap: the earlier one keeps the overlap, the later one // keeps its own resolution. Consumption Row(DateTimeOffset from, DateTimeOffset to) => new() { MeterId = 1, Time = to, Amount = 1, IntervalStart = from, IntervalEnd = to }; var runs = CoverageBuilder.Build( [Row(Utc(2026, 1, 5), Utc(2026, 1, 12)), Row(Utc(2026, 1, 1), Utc(2026, 1, 10)), Row(Utc(2026, 1, 2), Utc(2026, 1, 4))], TimeZoneInfo.Utc); Assert.Equal( [ Run(Utc(2026, 1, 1), Utc(2026, 1, 10), ResolutionClass.Month, divided: true, last: Utc(2026, 1, 1)), Run(Utc(2026, 1, 10), Utc(2026, 1, 12), ResolutionClass.Week, divided: true, last: Utc(2026, 1, 10)), ], runs); } [Fact] public void Runs_are_in_utc_whatever_offset_the_rows_carry() { var from = new DateTimeOffset(2026, 9, 1, 8, 0, 0, TimeSpan.FromHours(2)); var to = new DateTimeOffset(2026, 9, 1, 9, 0, 0, TimeSpan.FromHours(2)); var run = Assert.Single(CoverageBuilder.Build( [new Consumption { MeterId = 1, Time = to, Amount = 1, IntervalStart = from, IntervalEnd = to }], Berlin)); Assert.Equal(TimeSpan.Zero, run.From.Offset); Assert.Equal(TimeSpan.Zero, run.To.Offset); Assert.Equal(TimeSpan.Zero, run.LastIntervalStart!.Value.Offset); Assert.Equal(from, run.From); } }