using MeterVault.Core.Analysis; using MeterVault.Core.Domain; using MeterVault.Core.Normalization; using MeterVault.Core.Normalization.Normalizers; using static MeterVault.Core.Tests.Analysis.AnalysisTestTime; using static MeterVault.Core.Tests.TestData; namespace MeterVault.Core.Tests.Analysis; /// /// Every normalized row says which stretch of time it accrued over (D-10), and a row that closes at a /// local midnight is booked in the day it closes (D-11). Coverage, rollups and bucket status are built /// on these intervals, so each mode is pinned here — including the first reading, whose start is only /// known from a month label or an install date. /// public sealed class EngineIntervalTests { private readonly INormalizationEngine _engine = NormalizationEngine.CreateDefault(); private static Reading Manual(DateTimeOffset time, double value, int meterId = 1) => new() { MeterId = meterId, Time = time, Value = value, Quality = ReadingQuality.Manual }; private static Reading Measured(DateTimeOffset time, double value, int meterId = 1) => new() { MeterId = meterId, Time = time, Value = value, Quality = ReadingQuality.Measured }; private static NormalizationContext Context( MeterMode mode, TimeZoneInfo zone, IReadOnlyList readings, IReadOnlyList? events = null, DateOnly? installedAt = null) => new() { Meter = new MeterConfig { MeterId = 1, Mode = mode, Unit = "kWh", InstalledAt = installedAt }, Readings = readings, Events = events ?? [], TimeZone = zone, }; private static TimeZoneInfo Zone(string id) => id == "UTC" ? TimeZoneInfo.Utc : TimeZoneInfo.FindSystemTimeZoneById(id); // ---- Registers ------------------------------------------------------------------------------- [Fact] public void Each_register_row_covers_the_time_since_the_previous_reading() { var result = _engine.Normalize(Context(MeterMode.CumulativeCounter, Berlin, [ Manual(InBerlin(2026, 9, 2, 8), 100), Manual(InBerlin(2026, 9, 10, 20), 110), Manual(InBerlin(2026, 9, 20, 6), 125), ])); Assert.Equal(3, result.Count); Assert.Equal([InBerlin(2026, 9, 2, 8), InBerlin(2026, 9, 10, 20)], result.Skip(1).Select(c => c.IntervalStart!.Value)); Assert.Equal([InBerlin(2026, 9, 10, 20), InBerlin(2026, 9, 20, 6)], result.Skip(1).Select(c => c.IntervalEnd!.Value)); Assert.All(result.Skip(1), c => { Assert.False(c.Divided); Assert.False(c.OpeningBalance); Assert.Equal(CoverageGapReason.None, c.Gap); }); } [Fact] public void A_first_reading_without_a_label_or_install_date_is_an_opening_balance_with_an_unknown_start() { var first = Assert.Single(_engine.Normalize(Context(MeterMode.CumulativeCounter, Berlin, [Manual(InBerlin(2026, 9, 2, 8), 300)]))); Assert.True(first.OpeningBalance); Assert.Equal(300, first.Amount); Assert.Equal(InBerlin(2026, 9, 2, 8), first.IntervalStart); Assert.Equal(InBerlin(2026, 9, 2, 8), first.IntervalEnd); Assert.Equal(InBerlin(2026, 9, 2, 8), first.Time); } [Fact] public void A_first_reading_starts_at_the_local_midnight_of_the_install_date() { var first = Assert.Single(_engine.Normalize(Context( MeterMode.CumulativeCounter, Berlin, [Manual(InBerlin(2026, 9, 2, 8), 300)], installedAt: new DateOnly(2026, 8, 15)))); Assert.False(first.OpeningBalance); Assert.False(first.Divided); // the baseline delta is booked at its reading, as it always was Assert.Equal(Utc(2026, 8, 14, 22), first.IntervalStart); // 15 August 00:00 in Berlin Assert.Equal(InBerlin(2026, 9, 2, 8), first.IntervalEnd); Assert.Equal(InBerlin(2026, 9, 2, 8), first.Time); } [Fact] public void An_install_date_after_the_first_reading_says_nothing_about_its_start() { var first = Assert.Single(_engine.Normalize(Context( MeterMode.CumulativeCounter, Berlin, [Manual(InBerlin(2026, 9, 2, 8), 300)], installedAt: new DateOnly(2026, 9, 5)))); Assert.True(first.OpeningBalance); Assert.Equal(first.IntervalEnd, first.IntervalStart); } [Fact] public void A_first_month_row_covers_the_local_month_it_names() { // Zähler Auto's first sheet row, "Mai 2023" = 3755: the whole register booked as May's figure. var first = Assert.Single(_engine.Normalize(Context(MeterMode.CumulativeCounter, Berlin, [Reading(1, Month(2023, 5), 3755)]))); Assert.False(first.OpeningBalance); Assert.Equal(InBerlin(2023, 5, 1), first.IntervalStart); Assert.Equal(InBerlin(2023, 6, 1), first.IntervalEnd); Assert.Equal(Month(2023, 5), first.Time); } [Theory] [InlineData("UTC")] [InlineData("Europe/Berlin")] public void Consecutive_month_rows_keep_their_stamps_and_span_exactly_their_months(string zoneId) { // The golden-fixture shape. A label closes at a local midnight (its month's end) but is a month // figure, not a reading at that instant: it keeps its stamp. var zone = Zone(zoneId); var result = _engine.Normalize(Context(MeterMode.CumulativeCounter, zone, [ Reading(1, Month(2022, 9), 0), Reading(1, Month(2022, 10), 411), Reading(1, Month(2022, 11), 1153), Reading(1, Month(2022, 12), 1968), ])); Assert.Equal([Month(2022, 9), Month(2022, 10), Month(2022, 11), Month(2022, 12)], result.Select(c => c.Time)); Assert.Equal([0, 411, 742, 815], result.Select(c => c.Amount).ToArray()); DateTimeOffset MonthStart(int month) => GapAttribution.LocalMidnight(new DateOnly(2022, month, 1), zone); Assert.Equal([MonthStart(9), MonthStart(10), MonthStart(11), MonthStart(12)], result.Select(c => c.IntervalStart!.Value)); Assert.Equal([MonthStart(10), MonthStart(11), MonthStart(12), GapAttribution.LocalMidnight(new DateOnly(2023, 1, 1), zone)], result.Select(c => c.IntervalEnd!.Value)); Assert.DoesNotContain(result, c => c.Divided || c.OpeningBalance || c.Gap != CoverageGapReason.None); } [Fact] public void An_interval_divided_at_a_month_boundary_gives_each_share_its_own_segment() { var result = _engine.Normalize(Context(MeterMode.CumulativeCounter, Berlin, [ Manual(InBerlin(2026, 8, 1, 9), 700), Manual(InBerlin(2026, 9, 16, 18), 746), ])); var shares = result.Skip(1).ToList(); Assert.Equal(2, shares.Count); Assert.All(shares, c => { Assert.True(c.Divided); Assert.Equal(ReadingQuality.Estimated, c.Quality); }); Assert.Equal(InBerlin(2026, 8, 1, 9), shares[0].IntervalStart); Assert.Equal(InBerlin(2026, 9, 1), shares[0].IntervalEnd); Assert.Equal(InBerlin(2026, 8, 31, 23, 59, 59), shares[0].Time); Assert.Equal(InBerlin(2026, 9, 1), shares[1].IntervalStart); Assert.Equal(InBerlin(2026, 9, 16, 18), shares[1].IntervalEnd); Assert.Equal(InBerlin(2026, 9, 16, 18), shares[1].Time); Assert.Equal(46, shares.Sum(c => c.Amount), 9); } [Fact] public void Attribution_segments_tile_the_interval_at_local_month_starts() { var from = InBerlin(2026, 5, 20, 7); var to = InBerlin(2026, 8, 3, 21); var segments = GapAttribution.Attribute(from, to, to, 1000, Berlin); Assert.Equal([from, InBerlin(2026, 6, 1), InBerlin(2026, 7, 1), InBerlin(2026, 8, 1)], segments.Select(s => s.From)); Assert.Equal([InBerlin(2026, 6, 1), InBerlin(2026, 7, 1), InBerlin(2026, 8, 1), to], segments.Select(s => s.To)); Assert.All(segments, s => Assert.True(s.Time >= s.From && s.Time <= s.To, $"{s.Time:O} lies outside its segment")); } [Fact] public void An_unexplained_decrease_marks_its_interval_as_a_gap() { var result = _engine.Normalize(Context(MeterMode.CumulativeCounter, TimeZoneInfo.Utc, [Reading(1, Month(2023, 1), 100), Reading(1, Month(2023, 2), 60), Reading(1, Month(2023, 3), 70)])); Assert.Equal([CoverageGapReason.None, CoverageGapReason.UnexplainedDecrease, CoverageGapReason.None], result.Select(c => c.Gap)); Assert.Equal(Month(2023, 2), result[1].IntervalStart); Assert.Equal(Month(2023, 3), result[1].IntervalEnd); Assert.Equal(0, result[1].Amount); } [Theory] [InlineData(null, CoverageGapReason.ResetWithoutPrevious)] [InlineData(170d, CoverageGapReason.None)] public void A_reset_is_a_gap_only_when_it_does_not_say_where_the_old_register_stopped(double? prevValue, CoverageGapReason expected) { var result = _engine.Normalize(new NormalizationContext { Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "kWh", InitialBaseline = 90 }, Readings = [Reading(1, Month(2023, 1), 100), Reading(1, Month(2023, 2), 150), Reading(1, Month(2023, 3), 30)], Events = [Reset(1, Month(2023, 3), newValue: 0, prevValue: prevValue)], }); Assert.Equal(expected, result[^1].Gap); Assert.Equal(Month(2023, 3), result[^1].IntervalStart); } [Fact] public void A_swap_with_its_amount_is_covered_time() { // The water register …861 → 2 books 12 m³ across the swap month: no hole. var result = _engine.Normalize(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), Reading(1, Month(2023, 3), 2), Reading(1, Month(2023, 4), 15)], Events = [Swap(1, Month(2023, 3), prevValue: 861, newValue: 2, amount: 12)], }); Assert.DoesNotContain(result, c => c.Gap != CoverageGapReason.None); Assert.Equal(Month(2023, 3), result[2].IntervalStart); Assert.Equal(Month(2023, 4), result[2].IntervalEnd); } [Fact] public void Every_row_names_the_whole_reading_interval_it_came_from() { // A-03: shares are classified and told apart by their source interval, not by their own segment. var result = _engine.Normalize(Context(MeterMode.CumulativeCounter, Berlin, [ Manual(InBerlin(2026, 8, 2, 9), 700), Manual(InBerlin(2026, 8, 10, 9), 720), Manual(InBerlin(2026, 9, 16, 18), 766), ])); var inside = result.Single(c => c.IntervalEnd == InBerlin(2026, 8, 10, 9)); var shares = result.Where(c => c.Divided).ToList(); Assert.Equal((inside.IntervalStart, inside.IntervalEnd), (inside.SourceStart, inside.SourceEnd)); Assert.Equal(2, shares.Count); Assert.All(shares, c => { Assert.Equal(InBerlin(2026, 8, 10, 9), c.SourceStart); Assert.Equal(InBerlin(2026, 9, 16, 18), c.SourceEnd); }); Assert.NotEqual(shares[0].IntervalEnd, shares[1].IntervalEnd); } [Fact] public void A_register_that_stands_still_across_a_month_boundary_is_one_row_exact_in_every_month() { // m7 #2, A-02: nothing moved from 20 June to 20 July. One row, zero, flagged as month-exact, and not // estimated — nothing about it was inferred. var result = _engine.Normalize(Context(MeterMode.CumulativeCounter, Berlin, [Manual(InBerlin(2026, 6, 20, 12), 130), Manual(InBerlin(2026, 7, 20, 12), 130)])); var still = result[^1]; Assert.Equal(2, result.Count); Assert.Equal(0, still.Amount); Assert.True(still.Divided); Assert.Equal(ReadingQuality.Manual, still.Quality); Assert.Equal(InBerlin(2026, 6, 20, 12), still.IntervalStart); Assert.Equal(InBerlin(2026, 7, 20, 12), still.IntervalEnd); } [Fact] public void A_standstill_inside_one_month_or_a_rejected_decrease_is_not_flagged() { var inside = _engine.Normalize(Context(MeterMode.CumulativeCounter, Berlin, [Manual(InBerlin(2026, 6, 2, 12), 130), Manual(InBerlin(2026, 6, 20, 12), 130)])); var decrease = _engine.Normalize(Context(MeterMode.CumulativeCounter, Berlin, [Manual(InBerlin(2026, 6, 20, 12), 130), Manual(InBerlin(2026, 7, 20, 12), 90)])); Assert.False(inside[^1].Divided); Assert.False(decrease[^1].Divided); Assert.Equal(CoverageGapReason.UnexplainedDecrease, decrease[^1].Gap); } [Theory] [InlineData(MeterMode.RuntimeCounter)] [InlineData(MeterMode.DirectDelta)] [InlineData(MeterMode.GenerationCounter)] public void Zero_across_a_month_boundary_is_exact_in_every_month_for_every_register_like_mode(MeterMode mode) { var first = mode == MeterMode.DirectDelta ? 4 : 130; var second = mode == MeterMode.DirectDelta ? 0 : 130; var result = _engine.Normalize(Context(mode, Berlin, [Manual(InBerlin(2026, 6, 20, 12), first), Manual(InBerlin(2026, 7, 20, 12), second)])); Assert.Equal(0, result[^1].Amount); Assert.True(result[^1].Divided); Assert.Equal(ReadingQuality.Manual, result[^1].Quality); } [Fact] public void Burner_hours_that_moved_across_a_month_boundary_stay_undivided() { var result = _engine.Normalize(Context(MeterMode.RuntimeCounter, Berlin, [Manual(InBerlin(2026, 6, 20, 12), 130), Manual(InBerlin(2026, 7, 20, 12), 150)])); Assert.False(result[^1].Divided); Assert.Equal(20, result[^1].Amount); } // ---- Midnight stamps (D-11) ------------------------------------------------------------------ [Theory] [InlineData("Europe/Berlin")] [InlineData("America/New_York")] [InlineData("UTC")] public void A_daily_snapshot_at_local_midnight_is_booked_in_the_day_it_closes(string zoneId) { var zone = Zone(zoneId); var result = _engine.Normalize(Context(MeterMode.CumulativeCounter, zone, [ Measured(Local(zone, 2026, 9, 10), 100), Measured(Local(zone, 2026, 9, 11), 105), Measured(Local(zone, 2026, 9, 12), 112), ])); // The opening balance describes no time before its midnight, so it stays on it. Assert.Equal(Local(zone, 2026, 9, 10), result[0].Time); Assert.Equal([Local(zone, 2026, 9, 10, 23, 59, 59), Local(zone, 2026, 9, 11, 23, 59, 59)], result.Skip(1).Select(c => c.Time)); Assert.Equal([new DateOnly(2026, 9, 10), new DateOnly(2026, 9, 11)], result.Skip(1).Select(c => LocalDate(c.Time, zone))); Assert.Equal([5d, 7d], result.Skip(1).Select(c => c.Amount)); Assert.Equal([Local(zone, 2026, 9, 11), Local(zone, 2026, 9, 12)], result.Skip(1).Select(c => c.IntervalEnd!.Value)); Assert.All(result.Skip(1), c => Assert.Equal(ReadingQuality.Measured, c.Quality)); } [Fact] public void A_daily_snapshot_after_the_long_autumn_day_is_stamped_inside_that_day() { // 25 October 2026 has 25 hours in Berlin. var result = _engine.Normalize(Context(MeterMode.CumulativeCounter, Berlin, [Measured(InBerlin(2026, 10, 25), 100), Measured(InBerlin(2026, 10, 26), 110)])); Assert.Equal(new DateOnly(2026, 10, 25), LocalDate(result[1].Time, Berlin)); Assert.Equal(TimeSpan.FromHours(25), result[1].IntervalEnd - result[1].IntervalStart); } [Theory] [InlineData(MeterMode.CumulativeCounter)] [InlineData(MeterMode.RuntimeCounter)] [InlineData(MeterMode.DirectDelta)] [InlineData(MeterMode.InstantRate)] public void The_midnight_that_ends_a_month_books_its_row_on_that_months_last_day(MeterMode mode) { var result = _engine.Normalize(Context(mode, Berlin, [Manual(InBerlin(2026, 8, 20, 12), 100), Manual(InBerlin(2026, 9, 1), 130)])); var closing = result[^1]; Assert.Equal(InBerlin(2026, 8, 31, 23, 59, 59), closing.Time); Assert.Equal(InBerlin(2026, 8, 20, 12), closing.IntervalStart); Assert.Equal(InBerlin(2026, 9, 1), closing.IntervalEnd); } [Fact] public void A_divided_interval_that_closes_at_a_midnight_stamps_its_last_share_in_the_day_before() { var result = _engine.Normalize(Context(MeterMode.CumulativeCounter, Berlin, [Manual(InBerlin(2026, 8, 20, 12), 100), Manual(InBerlin(2026, 9, 10), 142)])); var shares = result.Skip(1).ToList(); Assert.Equal([InBerlin(2026, 8, 31, 23, 59, 59), InBerlin(2026, 9, 9, 23, 59, 59)], shares.Select(c => c.Time)); Assert.Equal([InBerlin(2026, 9, 1), InBerlin(2026, 9, 10)], shares.Select(c => c.IntervalEnd!.Value)); Assert.All(shares, c => Assert.True(c.Divided)); } [Fact] public void A_day_dated_import_at_utc_midnight_closes_the_day_before_only_where_that_is_local_midnight() { // The importer stamps "20.07.2026" at 00:00 UTC. In a UTC instance that is the midnight that ends // 19 July; in Berlin it is 02:00 on the 20th, an instant inside the day. Reading[] readings = [DayReading(1, Utc(2026, 7, 15), 600), DayReading(1, Utc(2026, 7, 20), 650)]; var inUtc = _engine.Normalize(Context(MeterMode.RuntimeCounter, TimeZoneInfo.Utc, readings)); var inBerlin = _engine.Normalize(Context(MeterMode.RuntimeCounter, Berlin, readings)); Assert.Equal(Utc(2026, 7, 20).AddSeconds(-1), inUtc[^1].Time); Assert.Equal(Utc(2026, 7, 20), inBerlin[^1].Time); Assert.Equal(Utc(2026, 7, 20), inUtc[^1].IntervalEnd); } [Fact] public void Where_midnight_happens_twice_only_the_first_starts_the_day() { // Havana leaves daylight time at 01:00 on 1 November 2026: 00:00 is at 04:00 and again at 05:00 UTC. var havana = TimeZoneInfo.FindSystemTimeZoneById("America/Havana"); Assert.True(GapAttribution.IsLocalMidnight(Utc(2026, 11, 1, 4), havana)); Assert.False(GapAttribution.IsLocalMidnight(Utc(2026, 11, 1, 5), havana)); Assert.True(GapAttribution.IsLocalMonthStart(Utc(2026, 11, 1, 4), havana)); } [Fact] public void A_midnight_stamp_never_leaves_an_interval_shorter_than_a_second() { var end = InBerlin(2026, 9, 1); var start = end.AddMilliseconds(-400); var stamp = GapAttribution.CloseStamp(start, end, Berlin); Assert.True(stamp > start && stamp < end, $"{stamp:O} lies outside the interval"); Assert.Equal(end, GapAttribution.CloseStamp(end, end, Berlin)); Assert.Equal(InBerlin(2026, 9, 1, 0, 0, 1), GapAttribution.CloseStamp(start, InBerlin(2026, 9, 1, 0, 0, 1), Berlin)); } // ---- Runtime --------------------------------------------------------------------------------- [Fact] public void Burner_rows_run_from_the_previous_reading_and_a_long_silence_stays_one_interval() { // The seeded burner: 0 h on 18.10.2010, then nothing until 7758 h on 14.10.2022. var result = _engine.Normalize(Context(MeterMode.RuntimeCounter, Berlin, [ DayReading(1, Utc(2010, 10, 18), 0), DayReading(1, Utc(2022, 10, 14), 7758), DayReading(1, Utc(2022, 11, 28), 7758), ])); Assert.Equal(3, result.Count); Assert.True(result[0].OpeningBalance); Assert.Equal(Utc(2010, 10, 18), result[1].IntervalStart); Assert.Equal(Utc(2022, 10, 14), result[1].IntervalEnd); Assert.Equal(7758, result[1].Amount); Assert.Equal(Utc(2022, 10, 14), result[1].Time); Assert.Equal(Utc(2022, 10, 14), result[2].IntervalStart); // The twelve years of hours are not divided; the burner that then stood still across 1 November is // exactly zero in October and November alike (A-02). Assert.False(result[1].Divided); Assert.True(result[2].Divided); Assert.Equal(0, result[2].Amount); } [Fact] public void Burner_hours_that_go_backwards_or_reset_blind_are_gaps() { var result = _engine.Normalize(new NormalizationContext { Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.RuntimeCounter, Unit = "h" }, Readings = [Reading(1, Month(2023, 1), 100), Reading(1, Month(2023, 2), 40), Reading(1, Month(2023, 3), 50), Reading(1, Month(2023, 4), 10)], Events = [Reset(1, Month(2023, 4), newValue: 0)], }); Assert.Equal( [CoverageGapReason.None, CoverageGapReason.UnexplainedDecrease, CoverageGapReason.None, CoverageGapReason.ResetWithoutPrevious], result.Select(c => c.Gap)); Assert.Equal([100d, 0d, 10d, 10d], result.Select(c => c.Amount)); } [Fact] public void A_first_burner_reading_covers_its_label_month_or_starts_at_the_install_date() { var label = Assert.Single(_engine.Normalize(Context(MeterMode.RuntimeCounter, Berlin, [Reading(1, Month(2023, 1), 7952)]))); var installed = Assert.Single(_engine.Normalize(Context( MeterMode.RuntimeCounter, Berlin, [Manual(InBerlin(2023, 1, 20, 10), 12)], installedAt: new DateOnly(2023, 1, 2)))); Assert.Equal(InBerlin(2023, 1, 1), label.IntervalStart); Assert.Equal(InBerlin(2023, 2, 1), label.IntervalEnd); Assert.False(label.OpeningBalance); Assert.Equal(InBerlin(2023, 1, 2), installed.IntervalStart); Assert.False(installed.OpeningBalance); } // ---- Tank ------------------------------------------------------------------------------------ [Fact] public void A_tank_row_covers_the_time_between_two_dipsticks_and_early_deliveries_cover_nothing() { var result = _engine.Normalize(new NormalizationContext { Meter = new MeterConfig { MeterId = 30, Mode = MeterMode.ConsumableBalance, Unit = "L", Tank = new TankConfig { Capacity = 7000, Calibration = new CalibrationCurve(7000d / 150d) }, }, Events = [ Delivery(30, Utc(2020, 9, 7), 3500), TankLevelCm(30, Utc(2022, 9, 8), 35), TankLevelCm(30, Utc(2022, 10, 14), 34), Delivery(30, Utc(2022, 12, 5), 3000), TankLevelCm(30, Utc(2022, 12, 5), 85), ], TimeZone = Berlin, }); Assert.Equal(2, result.Count); Assert.Equal([Utc(2022, 9, 8), Utc(2022, 10, 14)], result.Select(c => c.IntervalStart!.Value)); Assert.Equal([Utc(2022, 10, 14), Utc(2022, 12, 5)], result.Select(c => c.IntervalEnd!.Value)); Assert.Equal([Utc(2022, 10, 14), Utc(2022, 12, 5)], result.Select(c => c.Time)); Assert.DoesNotContain(result, c => c.OpeningBalance || c.Divided || c.Gap != CoverageGapReason.None); } [Fact] public void A_dipstick_at_local_midnight_is_booked_in_the_day_it_closes() { var result = _engine.Normalize(new NormalizationContext { Meter = new MeterConfig { MeterId = 30, Mode = MeterMode.ConsumableBalance, Unit = "L" }, Events = [ new MeterEvent { MeterId = 30, Time = InBerlin(2026, 8, 15, 10), EventType = MeterEventType.TankLevel, Amount = 3000, Unit = "L" }, new MeterEvent { MeterId = 30, Time = InBerlin(2026, 9, 1), EventType = MeterEventType.TankLevel, Amount = 2800, Unit = "L" }, ], TimeZone = Berlin, }); var row = Assert.Single(result); Assert.Equal(InBerlin(2026, 8, 31, 23, 59, 59), row.Time); Assert.Equal(InBerlin(2026, 9, 1), row.IntervalEnd); Assert.Equal(200, row.Amount); } [Fact] public void A_tank_nobody_drew_from_across_a_month_boundary_is_exact_in_every_month_but_a_level_that_rose_is_not() { MeterEvent Level(DateTimeOffset time, double litres) => new() { MeterId = 30, Time = time, EventType = MeterEventType.TankLevel, Amount = litres, Unit = "L" }; var result = _engine.Normalize(new NormalizationContext { Meter = new MeterConfig { MeterId = 30, Mode = MeterMode.ConsumableBalance, Unit = "L" }, Events = [Level(InBerlin(2026, 6, 20, 10), 3000), Level(InBerlin(2026, 7, 20, 10), 3000), Level(InBerlin(2026, 8, 20, 10), 3100)], TimeZone = Berlin, }); Assert.Equal([true, false], result.Select(c => c.Divided)); Assert.Equal([ReadingQuality.Manual, ReadingQuality.Estimated], result.Select(c => c.Quality)); Assert.All(result, c => Assert.Equal(0, c.Amount)); } // ---- Instant rate ---------------------------------------------------------------------------- [Fact] public void A_silence_far_longer_than_the_sensors_rhythm_is_a_sample_gap_that_keeps_its_integral() { // 2 kW sampled every five minutes from 10:00 to 11:00, then nothing until 14:00, then 14:05. 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 result = _engine.Normalize(Context(MeterMode.InstantRate, TimeZoneInfo.Utc, samples)); var gap = Assert.Single(result, c => c.Gap != CoverageGapReason.None); Assert.Equal(CoverageGapReason.SampleGap, gap.Gap); Assert.Equal(ReadingQuality.Estimated, gap.Quality); Assert.Equal(6, gap.Amount, 9); // 2 kW over three hours, still counted Assert.Equal(Utc(2024, 6, 1, 11), gap.IntervalStart); Assert.Equal(Utc(2024, 6, 1, 14), gap.IntervalEnd); Assert.Equal(13, result.Count(c => c.Gap == CoverageGapReason.None && c.Quality == ReadingQuality.Measured)); Assert.Equal(CoverageGapReason.None, result.Single(c => c.IntervalStart == Utc(2024, 6, 1, 14)).Gap); } [Fact] public void Each_sample_interval_is_a_row_interval_and_the_first_sample_only_seeds_the_integral() { var result = _engine.Normalize(Context(MeterMode.InstantRate, TimeZoneInfo.Utc, [Measured(Utc(2024, 6, 1, 10), 0), Measured(Utc(2024, 6, 1, 11), 2), Measured(Utc(2024, 6, 1, 12), 4)])); Assert.Equal([Utc(2024, 6, 1, 10), Utc(2024, 6, 1, 11)], result.Select(c => c.IntervalStart!.Value)); Assert.Equal([Utc(2024, 6, 1, 11), Utc(2024, 6, 1, 12)], result.Select(c => c.IntervalEnd!.Value)); Assert.DoesNotContain(result, c => c.OpeningBalance || c.Gap != CoverageGapReason.None); } [Theory] [InlineData(new[] { 1, 1, 1, 1 }, 61)] // a fast sensor: ten minutes would be too eager, an hourly interval is the floor [InlineData(new[] { 60, 60, 60 }, 600)] // hourly: a gap is longer than ten hours [InlineData(new[] { 5, 5, 5, 180 }, 61)] // the gap itself does not raise the threshold: the median stays 5 [InlineData(new[] { 10, 10, 20, 20 }, 150)] // an even count takes the middle of the two middle steps public void The_sample_gap_threshold_is_ten_median_intervals_but_never_under_an_hourly_interval(int[] stepMinutes, int expectedMinutes) { var time = Utc(2024, 6, 1); var samples = new List { Measured(time, 1) }; foreach (var step in stepMinutes) { time = time.AddMinutes(step); samples.Add(Measured(time, 1)); } var thresholds = InstantRateNormalizer.SampleGapThresholds(samples); Assert.Equal(stepMinutes.Length, thresholds.Length); Assert.All(thresholds, t => Assert.Equal(TimeSpan.FromMinutes(expectedMinutes), t)); } [Fact] public void A_single_sample_has_no_interval_to_judge() { Assert.Empty(InstantRateNormalizer.SampleGapThresholds([Measured(Utc(2024, 6, 1), 1)])); } [Fact] public void Hourly_polls_a_few_seconds_late_after_fast_samples_are_not_gaps() { // m7 #6: ten-second samples for a while, then a poller every hour, five seconds late each time. var samples = Enumerable.Range(0, 360).Select(i => Measured(Utc(2024, 6, 1, 8).AddSeconds(10 * i), 2)).ToList(); var last = samples[^1].Time; samples.AddRange(Enumerable.Range(1, 30).Select(i => Measured(last.AddSeconds(3605 * i), 2))); var result = _engine.Normalize(Context(MeterMode.InstantRate, TimeZoneInfo.Utc, samples)); Assert.DoesNotContain(result, c => c.Gap != CoverageGapReason.None); Assert.Equal(2 * (samples[^1].Time - samples[0].Time).TotalHours, result.Sum(c => c.Amount), 6); } [Fact] public void A_silence_barely_over_an_hour_between_fast_samples_is_within_the_poll_slack() { var samples = Enumerable.Range(0, 30).Select(i => Measured(Utc(2024, 6, 1, 8).AddSeconds(10 * i), 2)).ToList(); var silent = samples[^1].Time; samples.Add(Measured(silent.AddSeconds(3630), 2)); samples.Add(Measured(silent.AddSeconds(3640), 2)); samples.Add(Measured(silent.AddSeconds(3640 + 3662), 2)); samples.AddRange(Enumerable.Range(1, 30).Select(i => Measured(silent.AddSeconds(3640 + 3662 + (10 * i)), 2))); var result = _engine.Normalize(Context(MeterMode.InstantRate, TimeZoneInfo.Utc, samples)); // An hour and thirty seconds is an hourly interval; an hour and a minute and two seconds is not. var gap = Assert.Single(result, c => c.Gap == CoverageGapReason.SampleGap); Assert.Equal(silent.AddSeconds(3640), gap.IntervalStart); } [Fact] public void An_outage_is_judged_by_the_rhythm_around_it_not_by_the_meters_whole_history() { // A year of hourly polls would put the whole-history threshold at ten hours and hide a five-hour // outage of the five-minute sensor that replaced them; the rhythm around the outage finds it. var hourly = Enumerable.Range(0, 200).Select(i => Measured(Utc(2024, 1, 1).AddHours(i), 1)); var fastStart = Utc(2024, 1, 1).AddHours(199); var fast = Enumerable.Range(1, 60).Select(i => Measured(fastStart.AddMinutes(5 * i), 1)).ToList(); var resumed = fast[^1].Time.AddHours(5); var after = Enumerable.Range(0, 60).Select(i => Measured(resumed.AddMinutes(5 * i), 1)); var result = _engine.Normalize(Context(MeterMode.InstantRate, TimeZoneInfo.Utc, [.. hourly, .. fast, .. after])); var gap = Assert.Single(result, c => c.Gap == CoverageGapReason.SampleGap); Assert.Equal(fast[^1].Time, gap.IntervalStart); Assert.Equal(resumed, gap.IntervalEnd); } // ---- Direct delta ---------------------------------------------------------------------------- [Fact] public void Direct_delta_rows_cover_the_time_since_the_previous_report() { var result = _engine.Normalize(Context(MeterMode.DirectDelta, Berlin, [ Manual(InBerlin(2026, 9, 1, 6), 5), Manual(InBerlin(2026, 9, 1, 12), 3), Manual(InBerlin(2026, 9, 2), 4), ])); Assert.True(result[0].OpeningBalance); Assert.Equal(result[0].IntervalEnd, result[0].IntervalStart); Assert.Equal([InBerlin(2026, 9, 1, 6), InBerlin(2026, 9, 1, 12)], result.Skip(1).Select(c => c.IntervalStart!.Value)); Assert.Equal(InBerlin(2026, 9, 1, 23, 59, 59), result[2].Time); Assert.Equal([5d, 3d, 4d], result.Select(c => c.Amount)); } [Fact] public void A_first_direct_delta_is_an_opening_balance_even_when_the_install_date_is_known() { // m7 #1: the first increment covers one reporting step nobody recorded, not the years since the meter // was installed — otherwise 0.05 kWh would claim coverage all the way back to 2020. var first = Assert.Single(_engine.Normalize(Context( MeterMode.DirectDelta, Berlin, [Measured(InBerlin(2026, 9, 19, 10), 0.05)], installedAt: new DateOnly(2020, 1, 1)))); Assert.True(first.OpeningBalance); Assert.Equal(InBerlin(2026, 9, 19, 10), first.IntervalStart); Assert.Equal(first.IntervalEnd, first.IntervalStart); Assert.Empty(MeterVault.Core.Analysis.Coverage.CoverageBuilder.Build([first], Berlin)); } [Fact] public void A_register_still_starts_its_first_reading_at_the_install_date() { var register = Assert.Single(_engine.Normalize(Context( MeterMode.CumulativeCounter, Berlin, [Manual(InBerlin(2026, 9, 1, 6), 5)], installedAt: new DateOnly(2026, 8, 31)))); Assert.False(register.OpeningBalance); Assert.Equal(InBerlin(2026, 8, 31), register.IntervalStart); } [Fact] public void A_direct_delta_month_row_covers_its_month_whatever_came_before_it() { // Behind UTC the month rows are stamped at the local month start, and still cover whole months. var result = _engine.Normalize(Context(MeterMode.DirectDelta, NewYork, [ Manual(Local(NewYork, 2026, 6, 20, 12), 7), Reading(1, Month(2026, 7), 960), Reading(1, Month(2026, 8), 1000), ])); var months = result.Where(c => c.Amount >= 960).ToList(); Assert.Equal([Local(NewYork, 2026, 7, 1), Local(NewYork, 2026, 8, 1)], months.Select(c => c.IntervalStart!.Value)); Assert.Equal([Local(NewYork, 2026, 8, 1), Local(NewYork, 2026, 9, 1)], months.Select(c => c.IntervalEnd!.Value)); Assert.Equal([Local(NewYork, 2026, 7, 1), Local(NewYork, 2026, 8, 1)], months.Select(c => c.Time)); Assert.DoesNotContain(months, c => c.OpeningBalance); } // ---- Coalesce -------------------------------------------------------------------------------- [Fact] public void Rows_merged_onto_one_stamp_describe_both_intervals_and_keep_every_warning() { var at = Utc(2026, 8, 31, 21, 59); var merged = Assert.Single(NormalizationEngine.Coalesce( [ new Consumption { MeterId = 1, Time = at, Amount = 1, IntervalStart = at.AddHours(-2), IntervalEnd = at, Divided = true, SourceStart = at.AddDays(-2), SourceEnd = at.AddMinutes(1), }, new Consumption { MeterId = 1, Time = at, Amount = 2, IntervalStart = at.AddHours(-5), IntervalEnd = at.AddHours(1), SourceStart = at.AddHours(-5), SourceEnd = at.AddHours(1), OpeningBalance = true, Gap = CoverageGapReason.SampleGap, }, ])); Assert.Equal(3, merged.Amount); Assert.Equal(at.AddHours(-5), merged.IntervalStart); Assert.Equal(at.AddHours(1), merged.IntervalEnd); Assert.Equal(at.AddDays(-2), merged.SourceStart); Assert.Equal(at.AddHours(1), merged.SourceEnd); Assert.False(merged.Divided); Assert.True(merged.OpeningBalance); Assert.Equal(CoverageGapReason.SampleGap, merged.Gap); Assert.Equal(ReadingQuality.Estimated, merged.Quality); } [Fact] public void Two_divided_shares_merged_onto_one_stamp_stay_a_divided_share() { var at = Utc(2026, 8, 31, 21, 59); var merged = Assert.Single(NormalizationEngine.Coalesce( [ new Consumption { MeterId = 1, Time = at, Amount = 1, IntervalStart = at.AddDays(-3), IntervalEnd = at.AddSeconds(1), Divided = true }, new Consumption { MeterId = 1, Time = at, Amount = 1, IntervalStart = at.AddDays(-1), IntervalEnd = at.AddSeconds(1), Divided = true }, ])); Assert.True(merged.Divided); Assert.Equal(at.AddDays(-3), merged.IntervalStart); } [Fact] public void A_live_reading_merged_with_a_month_row_spans_the_whole_month() { // New York: HA polled at exactly the local midnight the "Juli" row is stamped at. The two rows share // one key; the merged row covers July. var julyMidnight = Local(NewYork, 2026, 7, 1); var result = _engine.Normalize(Context(MeterMode.CumulativeCounter, NewYork, [ Reading(1, Month(2026, 6), 1000), Reading(1, Month(2026, 7), 1300), Measured(julyMidnight, 1005), ])); var july = Assert.Single(result, c => c.Time == julyMidnight); Assert.Equal(300, july.Amount, 9); Assert.Equal(julyMidnight, july.IntervalStart); Assert.Equal(Local(NewYork, 2026, 8, 1), july.IntervalEnd); } }