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