using MeterVault.Core.Analysis; using MeterVault.Core.Analysis.Coverage; using static MeterVault.Core.Tests.Analysis.CoverageTestData; namespace MeterVault.Core.Tests.Analysis; /// /// A change figure is confident only over the time both periods cover (D-07). What these pin down: the /// match follows the current data's actual end rather than the period's; it trims to whole months where /// either side only has monthly data, and to interval edges inside undivided intervals; a bound on a reading /// instant keeps the row closing there on the side its time lies on; every source of a combined scope has to /// agree; holes split the match; an opening balance stays out of it; and no overlap means "not comparable" /// rather than a percentage against nothing. /// public sealed class MatchedCoverageTests { private static readonly DateTimeOffset Now = At(2026, 9, 19, 10); private static readonly TimeSpan Past = MatchedCoverage.PastBookedRow; private static DateTimeOffset PreviousYear(DateTimeOffset instant) => ShiftYears(instant, -1); private static ResolvedPeriod YearToDate(DateTimeOffset now) => Period(PeriodPreset.YearToDate, new DateOnly(now.Year, 1, 1), DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(now, Berlin).DateTime), now); private static MatchedCoverageResult MatchYearToDate( DateTimeOffset now, CoverageRun[] currentRuns, CoverageRun[] comparisonRuns, DateTimeOffset[]? currentOpeningBalances = null, DateTimeOffset[]? comparisonOpeningBalances = null) { var current = YearToDate(now); return MatchedCoverage.Match( MatchSide.Of(current.From, current.To, now, Berlin, currentRuns, currentOpeningBalances), MatchSide.Of(PreviousYear(current.From), PreviousYear(current.To), now, Berlin, comparisonRuns, comparisonOpeningBalances), PreviousYear); } [Fact] public void Year_to_date_with_data_until_31_May_matches_January_to_May_in_both_years() { var result = MatchYearToDate(Now, [MonthLabels(2026, 1, 2026, 6)], [MonthLabels(2025, 1, 2026, 1)]); Assert.True(result.IsComparable); Assert.True(result.IsContiguous); Assert.Equal(new MatchedRange(At(2026, 1, 1), At(2026, 6, 1), new DateOnly(2026, 1, 1), new DateOnly(2026, 5, 31)), result.Current); Assert.Equal(new MatchedRange(At(2025, 1, 1), At(2025, 6, 1), new DateOnly(2025, 1, 1), new DateOnly(2025, 5, 31)), result.Comparison); } [Fact] public void Hourly_data_on_both_sides_matches_up_to_the_cut_at_now() { var result = MatchYearToDate( Now, [Run(At(2025, 6, 1), At(2026, 12, 1), ResolutionClass.Hour)], [Run(At(2024, 6, 1), At(2025, 12, 1), ResolutionClass.Hour)]); Assert.Equal(At(2026, 1, 1), result.Current!.From); Assert.Equal(Now, result.Current.To); Assert.Equal(new DateOnly(2026, 9, 19), result.Current.LastDay); Assert.Equal(At(2025, 9, 19, 10), result.Comparison!.To); } [Fact] public void Monthly_data_on_the_comparison_side_trims_a_mid_month_cut_to_whole_months_on_both_sides() { // Month-resolution data cannot say how much of May had accrued by the 19th. var now = At(2026, 5, 19, 14, 37); var result = MatchYearToDate( now, [Run(At(2025, 12, 1), At(2026, 5, 19, 14, 37), ResolutionClass.Hour)], [MonthLabels(2025, 1, 2026, 1)]); Assert.Equal(new MatchedRange(At(2026, 1, 1), At(2026, 5, 1), new DateOnly(2026, 1, 1), new DateOnly(2026, 4, 30)), result.Current); Assert.Equal(new MatchedRange(At(2025, 1, 1), At(2025, 5, 1), new DateOnly(2025, 1, 1), new DateOnly(2025, 4, 30)), result.Comparison); } [Fact] public void Daily_readings_match_up_to_the_last_reading_before_now_and_include_its_row() { // Read at 06:00 daily; the reading of 20 September is already stamped. The interval containing now is // given up (A-04), and the match ends on today's 06:00 reading, whose row closes the day before it. var result = MatchYearToDate( Now, [Run(At(2025, 12, 31, 6), At(2026, 9, 20, 6), ResolutionClass.Day, divided: true, last: At(2026, 9, 19, 6))], [Run(At(2024, 12, 1), At(2025, 12, 1), ResolutionClass.Hour)]); Assert.Equal(At(2026, 1, 1), result.Current!.From); Assert.Equal(At(2026, 9, 19, 6) + Past, result.Current.To); Assert.Equal(new DateOnly(2026, 9, 19), result.Current.LastDay); Assert.Equal(At(2025, 9, 19, 6), result.Comparison!.To); } [Fact] public void A_cut_inside_daily_readings_falls_back_to_local_midnight() { // The comparison year was read at 06:00 daily; 10:00 on 19 September lies inside one of its days. var result = MatchYearToDate( Now, [Run(At(2025, 12, 1), At(2026, 12, 1), ResolutionClass.Hour)], [Run(At(2024, 12, 31, 6), At(2025, 12, 1, 6), ResolutionClass.Day, divided: true)]); Assert.Equal(At(2026, 9, 19), result.Current!.To); Assert.Equal(new DateOnly(2026, 9, 18), result.Current.LastDay); Assert.Equal(At(2025, 9, 19), result.Comparison!.To); } [Fact] public void An_undivided_tank_interval_open_at_the_cut_is_left_out_of_the_match() { var result = MatchYearToDate( Now, [Run(At(2026, 1, 1), At(2026, 8, 5), ResolutionClass.Month), Single(At(2026, 8, 5), At(2026, 10, 5), ResolutionClass.Coarse)], [Run(At(2025, 1, 1), At(2025, 12, 1), ResolutionClass.Hour)]); Assert.Equal(At(2026, 8, 5), result.Current!.To); Assert.Equal(At(2025, 8, 5), result.Comparison!.To); } [Fact] public void Trimming_month_data_never_reaches_back_into_the_long_interval_before_it() { // Monthly tank readings resumed on 3 May after a 44-day interval. Floored to 1 May, the cut would // land inside that interval; the reading on 3 May is the last edge the data can end on. var now = At(2026, 5, 19, 14); var result = MatchYearToDate( now, [ Run(At(2026, 1, 1), At(2026, 3, 20), ResolutionClass.Month), Run(At(2026, 3, 20), At(2026, 5, 3), ResolutionClass.Coarse), Run(At(2026, 5, 3), At(2026, 6, 2), ResolutionClass.Month), ], [Run(At(2025, 1, 1), At(2026, 1, 1), ResolutionClass.Hour)]); Assert.Equal(At(2026, 5, 3), result.Current!.To); Assert.Equal(At(2025, 5, 3), result.Comparison!.To); } [Fact] public void A_match_ending_on_a_dipstick_reading_includes_the_row_booked_at_that_reading() { // m2 #3 (P4): the row for 20 March → 3 May is stamped at 3 May 10:00. A query ending exactly there // would leave those 44 days out of the current side while the comparison year keeps them. var now = At(2026, 5, 19, 14); var result = MatchYearToDate( now, [ Run(At(2026, 1, 1), At(2026, 3, 20, 10), ResolutionClass.Month), Single(At(2026, 3, 20, 10), At(2026, 5, 3, 10), ResolutionClass.Coarse), Single(At(2026, 5, 3, 10), At(2026, 6, 2, 10), ResolutionClass.Month), ], [Run(At(2025, 1, 1), At(2026, 1, 1), ResolutionClass.Hour)]); Assert.Equal(At(2026, 5, 3, 10) + Past, result.Current!.To); Assert.Equal(new DateOnly(2026, 5, 3), result.Current.LastDay); Assert.Equal(At(2025, 5, 3, 10), result.Comparison!.To); } [Fact] public void A_comparison_month_cut_inside_undivided_dipstick_intervals_is_cut_at_the_dipsticks() { // m2 #3 (P7): November 2026 hourly against November 2025, read on 20 October, 18 and 29 November and // 20 December. 1 November and 1 December lie inside undivided intervals; their dipsticks are the only // cut points, and the start moves past the row closing at the first one. var current = Period(PeriodPreset.Custom, new DateOnly(2026, 11, 1), new DateOnly(2026, 11, 30), At(2026, 12, 5)); var comparisonRuns = new[] { Single(At(2025, 10, 20, 10), At(2025, 11, 18, 10), ResolutionClass.Month), Single(At(2025, 11, 18, 10), At(2025, 11, 29, 10), ResolutionClass.Month, divided: true), Single(At(2025, 11, 29, 10), At(2025, 12, 20, 10), ResolutionClass.Month), }; var result = MatchedCoverage.Match( MatchSide.Of(current.From, current.To, current.Now, Berlin, [Run(At(2026, 10, 1), At(2027, 1, 1), ResolutionClass.Hour)]), MatchSide.Of(PreviousYear(current.From), PreviousYear(current.To), current.Now, Berlin, comparisonRuns), PreviousYear); Assert.Equal(new MatchedRange(At(2025, 11, 18, 10) + Past, At(2025, 11, 29, 10) + Past, new DateOnly(2025, 11, 18), new DateOnly(2025, 11, 29)), result.Comparison); Assert.Equal(new MatchedRange(At(2026, 11, 18, 10), At(2026, 11, 29, 10), new DateOnly(2026, 11, 18), new DateOnly(2026, 11, 29)), result.Current); } [Fact] public void A_comparison_month_that_one_undivided_interval_straddles_on_both_ends_is_not_comparable() { // m2 #3 (P7): 14 October → 28 November 10:00 → 20 December. No dipstick falls where November can be cut. var current = Period(PeriodPreset.Custom, new DateOnly(2026, 11, 1), new DateOnly(2026, 11, 30), At(2026, 12, 5)); var result = MatchedCoverage.Match( MatchSide.Of(current.From, current.To, current.Now, Berlin, [Run(At(2026, 10, 1), At(2027, 1, 1), ResolutionClass.Hour)]), MatchSide.Of( PreviousYear(current.From), PreviousYear(current.To), current.Now, Berlin, [Single(At(2025, 10, 14), At(2025, 11, 28, 10), ResolutionClass.Coarse), Single(At(2025, 11, 28, 10), At(2025, 12, 20), ResolutionClass.Month)]), PreviousYear); Assert.False(result.IsComparable); } [Fact] public void A_combined_scope_starts_its_match_where_every_source_can_be_cut() { // m2 #4 (P1): source A books 1 January – 15 March in one interval, then reads daily; source B starts // hourly on 1 March. Together they cover from 1 March, but A's interval can only be cut at its end. var current = YearToDate(Now); IReadOnlyList[] currentSources = [ [Single(At(2026, 1, 1), At(2026, 3, 15), ResolutionClass.Coarse), Run(At(2026, 3, 15), At(2026, 12, 1), ResolutionClass.Day, divided: true)], [Run(At(2026, 3, 1), At(2026, 12, 1), ResolutionClass.Hour)], ]; IReadOnlyList[] comparisonSources = [ [Run(At(2025, 1, 1), At(2026, 1, 1), ResolutionClass.Hour)], [Run(At(2025, 1, 1), At(2026, 1, 1), ResolutionClass.Hour)], ]; var result = MatchedCoverage.Match( MatchSide.OfSources(current.From, current.To, Now, Berlin, currentSources), MatchSide.OfSources(PreviousYear(current.From), PreviousYear(current.To), Now, Berlin, comparisonSources), PreviousYear); Assert.Equal(At(2026, 3, 15), result.Current!.From); Assert.Equal(At(2025, 3, 15), result.Comparison!.From); } [Fact] public void A_scope_without_sources_is_not_comparable() { var current = YearToDate(Now); var result = MatchedCoverage.Match( MatchSide.OfSources(current.From, current.To, Now, Berlin, []), MatchSide.Of(PreviousYear(current.From), PreviousYear(current.To), Now, Berlin, [MonthLabels(2025, 1, 2026, 1)]), PreviousYear); Assert.False(result.IsComparable); } [Fact] public void A_meter_installed_in_March_last_year_matches_from_March_on_both_sides() { var result = MatchYearToDate( Now, [Run(At(2025, 1, 1), At(2026, 12, 1), ResolutionClass.Hour)], [Run(At(2025, 3, 10), At(2026, 1, 1), ResolutionClass.Hour)]); Assert.Equal(At(2026, 3, 10), result.Current!.From); Assert.Equal(new DateOnly(2026, 3, 10), result.Current.FirstDay); Assert.Equal(At(2025, 3, 10), result.Comparison!.From); Assert.Equal(Now, result.Current.To); } [Fact] public void An_outage_in_the_comparison_year_splits_the_match_and_is_left_out_of_both_sides() { var result = MatchYearToDate( Now, [Run(At(2026, 1, 1), At(2026, 12, 1), ResolutionClass.Hour)], [ Run(At(2025, 1, 1), At(2025, 3, 10, 14), ResolutionClass.Hour), Gap(At(2025, 3, 10, 14), At(2025, 3, 13, 9), CoverageGapReason.SampleGap), Run(At(2025, 3, 13, 9), At(2026, 1, 1), ResolutionClass.Hour), ]); Assert.False(result.IsContiguous); Assert.Equal(2, result.Pieces.Count); Assert.Equal(At(2026, 3, 10, 14), result.Pieces[0].Current.To); Assert.Equal(At(2026, 3, 13, 9), result.Pieces[1].Current.From); // The gap's row is booked at its end, 13 March 09:00: the second piece starts just past it, and the // first ends just past the last sample before the outage. Assert.Equal(At(2025, 3, 10, 14) + Past, result.Pieces[0].Comparison.To); Assert.Equal(At(2025, 3, 13, 9) + Past, result.Pieces[1].Comparison.From); // The spans still run from the first to the last matched instant. Assert.Equal(At(2026, 1, 1), result.Current!.From); Assert.Equal(Now, result.Current.To); } // ---- Opening balances (A-01) ---------------------------------------------------------------------- [Fact] public void An_opening_balance_at_the_start_of_the_current_data_stays_out_of_the_match() { // D-14: the first reading's row holds consumption of unknown extent. var firstReading = At(2026, 2, 3, 11); var result = MatchYearToDate( Now, [Run(firstReading, At(2026, 12, 1), ResolutionClass.Hour)], [Run(At(2025, 1, 1), At(2026, 1, 1), ResolutionClass.Hour)], currentOpeningBalances: [firstReading]); Assert.Equal(firstReading + Past, result.Current!.From); Assert.Equal(new DateOnly(2026, 2, 3), result.Current.FirstDay); Assert.Equal(At(2025, 2, 3, 11), result.Comparison!.From); } [Fact] public void An_opening_balance_read_at_midnight_stays_out_of_a_match_starting_there() { // m2 #2: a first reading at 00:00 describes no time before it, so its row stays on the midnight // (D-11 only moves rows that close an interval) and a range starting there must step past it. var firstReading = At(2026, 2, 3); var result = MatchYearToDate( Now, [Run(firstReading, At(2026, 12, 1), ResolutionClass.Hour)], [Run(At(2025, 1, 1), At(2026, 1, 1), ResolutionClass.Hour)], currentOpeningBalances: [firstReading]); Assert.Equal(firstReading + Past, result.Current!.From); Assert.Equal(new DateOnly(2026, 2, 3), result.Current.FirstDay); Assert.Equal(At(2025, 2, 3), result.Comparison!.From); } [Fact] public void An_opening_balance_in_the_comparison_year_moves_the_start_of_both_sides() { var firstReading = At(2025, 4, 7, 15); var result = MatchYearToDate( Now, [Run(At(2025, 12, 1), At(2026, 12, 1), ResolutionClass.Hour)], [Run(firstReading, At(2026, 1, 1), ResolutionClass.Hour)], comparisonOpeningBalances: [firstReading]); Assert.Equal(firstReading + Past, result.Comparison!.From); Assert.Equal(At(2026, 4, 7, 15), result.Current!.From); } // ---- Not comparable --------------------------------------------------------------------------------- [Fact] public void No_coverage_in_the_comparison_period_is_not_comparable() { var result = MatchYearToDate( Now, [Run(At(2026, 1, 1), At(2026, 12, 1), ResolutionClass.Hour)], [Run(At(2025, 10, 1), At(2026, 1, 1), ResolutionClass.Hour)]); Assert.False(result.IsComparable); Assert.Same(MatchedCoverageResult.NotComparable, result); Assert.Null(result.Current); Assert.Null(result.Comparison); } [Fact] public void No_coverage_in_the_current_period_is_not_comparable() { var result = MatchYearToDate(Now, [], [MonthLabels(2025, 1, 2026, 1)]); Assert.False(result.IsComparable); } [Fact] public void An_overlap_shorter_than_the_monthly_side_can_resolve_is_not_comparable() { // Hourly data for 1-20 January against a year of month rows: January cannot be cut on the 20th. var now = At(2026, 1, 20, 12); var result = MatchYearToDate(now, [Run(At(2026, 1, 1), At(2026, 2, 1), ResolutionClass.Hour)], [MonthLabels(2025, 1, 2026, 1)]); Assert.False(result.IsComparable); } // ---- Calendar shifts ------------------------------------------------------------------------------ [Fact] public void A_previous_period_shifted_by_days_is_trimmed_on_the_side_whose_data_is_coarse() { // 10-19 September against 31 August - 9 September, with the earlier stretch read once a day at noon. var now = At(2026, 9, 19, 10); var current = Period(PeriodPreset.Custom, new DateOnly(2026, 9, 10), new DateOnly(2026, 9, 19), now); var result = MatchedCoverage.Match( MatchSide.Of(current.From, current.To, now, Berlin, [Run(At(2026, 9, 1), At(2026, 10, 1), ResolutionClass.Hour)]), MatchSide.Of(At(2026, 8, 31), ShiftDays(now, -10), now, Berlin, [Run(At(2026, 8, 30, 12), At(2026, 9, 12, 12), ResolutionClass.Day, divided: true)]), t => ShiftDays(t, -10)); // The hourly side needs no trimming; the daily side ends its match at local midnight on 9 September. Assert.Equal(At(2026, 9, 10), result.Current!.From); Assert.Equal(At(2026, 9, 19), result.Current.To); Assert.Equal(At(2026, 8, 31), result.Comparison!.From); Assert.Equal(At(2026, 9, 9), result.Comparison.To); } [Fact] public void A_match_across_the_spring_DST_change_lines_up_on_local_wall_time() { // DST began on 30 March 2025 and 29 March 2026; the matched ranges still start and end at local midnights. var now = At(2026, 4, 15, 12); var result = MatchYearToDate( now, [Run(At(2026, 3, 1), At(2026, 4, 16, 6), ResolutionClass.Day, divided: true)], [Run(At(2025, 1, 1), At(2026, 1, 1), ResolutionClass.Hour)]); Assert.Equal(At(2026, 3, 1), result.Current!.From); Assert.Equal(At(2026, 4, 15), result.Current.To); Assert.Equal(At(2025, 3, 1), result.Comparison!.From); Assert.Equal(At(2025, 4, 15), result.Comparison.To); Assert.Equal(new DateOnly(2025, 4, 14), result.Comparison.LastDay); } [Fact] public void A_match_on_the_long_autumn_day_cuts_both_years_at_the_same_wall_time() { // 25 October 2026 has 25 hours in Berlin; on 25 October 2025 the clocks had not gone back yet. var now = At(2026, 10, 25, 12); var hourly = MatchYearToDate( now, [Run(At(2025, 12, 1), At(2026, 12, 1), ResolutionClass.Hour)], [Run(At(2024, 12, 1), At(2025, 12, 1), ResolutionClass.Hour)]); var daily = MatchYearToDate( now, [Run(At(2025, 12, 31, 6), At(2026, 12, 1, 6), ResolutionClass.Day, divided: true)], [Run(At(2024, 12, 1), At(2025, 12, 1), ResolutionClass.Hour)]); Assert.Equal(now, hourly.Current!.To); Assert.Equal(At(2025, 10, 25, 12), hourly.Comparison!.To); Assert.Equal(TimeSpan.FromHours(1), (hourly.Current.To - hourly.Current.From) - (hourly.Comparison.To - hourly.Comparison.From)); Assert.Equal(At(2026, 10, 25), daily.Current!.To); Assert.Equal(At(2025, 10, 25), daily.Comparison!.To); Assert.Equal(new DateOnly(2025, 10, 24), daily.Comparison.LastDay); } [Fact] public void March_to_date_on_the_30th_matches_all_of_February_because_the_29th_to_31st_map_onto_its_end() { // m2 #11: shifting by a month clamps 29-31 March to the end of February (D-06), so the shift is not // one-to-one there: every instant from 29 March 00:00 maps to 1 March 00:00. var now = At(2026, 3, 30, 10); var current = Period(PeriodPreset.MonthToDate, new DateOnly(2026, 3, 1), new DateOnly(2026, 3, 30), now); var result = MatchedCoverage.Match( MatchSide.Of(current.From, current.To, now, Berlin, [Run(At(2026, 1, 1), At(2026, 5, 1), ResolutionClass.Hour)]), MatchSide.Of(At(2026, 2, 1), At(2026, 3, 1), now, Berlin, [Run(At(2026, 1, 1), At(2026, 3, 1), ResolutionClass.Hour)]), t => ShiftMonths(t, -1)); Assert.Equal(At(2026, 3, 1), ShiftMonths(At(2026, 3, 29), -1)); Assert.Equal(At(2026, 3, 1), ShiftMonths(At(2026, 3, 31, 23), -1)); Assert.Equal(new MatchedRange(At(2026, 3, 1), now, new DateOnly(2026, 3, 1), new DateOnly(2026, 3, 30)), result.Current); Assert.Equal(new MatchedRange(At(2026, 2, 1), At(2026, 3, 1), new DateOnly(2026, 2, 1), new DateOnly(2026, 2, 28)), result.Comparison); } [Fact] public void Year_to_date_on_29_February_matches_up_to_the_end_of_February_the_year_before() { // m2 #11: 29 February 2024 has no counterpart in 2023; the day maps onto 1 March 2023 00:00. var now = At(2024, 2, 29, 10); var result = MatchYearToDate( now, [Run(At(2023, 12, 31, 6), At(2024, 3, 10, 6), ResolutionClass.Day, divided: true)], [Run(At(2022, 12, 1), At(2023, 12, 1), ResolutionClass.Hour)]); Assert.Equal(new MatchedRange(At(2024, 1, 1), At(2024, 2, 29), new DateOnly(2024, 1, 1), new DateOnly(2024, 2, 28)), result.Current); Assert.Equal(new MatchedRange(At(2023, 1, 1), At(2023, 3, 1), new DateOnly(2023, 1, 1), new DateOnly(2023, 2, 28)), result.Comparison); } [Fact] public void In_New_York_monthly_labels_match_on_New_York_month_starts() { var now = At(NewYork, 2026, 9, 19, 10); var from = At(NewYork, 2026, 1, 1); DateTimeOffset LastYear(DateTimeOffset t) => Shift(t, NewYork, months: -12); var result = MatchedCoverage.Match( MatchSide.Of(from, now, now, NewYork, [MonthLabels(NewYork, 2026, 1, 2026, 7)]), MatchSide.Of(LastYear(from), LastYear(now), now, NewYork, [MonthLabels(NewYork, 2025, 1, 2026, 1)]), LastYear); Assert.Equal(new MatchedRange(At(NewYork, 2026, 1, 1), At(NewYork, 2026, 7, 1), new DateOnly(2026, 1, 1), new DateOnly(2026, 6, 30)), result.Current); Assert.Equal(new DateTimeOffset(2026, 7, 1, 4, 0, 0, TimeSpan.Zero), result.Current!.To); Assert.Equal(new MatchedRange(At(NewYork, 2025, 1, 1), At(NewYork, 2025, 7, 1), new DateOnly(2025, 1, 1), new DateOnly(2025, 6, 30)), result.Comparison); } }