using MeterVault.Core.Domain; using MeterVault.Core.Normalization; using static MeterVault.Core.Tests.TestData; namespace MeterVault.Core.Tests; /// /// Consumption between two readings belongs to the months it accrued in. What these pin down: an /// interval crossing a local month boundary is divided by elapsed time; an imported monthly table /// still produces exactly one unchanged row per row, because that is what reconciles against the /// reference spreadsheet (SDD §13). /// public sealed class GapAttributionTests { private static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin"); private readonly INormalizationEngine _engine = NormalizationEngine.CreateDefault(); private static DateTimeOffset BerlinTime(int year, int month, int day, int hour = 0, int minute = 0) => new(new DateTime(year, month, day, hour, minute, 0), Berlin.GetUtcOffset(new DateTime(year, month, day, hour, minute, 0))); private static Reading Manual(DateTimeOffset time, double value) => new() { MeterId = 1, Time = time, Value = value, Quality = ReadingQuality.Manual }; private static string LocalMonth(DateTimeOffset instant) => TimeZoneInfo.ConvertTime(instant, Berlin).ToString("yyyy-MM"); [Fact] public void Readings_from_1_August_to_16_September_are_divided_between_the_two_months() { // The reported case: nothing read in between, so September used to carry all 46 days. var august1 = BerlinTime(2026, 8, 1, 9, 0); var september16 = BerlinTime(2026, 9, 16, 18, 0); var ctx = new NormalizationContext { Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "m3" }, Readings = [Manual(august1, 700), Manual(september16, 746)], TimeZone = Berlin, }; var result = _engine.Normalize(ctx).ToList(); var interval = result.Skip(1).ToList(); Assert.Equal(2, interval.Count); Assert.Equal(["2026-08", "2026-09"], interval.Select(c => LocalMonth(c.Time))); Assert.Equal(46, interval.Sum(c => c.Amount), 9); var total = september16 - august1; var inAugust = BerlinTime(2026, 9, 1) - august1; Assert.Equal(46 * (inAugust / total), interval[0].Amount, 9); Assert.All(interval, c => Assert.Equal(ReadingQuality.Estimated, c.Quality)); // September's share still sits on the reading that closed the interval. Assert.Equal(september16, interval[1].Time); } [Fact] public void An_interval_inside_one_month_is_one_row_at_its_reading_with_its_own_quality() { var ctx = new NormalizationContext { Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "m3" }, Readings = [Manual(BerlinTime(2026, 9, 1, 8), 700), Manual(BerlinTime(2026, 9, 16, 18), 710)], TimeZone = Berlin, }; var result = _engine.Normalize(ctx).ToList(); Assert.Equal(2, result.Count); Assert.Equal(BerlinTime(2026, 9, 16, 18), result[1].Time); Assert.Equal(ReadingQuality.Manual, result[1].Quality); } [Fact] public void Months_are_local_so_a_reading_just_after_local_midnight_does_not_take_the_month_with_it() { // 1 September 00:30 in Berlin is still 31 August in UTC. Split at UTC boundaries this interval // would be one row stamped in local September carrying all of August. var ctx = new NormalizationContext { Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "m3" }, Readings = [Manual(BerlinTime(2026, 8, 1), 700), Manual(BerlinTime(2026, 9, 1, 0, 30), 731)], TimeZone = Berlin, }; var interval = _engine.Normalize(ctx).Skip(1).ToList(); Assert.Equal(["2026-08", "2026-09"], interval.Select(c => LocalMonth(c.Time))); Assert.True(interval[0].Amount > 30.9, $"August got only {interval[0].Amount}"); } [Fact] public void A_reading_exactly_at_local_midnight_on_the_first_books_wholly_to_the_month_before() { var ctx = new NormalizationContext { Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "m3" }, Readings = [Manual(BerlinTime(2026, 8, 1), 700), Manual(BerlinTime(2026, 9, 1), 731)], TimeZone = Berlin, }; var result = _engine.Normalize(ctx).ToList(); Assert.Equal(2, result.Count); Assert.Equal("2026-08", LocalMonth(result[1].Time)); Assert.Equal(31, result[1].Amount, 9); Assert.Equal(ReadingQuality.Manual, result[1].Quality); } [Fact] public void An_ordinary_imported_monthly_series_produces_one_unchanged_row_per_reading() { // The reference-data shape: rows stamped 00:00 UTC on the 1st. It must not gain rows, move // them, or lose its quality — in UTC or in the instance timezone. foreach (var zone in new[] { TimeZoneInfo.Utc, Berlin }) { var ctx = new NormalizationContext { Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "kWh" }, Readings = [ Reading(1, Month(2022, 9), 0), Reading(1, Month(2022, 10), 411), Reading(1, Month(2022, 11), 1153), Reading(1, Month(2022, 12), 1968), ], TimeZone = zone, }; var result = _engine.Normalize(ctx).ToList(); Assert.Equal(4, result.Count); Assert.DoesNotContain(result, c => c.Quality == ReadingQuality.Estimated); Assert.Equal([0, 411, 742, 815], result.Select(c => c.Amount).ToArray()); Assert.Equal([Month(2022, 9), Month(2022, 10), Month(2022, 11), Month(2022, 12)], result.Select(c => c.Time)); } } [Fact] public void A_month_label_is_an_imported_row_the_importer_flagged_as_a_month() { Assert.True(GapAttribution.IsMonthLabel(Reading(1, Month(2026, 5), 1))); Assert.False(GapAttribution.IsMonthLabel(DayReading(1, Month(2026, 5), 1))); // Correcting a typo in an imported month row by hand does not turn it into a reading on the 1st. Assert.True(GapAttribution.IsMonthLabel(new Reading { Time = Month(2026, 5), Quality = ReadingQuality.Manual, Flags = ReadingFlags.MonthLabel, })); // "Mai 2026" is the register at the end of May. Assert.Equal(BerlinTime(2026, 6, 1), GapAttribution.EffectiveTime(Reading(1, Month(2026, 5), 1), Berlin)); } [Fact] public void A_day_dated_import_on_the_first_is_an_instant_not_a_month() { // A meter log imported with "15.07.2026" and "01.08.2026", then read by hand on 16 September. The // row on the 1st is stamped at the same midnight as a month label but means that day. var ctx = new NormalizationContext { Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "m3" }, Readings = [ DayReading(1, new DateTimeOffset(2026, 7, 15, 0, 0, 0, TimeSpan.Zero), 600), DayReading(1, Month(2026, 8), 700), Manual(BerlinTime(2026, 9, 16, 18), 746), ], TimeZone = Berlin, }; var byMonth = _engine.Normalize(ctx).Skip(1) .GroupBy(c => LocalMonth(c.Time)) .ToDictionary(g => g.Key, g => g.Sum(c => c.Amount)); // 15 July to 1 August 00:00 UTC ends at 02:00 local on the 1st: two hours of the 100 in August, not 65. Assert.True(byMonth["2026-07"] > 99.4, $"July got only {byMonth["2026-07"]}"); // ...and the six weeks after it are shared between August and September. Assert.True(byMonth["2026-08"] is > 30 and < 31, $"August got {byMonth["2026-08"]}"); Assert.True(byMonth["2026-09"] is > 15 and < 16, $"September got {byMonth["2026-09"]}"); Assert.Equal(146, byMonth.Values.Sum(), 6); } [Fact] public void A_monthly_table_imported_after_live_readings_does_not_count_its_month_twice() { // Read by hand on 1 August and 16 September; later the sheet rows "Juli" and "August" are imported. // "August 2026" = 731 is the register at the end of August, so it belongs after the reading taken // on 1 August even though it is stamped at midnight that day. var ctx = new NormalizationContext { Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "m3" }, Readings = [ Reading(1, Month(2026, 7), 699), Reading(1, Month(2026, 8), 731), Manual(BerlinTime(2026, 8, 1, 9), 700), Manual(BerlinTime(2026, 9, 16, 18), 746), ], TimeZone = Berlin, }; var result = _engine.Normalize(ctx).ToList(); var byMonth = result.GroupBy(c => LocalMonth(c.Time)).ToDictionary(g => g.Key, g => g.Sum(c => c.Amount)); Assert.Equal(746, result.Sum(c => c.Amount), 6); Assert.Equal(32, byMonth["2026-08"], 6); // 1 on the morning of the 1st, 31 through the month Assert.Equal(15, byMonth["2026-09"], 6); Assert.DoesNotContain(result, c => c.Amount < 0); } [Fact] public void Behind_utc_imported_months_stay_in_their_own_local_month() { // In New York, 00:00 UTC on the 1st is still the evening before. The water sheet rows, including // the swap in March, must each land in the month they name: none doubled, none empty. var newYork = TimeZoneInfo.FindSystemTimeZoneById("America/New_York"); var ctx = new NormalizationContext { Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "m³" }, Readings = [ Reading(1, Month(2022, 12), 834), Reading(1, Month(2023, 1), 848), Reading(1, Month(2023, 2), 861), Reading(1, Month(2023, 3), 2), Reading(1, Month(2023, 4), 15), ], Events = [Swap(1, Month(2023, 3), prevValue: 861, newValue: 2, amount: 12)], TimeZone = newYork, }; var result = _engine.Normalize(ctx).ToList(); string NewYorkMonth(DateTimeOffset t) => TimeZoneInfo.ConvertTime(t, newYork).ToString("yyyy-MM"); Assert.Equal( ["2022-12", "2023-01", "2023-02", "2023-03", "2023-04"], result.Select(c => NewYorkMonth(c.Time))); Assert.Equal([834, 14, 13, 12, 13], result.Select(c => c.Amount).ToArray()); } [Fact] public void Behind_utc_a_live_reading_next_to_imported_months_never_duplicates_a_stored_row() { // Imported "Juni" and "Juli", and HA polled at exactly local midnight on 1 July, the instant the // July row is stamped at. Two rows on one key used to fail the whole recompute; they now add up. var newYork = TimeZoneInfo.FindSystemTimeZoneById("America/New_York"); var julyMidnight = new DateTimeOffset(2026, 7, 1, 4, 0, 0, TimeSpan.Zero); // 00:00 EDT var ctx = new NormalizationContext { Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "kWh" }, Readings = [ Reading(1, Month(2026, 6), 1000), Reading(1, Month(2026, 7), 1300), new Reading { MeterId = 1, Time = julyMidnight, Value = 1005, Quality = ReadingQuality.Measured }, new Reading { MeterId = 1, Time = new DateTimeOffset(2026, 8, 10, 16, 0, 0, TimeSpan.Zero), Value = 1420, Quality = ReadingQuality.Measured }, ], TimeZone = newYork, }; var result = _engine.Normalize(ctx).ToList(); var july = result.Where(c => TimeZoneInfo.ConvertTime(c.Time, newYork).ToString("yyyy-MM") == "2026-07").ToList(); Assert.Equal(result.Count, result.Select(c => (c.Time, c.Kind)).Distinct().Count()); Assert.Equal(1420, result.Sum(c => c.Amount), 6); Assert.Equal(300, july.Sum(c => c.Amount), 6); } [Fact] public void A_swap_detected_in_a_monthly_table_applies_to_the_first_live_reading_of_that_month() { // HA already read the new water meter on 10 and 25 March; later the sheet is imported and the // importer detects the swap at the "Maerz" row. The old tail must not be measured from 1.5. var ctx = new NormalizationContext { Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "m³" }, Readings = [ Reading(1, Month(2023, 1), 848), Reading(1, Month(2023, 2), 861), Manual(BerlinTime(2023, 3, 10, 12), 0.5), Manual(BerlinTime(2023, 3, 25, 12), 1.5), Reading(1, Month(2023, 3), 2), Reading(1, Month(2023, 4), 15), ], Events = [Swap(1, Month(2023, 3), prevValue: 861, newValue: 2)], TimeZone = Berlin, }; var byMonth = _engine.Normalize(ctx).GroupBy(c => LocalMonth(c.Time)).ToDictionary(g => g.Key, g => g.Sum(c => c.Amount)); Assert.Equal(1.5, byMonth["2023-03"], 6); Assert.Equal(13, byMonth["2023-04"], 6); } [Fact] public void A_reset_at_a_month_rows_stamp_applies_where_the_month_begins() { // A reset posted at 00:00 UTC on 1 August, with the August row and two live August readings. var ctx = new NormalizationContext { Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "kWh" }, Readings = [ Reading(1, Month(2026, 7), 699), Manual(BerlinTime(2026, 8, 5, 12), 5), Manual(BerlinTime(2026, 8, 20, 12), 20), Reading(1, Month(2026, 8), 31), ], Events = [Reset(1, Month(2026, 8), newValue: 0, prevValue: 700)], TimeZone = Berlin, }; var byMonth = _engine.Normalize(ctx).GroupBy(c => LocalMonth(c.Time)).ToDictionary(g => g.Key, g => g.Sum(c => c.Amount)); Assert.Equal(32, byMonth["2026-08"], 6); } [Fact] public void Burner_hours_follow_the_same_month_rows_as_registers() { // "Juli" = 960 h and "August" = 1000 h imported, plus live readings on 15 August and 16 September. var ctx = new NormalizationContext { Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.RuntimeCounter, Unit = "h" }, Readings = [ Reading(1, Month(2026, 7), 960), Reading(1, Month(2026, 8), 1000), Manual(BerlinTime(2026, 8, 15, 12), 990), Manual(BerlinTime(2026, 9, 16, 12), 1020), ], TimeZone = Berlin, }; var result = _engine.Normalize(ctx).ToList(); Assert.Equal(1020, result.Sum(c => c.Amount), 6); Assert.DoesNotContain(result, c => c.Amount < 0); } [Fact] public void Behind_utc_month_rows_of_deltas_and_hours_stay_in_their_own_month() { var newYork = TimeZoneInfo.FindSystemTimeZoneById("America/New_York"); string NewYorkMonth(DateTimeOffset t) => TimeZoneInfo.ConvertTime(t, newYork).ToString("yyyy-MM"); foreach (var mode in new[] { MeterMode.DirectDelta, MeterMode.RuntimeCounter }) { var ctx = new NormalizationContext { Meter = new MeterConfig { MeterId = 1, Mode = mode, Unit = "h" }, Readings = [Reading(1, Month(2026, 7), 960), Reading(1, Month(2026, 8), 1000)], TimeZone = newYork, }; var result = _engine.Normalize(ctx).ToList(); Assert.Equal(["2026-07", "2026-08"], result.Select(c => NewYorkMonth(c.Time))); } } [Theory] [InlineData("America/Asuncion", 2023, 8)] [InlineData("America/Asuncion", 2017, 8)] [InlineData("Europe/Volgograd", 2018, 11)] [InlineData("Pacific/Apia", 2011, 11)] [InlineData("Asia/Amman", 2005, 2)] [InlineData("Asia/Damascus", 2011, 2)] public void Months_at_a_transition_the_zone_data_contradicts_still_divide_and_finish(string zoneId, int year, int month) { // Zone data that reports a midnight's offset from after the change used to stall the month walk // forever — an import or the startup rebuild that never returned. if (!TimeZoneInfo.TryFindSystemTimeZoneById(zoneId, out var zone)) { return; } var from = new DateTimeOffset(year, month, 10, 12, 0, 0, TimeSpan.Zero); var to = from.AddMonths(3); var work = Task.Run(() => GapAttribution.Attribute(from, to, to, 90, zone)); Assert.True(work.Wait(TimeSpan.FromSeconds(5)), "Attribution did not finish."); Assert.Equal(90, work.Result.Sum(s => s.Amount), 6); Assert.All(work.Result, s => Assert.True(s.Time > from && s.Time <= to, $"{s.Time:O} lies outside the interval")); } [Fact] public void Where_clocks_fall_back_at_midnight_the_month_starts_at_the_first_midnight() { // Havana leaves daylight time at 01:00 on 1 November 2026, so 00:00 happens twice. October ends at // the first one; stamping October's share after it would file it under November. var havana = TimeZoneInfo.FindSystemTimeZoneById("America/Havana"); var from = new DateTimeOffset(2026, 10, 20, 16, 0, 0, TimeSpan.Zero); var to = new DateTimeOffset(2026, 11, 20, 17, 0, 0, TimeSpan.Zero); var segments = GapAttribution.Attribute(from, to, to, 31, havana); Assert.Equal(2, segments.Count); Assert.True(segments[0].Time < new DateTimeOffset(2026, 11, 1, 4, 0, 0, TimeSpan.Zero), $"October's share is stamped {segments[0].Time:O}"); } [Fact] public void A_live_reading_after_the_last_imported_row_counts_from_the_end_of_that_rows_month() { // Solar 1 read monthly up to "Mai 2026", then one live reading on 18 July. May's use is already // in the May row; the 714.5 kWh since belongs to June and July — not a third of it back in May. var july18 = new DateTimeOffset(2026, 7, 18, 15, 33, 0, TimeSpan.Zero); var ctx = new NormalizationContext { Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.GenerationCounter, Unit = "kWh" }, Readings = [ Reading(1, Month(2026, 4), 10308), Reading(1, Month(2026, 5), 10731), new Reading { MeterId = 1, Time = july18, Value = 11445.5, Quality = ReadingQuality.Measured }, ], TimeZone = Berlin, }; var result = _engine.Normalize(ctx).ToList(); var gap = result.Skip(2).ToList(); Assert.Equal(["2026-06", "2026-07"], gap.Select(c => LocalMonth(c.Time))); Assert.Equal(714.5, gap.Sum(c => c.Amount), 6); var june = BerlinTime(2026, 7, 1) - BerlinTime(2026, 6, 1); Assert.Equal(714.5 * (june / (july18 - BerlinTime(2026, 6, 1))), gap[0].Amount, 6); Assert.Equal(11445.5, result.Sum(c => c.Amount), 6); } [Fact] public void A_skipped_month_in_an_imported_table_is_shared_between_the_months_it_covers() { // "Januar" then "April": the 900 accrued over February, March and April. var ctx = new NormalizationContext { Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "kWh" }, Readings = [Reading(1, Month(2023, 1), 1000), Reading(1, Month(2023, 4), 1900)], }; var spread = _engine.Normalize(ctx).Skip(1).ToList(); Assert.Equal(["2023-02", "2023-03", "2023-04"], spread.Select(c => c.Time.UtcDateTime.ToString("yyyy-MM"))); Assert.Equal(900, spread.Sum(c => c.Amount), 6); Assert.Equal(900 * (28d / 89d), spread[0].Amount, 6); // Feb 28 + Mar 31 + Apr 30 days Assert.All(spread, c => Assert.Equal(ReadingQuality.Estimated, c.Quality)); // April's share keeps the April row's own timestamp. Assert.Equal(Month(2023, 4), spread[^1].Time); } [Fact] public void Attribution_preserves_the_total_and_stamps_every_share_inside_its_month() { var from = BerlinTime(2026, 5, 20, 7); var to = BerlinTime(2026, 8, 3, 21); var segments = GapAttribution.Attribute(from, to, to, 1000.1, Berlin); Assert.Equal(["2026-05", "2026-06", "2026-07", "2026-08"], segments.Select(s => LocalMonth(s.Time))); Assert.Equal(1000.1, segments.Sum(s => s.Amount), 9); Assert.All(segments, s => Assert.True(s.Time > from && s.Time <= to)); Assert.Equal(segments.Count, segments.Select(s => s.Time).Distinct().Count()); } [Fact] public void An_unchanged_register_across_months_does_not_fan_out_into_empty_rows() { var ctx = new NormalizationContext { Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "kWh" }, Readings = [Reading(1, Month(2023, 1), 500), Reading(1, Month(2023, 5), 500)], }; var result = _engine.Normalize(ctx).ToList(); Assert.Equal(2, result.Count); Assert.Equal(0, result[^1].Amount, 6); } [Fact] public void A_rejected_decrease_across_months_stays_a_single_row() { var ctx = new NormalizationContext { Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "kWh" }, Readings = [Reading(1, Month(2023, 1), 900), Reading(1, Month(2023, 5), 100)], }; var result = _engine.Normalize(ctx).ToList(); Assert.Equal(2, result.Count); Assert.Equal(0, result[^1].Amount, 6); Assert.Equal(Month(2023, 5), result[^1].Time); } [Fact] public void A_swap_across_months_keeps_its_explicit_amount_in_one_row() { // Swap amounts are corrections booked at the event (the water …861 → 2 case reconciles to // 12). Apportioning one would silently rewrite a number the operator supplied. var ctx = new NormalizationContext { Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "m³" }, Readings = [ Reading(1, Month(2023, 1), 861), Reading(1, Month(2023, 5), 15), ], Events = [Swap(1, Month(2023, 3), prevValue: 861, newValue: 2, amount: 12)], TimeZone = Berlin, }; var result = _engine.Normalize(ctx).ToList(); Assert.Equal(2, result.Count); Assert.Equal(12, result[^1].Amount, 6); Assert.NotEqual(ReadingQuality.Estimated, result[^1].Quality); } }