Files
MeterVault/tests/Core.Tests/Analysis/CoverageEvaluatorTests.cs
T
Florian Schmidt 8940ef25c3
ci / build-test (push) Successful in 2m31s
Analysis: one selected period, one set of numbers, on every page
The dashboards told several stories at once. Overview asked for full
calendar years, meter detail for a fixed 12-month window that was really
13, Trends for 24 months with an Apply button, and the energy pages for
60. Each page derived "today" from UTC, so the first hours of a local day
belonged to yesterday. A missing tariff, a month nobody measured and a
genuine zero all rendered as 0. And a virtual meter -- the one thing the
spreadsheet leans on hardest -- was excluded from analysis outright:
MeterPeriodService returned null for it and the page offered a flow
diagram instead.

docs/DASHBOARD_ANALYSIS_CHANGE_BRIEF.md is the work order. Every choice it
left open is settled in docs/ANALYSIS_IMPLEMENTATION_NOTE.md as D-01..D-58
plus amendments A-01..A-30; code, tests and release notes cite those ids.

The analysis layer

Core/Analysis holds the pure rules: period presets resolved once in the
instance zone into a local date range and a half-open UTC range, bucket
plans, calendar-unit comparisons, coverage runs with a resolution class,
normalized quantities and units, the totals policy, the virtual formula
parser/validator/evaluator, and the cost calculator. "Now" comes from
TimeProvider; services never read the clock.

Normalization now writes, in the same transaction as consumption and by
diff, per-meter rollups by local day and month plus coverage runs and a
rollup state (AnalysisDataWriter). AnalysisReader answers a request from
those tables -- month rollups for month and year buckets, day rollups
otherwise, at most two partial edge days from consumption -- and
CostReader prices the result month by month. Pages, /api/v1 and the CSV
export read nothing else. The unused continuous aggregates are dropped.

The reader's statement count per request is constant whether it covers one
meter or a thousand. On a synthetic 1,000-meter, ten-year instance the
brief's target request (100 meters, ten years, monthly) takes 374 ms
against a two-second target, and the Overview went from 48,244 SQL
statements per load to 205.

Missing is not zero

Every bucket carries a status -- available, partial, missing, unresolved,
invalid, pending -- derived from coverage, never from the amount, with
provenance and a reason code beside it. A true zero is a number and a bar
on the baseline; an unknown bucket is a gap that says why; a month whose
data only exists monthly says so instead of inventing daily detail; a
scope with no tariff says "not priced" instead of 0. Rows whose interval
closes after now are reported separately rather than counted.

Virtual meters are analysis subjects

A virtual meter stores a canonical definition -- expression over m<id>
references, result kind, unit and cost rule -- validated on save and on
read for syntax, unknown or self references, loops and unit/kind rules.
It is evaluated on read from its sources' rollups over their joint
coverage: a missing source makes the bucket missing, an observed zero is
a valid input, a non-finite result is invalid with its dependency path,
and the page lists each source's contribution. Topology links are
topology only and never rewrite a saved calculation; expression-less
meters from older installs are converted once at startup. The editor has
Sum, Difference and Advanced modes with a live preview.

Totals and the bill

Per energy type the totals policy separates use, grid import, export,
generation and runtime, marks breakdown meters as breakdowns and virtual
meters as views, and never adds across units. The bill follows it: grid
import where there is one, separately priced subsections at their own
price, feed-in only on export meters, standing charges once per scope per
local day, manual costs once on their start day, categories as
non-overlapping covers whose composition reconciles to the bill. The
seeded demo's yearly totals now match the spreadsheet.

Pages and navigation

The period lives in the URL and every page reads the same contract, so a
link, a reload and the browser's Back button keep it. Shared components
carry it: page header with breadcrumbs, period toolbar, theme-aware chart
with an accessible table beside it, metric cards, comparison and
availability states, attention items that each link to the one action
that fixes them. Meter detail leads with an Analysis tab and resolves its
tabs by key; the energy page has Overview, History, Flow and Meters; the
old cost-only Trends page is a general Analysis page over portfolio, type,
category, meter or a meter comparison. Records tabs are paged server-side
instead of showing the latest 200. Everything is English and German,
light and dark, down to 360px.

Some figures change on purpose; docs/RELEASE_NOTES.md lists each one and
what the first start after the update does (it rebuilds all analysis data
before the web server listens). docs/SDD.md and CLAUDE.md describe the
system as it now is.

Tests: 1,733 Core and 746 integration, all green, plus an opt-in
performance suite with a synthetic 1,000-meter generator.
2026-09-20 10:29:13 +02:00

810 lines
36 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Coverage;
using MeterVault.Core.Domain;
using MeterVault.Core.Normalization;
using static MeterVault.Core.Tests.Analysis.CoverageTestData;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>
/// A bucket's status comes from coverage runs, not from sums (D-14). What these pin down: monthly data stays
/// monthly — it resolves months and years and nothing finer; an interval divided at month edges resolves
/// months but not weeks; a long undivided interval leaves every bucket whose edge it crosses unresolved,
/// within the 5 % edge tolerance; outages make exactly the buckets they touch partial or missing; an
/// opening balance, which the caller reports from the rollup flag, keeps its bucket partial (A-01); and a
/// bucket that ends at now is judged on what has happened by then (A-04).
/// </summary>
public sealed class CoverageEvaluatorTests
{
private static readonly IReadOnlyList<CoverageGapReason> NoGaps = [];
private static BucketCoverage Evaluate(AnalysisBucket bucket, params CoverageRun[] runs) =>
CoverageEvaluator.Evaluate(bucket, runs, Berlin, openingBalanceInBucket: false);
// ---- Seeded water meter: imported monthly table rows ---------------------------------------------
[Fact]
public void Monthly_label_data_resolves_its_months_and_years()
{
var water = MonthLabels(2022, 1, 2023, 1);
var december = Evaluate(Month(2022, 12), water);
var year = Evaluate(Year(2022), water);
Assert.Equal(BucketStatus.Available, december.Status);
Assert.Equal(ValueIssue.None, december.Issue);
Assert.Equal(ResolutionClass.Month, december.Resolution);
Assert.Equal(BucketStatus.Available, year.Status);
}
[Fact]
public void Monthly_label_data_is_unresolved_for_days_and_weeks_although_it_covers_them()
{
var water = MonthLabels(2022, 1, 2023, 1);
foreach (var bucket in new[] { Day(2022, 12, 15), Week(2022, 12, 14), Week(2022, 6, 1), Day(2022, 12, 31), Day(2022, 1, 1) })
{
var coverage = Evaluate(bucket, water);
Assert.Equal(BucketStatus.Unresolved, coverage.Status);
Assert.Equal(ValueIssue.CoarseResolution, coverage.Issue);
Assert.Equal(1d, coverage.CoveredFraction, 9);
}
}
// ---- A counter read on 1 August and 16 September -------------------------------------------------
public static TheoryData<string> DividedIntervalShapes => ["one coarse divided run", "month shares (D-10 segment bounds)"];
private static CoverageRun[] CounterReadAugustToSeptember(string shape)
{
// As one run the 46-day interval is coarse. As D-10's month shares (1 Aug 09:00 - 1 Sep and
// 1 Sep - 16 Sep 18:00) both shares are month-class and merge into one month-class run. Either way
// the normalizer divided it at the month edge; daily readings surround it.
var interval = shape == "one coarse divided run"
? Run(At(2026, 8, 1, 9), At(2026, 9, 16, 18), ResolutionClass.Coarse, divided: true)
: Run(At(2026, 8, 1, 9), At(2026, 9, 16, 18), ResolutionClass.Month, divided: true);
return
[
Run(At(2026, 7, 1), At(2026, 8, 1, 9), ResolutionClass.Day),
interval,
Run(At(2026, 9, 16, 18), At(2026, 10, 1), ResolutionClass.Day),
];
}
[Theory]
[MemberData(nameof(DividedIntervalShapes))]
public void A_46_day_interval_divided_at_the_month_edge_resolves_both_months(string shape)
{
var runs = CounterReadAugustToSeptember(shape);
Assert.Equal(BucketStatus.Available, Evaluate(Month(2026, 8), runs).Status);
Assert.Equal(BucketStatus.Available, Evaluate(Month(2026, 9), runs).Status);
}
[Theory]
[MemberData(nameof(DividedIntervalShapes))]
public void A_46_day_interval_divided_at_the_month_edge_still_cannot_say_which_week_it_was_used_in(string shape)
{
var runs = CounterReadAugustToSeptember(shape);
// Mid-August, the week across the month edge (Mon 31 Aug), and the week the closing reading lies in.
foreach (var bucket in new[] { Week(2026, 8, 12), Week(2026, 8, 31), Week(2026, 9, 16), Day(2026, 8, 20) })
{
Assert.Equal(BucketStatus.Unresolved, Evaluate(bucket, runs).Status);
}
// The weeks around it are resolved by the daily readings on either side.
Assert.Equal(BucketStatus.Available, Evaluate(Week(2026, 7, 15), runs).Status);
Assert.Equal(BucketStatus.Available, Evaluate(Week(2026, 9, 23), runs).Status);
}
// ---- Seeded burner runtime: one 12-year undivided interval ---------------------------------------
[Fact]
public void A_twelve_year_undivided_runtime_interval_leaves_its_months_and_years_unresolved()
{
var brenner = Run(At(2013, 10, 1), At(2025, 10, 1), ResolutionClass.Coarse);
var afterwards = Run(At(2025, 10, 1), At(2026, 6, 1), ResolutionClass.Month, divided: true);
Assert.Equal(BucketStatus.Unresolved, Evaluate(Year(2022), brenner, afterwards).Status);
Assert.Equal(BucketStatus.Unresolved, Evaluate(Month(2022, 3), brenner, afterwards).Status);
// The year it closes in would receive twelve years of runtime.
Assert.Equal(BucketStatus.Unresolved, Evaluate(Year(2025), brenner, afterwards).Status);
Assert.Equal(BucketStatus.Available, Evaluate(Month(2026, 2), brenner, afterwards).Status);
}
// ---- Seeded oil tank: dipstick levels weeks apart, never divided ---------------------------------
private static readonly CoverageRun[] Tank =
[
Run(At(2025, 9, 20), At(2025, 10, 14), ResolutionClass.Month),
Run(At(2025, 10, 14), At(2025, 11, 28), ResolutionClass.Coarse),
Run(At(2025, 11, 28), At(2025, 12, 20), ResolutionClass.Month),
];
[Fact]
public void A_45_day_undivided_tank_interval_leaves_both_months_it_spans_unresolved()
{
var october = Evaluate(Month(2025, 10), Tank);
var november = Evaluate(Month(2025, 11), Tank);
Assert.Equal(BucketStatus.Unresolved, october.Status);
Assert.Equal(ValueIssue.CoarseResolution, october.Issue);
Assert.Equal(ResolutionClass.Coarse, october.Resolution);
Assert.Equal(BucketStatus.Unresolved, november.Status);
}
[Fact]
public void A_45_day_tank_interval_inside_one_year_does_not_make_the_year_unresolved()
{
// D-14: only an interval crossing a bucket edge misbooks. This one starts and ends in 2025, so the
// year total is right — merely partial, because the tank was only read from 20 September.
var year = Evaluate(Year(2025), Tank);
Assert.Equal(BucketStatus.Partial, year.Status);
Assert.Equal(ValueIssue.PartialCoverage, year.Issue);
Assert.Equal(At(2025, 9, 20), year.FirstCovered);
Assert.Equal(At(2025, 12, 20), year.LastCovered);
}
[Fact]
public void A_month_class_tank_interval_crossing_a_month_edge_leaves_that_month_unresolved()
{
// D-14: the 20 Sep - 14 Oct dipstick interval is booked on 14 October, although 11 of its days lie in
// September. Being no longer than a month does not make an undivided interval land in the right month.
var september = Evaluate(Month(2025, 9), Tank);
Assert.Equal(BucketStatus.Unresolved, september.Status);
Assert.Equal(ValueIssue.CoarseResolution, september.Issue);
Assert.Equal(At(2025, 9, 20), september.FirstCovered);
}
[Fact]
public void Undivided_month_class_intervals_read_mid_month_leave_their_months_unresolved()
{
// Review F1: dipsticks (or burner hours) read on 15 Jan, 14 Feb and 16 Mar. February would receive the
// 15 Jan - 14 Feb draw, 17 of whose 30 days lie in January, so neither month can be told apart.
CoverageRun[] runs =
[
Single(At(2026, 1, 15, 10), At(2026, 2, 14, 10), ResolutionClass.Month),
Single(At(2026, 2, 14, 10), At(2026, 3, 16, 10), ResolutionClass.Month),
];
var february = CoverageEvaluator.Evaluate(Month(2026, 2), runs, Berlin, openingBalanceInBucket: false);
Assert.Equal(BucketStatus.Unresolved, february.Status);
Assert.Equal(ValueIssue.CoarseResolution, february.Issue);
Assert.Equal(BucketStatus.Unresolved, Evaluate(Month(2026, 1), runs).Status);
// The year holds both intervals whole: it is resolved, merely partial (read from 15 January only).
Assert.Equal(BucketStatus.Partial, Evaluate(Year(2026), runs).Status);
}
[Fact]
public void Undivided_month_class_intervals_that_cross_a_month_edge_by_less_than_5_percent_stay_resolved()
{
// The seeded oil tank's dipsticks sit on month-end days: 30 Nov 01:00 - 31 Dec 01:00 crosses into
// December by 23 hours of 31 days (3 %), which D-14 tolerates.
CoverageRun[] runs =
[
Single(At(2025, 11, 30, 1), At(2025, 12, 31, 1), ResolutionClass.Month),
Single(At(2025, 12, 31, 1), At(2026, 1, 31, 1), ResolutionClass.Month),
Single(At(2026, 1, 31, 1), At(2026, 2, 28, 1), ResolutionClass.Month),
];
Assert.Equal(BucketStatus.Available, Evaluate(Month(2025, 12), runs).Status);
Assert.Equal(BucketStatus.Available, Evaluate(Month(2026, 1), runs).Status);
}
[Fact]
public void Day_class_intervals_across_a_month_edge_still_resolve_months_on_the_class_rule()
{
// Daily readings at 06:00 cross each month edge by six hours, far inside the tolerance.
var run = Single(At(2026, 1, 31, 6), At(2026, 2, 1, 6), ResolutionClass.Day);
Assert.True(CoverageEvaluator.Resolves(run, Month(2026, 1), Berlin));
Assert.True(CoverageEvaluator.Resolves(run, Month(2026, 2), Berlin));
}
[Fact]
public void An_undivided_interval_crossing_new_year_by_less_than_5_percent_of_a_year_keeps_both_years_resolved()
{
CoverageRun[] runs =
[
Run(At(2024, 1, 1), At(2024, 12, 20), ResolutionClass.Month),
Run(At(2024, 12, 20), At(2025, 2, 5), ResolutionClass.Coarse),
Run(At(2025, 2, 5), At(2026, 1, 1), ResolutionClass.Month),
];
Assert.Equal(BucketStatus.Available, Evaluate(Year(2024), runs).Status);
Assert.Equal(BucketStatus.Available, Evaluate(Year(2025), runs).Status);
Assert.Equal(BucketStatus.Unresolved, Evaluate(Month(2024, 12), runs).Status);
Assert.Equal(BucketStatus.Unresolved, Evaluate(Month(2025, 1), runs).Status);
}
[Fact]
public void An_undivided_interval_crossing_new_year_by_more_than_5_percent_of_a_year_leaves_both_years_unresolved()
{
// 22 days on each side of a 365-day bucket is 6 %.
CoverageRun[] runs =
[
Run(At(2024, 1, 1), At(2024, 12, 10), ResolutionClass.Month),
Run(At(2024, 12, 10), At(2025, 1, 23), ResolutionClass.Coarse),
Run(At(2025, 1, 23), At(2026, 1, 1), ResolutionClass.Month),
];
Assert.Equal(BucketStatus.Unresolved, Evaluate(Year(2024), runs).Status);
Assert.Equal(BucketStatus.Unresolved, Evaluate(Year(2025), runs).Status);
}
[Fact]
public void Unresolved_wins_over_partial_because_a_value_too_coarse_to_divide_is_not_a_partial_total()
{
var coverage = Evaluate(Month(2026, 3), Run(At(2026, 3, 10), At(2026, 4, 20), ResolutionClass.Coarse));
Assert.Equal(BucketStatus.Unresolved, coverage.Status);
Assert.True(coverage.CoveredFraction < 1);
}
// ---- Hourly samples with a three-day outage ------------------------------------------------------
private static readonly CoverageRun[] HourlyWithOutage =
[
Run(At(2026, 1, 1), At(2026, 3, 10, 14), ResolutionClass.Hour),
Gap(At(2026, 3, 10, 14), At(2026, 3, 13, 9), CoverageGapReason.SampleGap),
Run(At(2026, 3, 13, 9), At(2026, 5, 1), ResolutionClass.Hour),
];
[Fact]
public void An_outage_leaves_exactly_the_days_it_touches_partial_or_missing()
{
var notAvailable = new List<(DateOnly Day, BucketStatus Status)>();
for (var day = new DateOnly(2026, 3, 1); day < new DateOnly(2026, 5, 1); day = day.AddDays(1))
{
var coverage = Evaluate(Day(day.Year, day.Month, day.Day), HourlyWithOutage);
if (coverage.Status != BucketStatus.Available)
{
notAvailable.Add((day, coverage.Status));
Assert.Equal(ValueIssue.SampleGap, coverage.Issue);
}
}
Assert.Equal(
[
(new DateOnly(2026, 3, 10), BucketStatus.Partial),
(new DateOnly(2026, 3, 11), BucketStatus.Missing),
(new DateOnly(2026, 3, 12), BucketStatus.Missing),
(new DateOnly(2026, 3, 13), BucketStatus.Partial),
],
notAvailable);
}
[Fact]
public void An_outage_makes_its_week_and_month_partial_and_leaves_their_neighbours_available()
{
var outageWeek = Evaluate(Week(2026, 3, 11), HourlyWithOutage);
Assert.Equal(BucketStatus.Partial, outageWeek.Status);
Assert.Equal(ValueIssue.SampleGap, outageWeek.Issue);
Assert.Equal([CoverageGapReason.SampleGap], outageWeek.Gaps);
Assert.Equal(TimeSpan.FromDays(7) - (At(2026, 3, 13, 9) - At(2026, 3, 10, 14)), outageWeek.Covered);
Assert.Equal(BucketStatus.Available, Evaluate(Week(2026, 3, 4), HourlyWithOutage).Status);
Assert.Equal(BucketStatus.Available, Evaluate(Week(2026, 3, 18), HourlyWithOutage).Status);
Assert.Equal(BucketStatus.Partial, Evaluate(Month(2026, 3), HourlyWithOutage).Status);
Assert.Equal(BucketStatus.Available, Evaluate(Month(2026, 2), HourlyWithOutage).Status);
Assert.Equal(BucketStatus.Available, Evaluate(Month(2026, 4), HourlyWithOutage).Status);
}
[Fact]
public void The_first_outage_day_reports_what_is_covered_and_until_when()
{
var coverage = Evaluate(Day(2026, 3, 10), HourlyWithOutage);
Assert.Equal(TimeSpan.FromHours(14), coverage.Covered);
Assert.Equal(14d / 24, coverage.CoveredFraction, 9);
Assert.Equal(At(2026, 3, 10), coverage.FirstCovered);
Assert.Equal(At(2026, 3, 10, 14), coverage.LastCovered);
Assert.Equal(ResolutionClass.Hour, coverage.Resolution);
}
[Fact]
public void Hourly_data_resolves_the_23_and_25_hour_DST_days()
{
var hourly = Run(At(2026, 3, 1), At(2026, 11, 1), ResolutionClass.Hour);
Assert.Equal(BucketStatus.Available, Evaluate(Day(2026, 3, 29), hourly).Status);
Assert.Equal(BucketStatus.Available, Evaluate(Day(2026, 10, 25), hourly).Status);
}
// ---- Edges and tolerances --------------------------------------------------------------------------
[Fact]
public void A_bucket_nothing_covers_is_missing_not_zero()
{
var coverage = Evaluate(Month(2026, 3));
Assert.Equal(BucketStatus.Missing, coverage.Status);
Assert.Equal(ValueIssue.NoCoverage, coverage.Issue);
Assert.Null(coverage.FirstCovered);
Assert.Null(coverage.Resolution);
Assert.Equal(0d, coverage.CoveredFraction);
}
[Fact]
public void Runs_that_only_touch_the_bucket_edges_do_not_cover_it()
{
var march = Month(2026, 3);
var coverage = Evaluate(
march,
Run(At(2026, 2, 1), march.From, ResolutionClass.Hour),
Run(march.To, At(2026, 5, 1), ResolutionClass.Hour));
Assert.Equal(BucketStatus.Missing, coverage.Status);
}
[Fact]
public void Coverage_short_by_less_than_a_minute_is_complete_and_by_two_minutes_is_partial()
{
var day = Day(2026, 6, 10);
var jitter = Evaluate(day, Run(day.From.AddSeconds(59), day.To, ResolutionClass.Hour));
var shortfall = Evaluate(day, Run(day.From.AddMinutes(2), day.To, ResolutionClass.Hour));
Assert.Equal(BucketStatus.Available, jitter.Status);
Assert.Equal(BucketStatus.Partial, shortfall.Status);
Assert.Equal(ValueIssue.PartialCoverage, shortfall.Issue);
}
[Fact]
public void Overlapping_runs_are_unioned_rather_than_counted_twice()
{
var day = Day(2026, 6, 10);
var coverage = Evaluate(
day,
Run(day.From, day.From.AddHours(16), ResolutionClass.Hour),
Run(day.From.AddHours(8), day.From.AddHours(20), ResolutionClass.Hour));
Assert.Equal(TimeSpan.FromHours(20), coverage.Covered);
Assert.Equal(BucketStatus.Partial, coverage.Status);
}
[Fact]
public void An_empty_clipped_bucket_is_missing()
{
var instant = At(2026, 9, 1);
var empty = new AnalysisBucket(new DateOnly(2026, 9, 1), new DateOnly(2026, 9, 1), instant, instant, BucketSize.Day);
Assert.Equal(BucketStatus.Missing, Evaluate(empty, Run(At(2026, 8, 1), At(2026, 10, 1), ResolutionClass.Hour)).Status);
}
[Fact]
public void A_bucket_that_ends_before_it_starts_is_a_caller_error()
{
var reversed = new AnalysisBucket(new DateOnly(2026, 9, 2), new DateOnly(2026, 9, 1), At(2026, 9, 2), At(2026, 9, 1), BucketSize.Day);
Assert.Throws<ArgumentException>(() => Evaluate(reversed));
}
// ---- Opening balances and register discontinuities ------------------------------------------------
[Fact]
public void An_opening_balance_keeps_its_bucket_partial_even_when_readings_cover_the_rest()
{
var firstReading = At(2026, 3, 10, 14);
CoverageRun[] runs = [Run(firstReading, At(2026, 5, 1), ResolutionClass.Hour, divided: true)];
var day = CoverageEvaluator.Evaluate(Day(2026, 3, 10), runs, Berlin, openingBalanceInBucket: true);
var month = CoverageEvaluator.Evaluate(Month(2026, 3), runs, Berlin, openingBalanceInBucket: true);
var nextDay = CoverageEvaluator.Evaluate(Day(2026, 3, 11), runs, Berlin, openingBalanceInBucket: false);
Assert.Equal(BucketStatus.Partial, day.Status);
Assert.Equal(ValueIssue.OpeningBalance, day.Issue);
Assert.True(day.OpeningBalance);
Assert.Equal(ValueIssue.OpeningBalance, month.Issue);
Assert.Equal(BucketStatus.Available, nextDay.Status);
Assert.False(nextDay.OpeningBalance);
}
[Fact]
public void A_bucket_holding_only_an_opening_balance_is_partial_not_missing()
{
var coverage = CoverageEvaluator.Evaluate(Day(2026, 3, 10), [], Berlin, openingBalanceInBucket: true);
Assert.Equal(BucketStatus.Partial, coverage.Status);
Assert.Equal(ValueIssue.OpeningBalance, coverage.Issue);
Assert.Equal(TimeSpan.Zero, coverage.Covered);
}
[Fact]
public void An_opening_balance_read_at_midnight_marks_the_day_its_row_is_booked_in_and_not_the_day_before()
{
// A-01: the flag follows the booked row. A first reading describes no time before its midnight, so
// its row stays on 10 March 00:00 (D-11 only moves rows that close an interval), and [From, To)
// files that stamp under 10 March, the day the rollup flags.
var midnight = At(2026, 3, 10);
var rows = NormalizationEngine.CreateDefault().Normalize(new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "kWh" },
Readings =
[
new Reading { MeterId = 1, Time = midnight, Value = 100, Quality = ReadingQuality.Measured },
new Reading { MeterId = 1, Time = At(2026, 3, 10, 12), Value = 107, Quality = ReadingQuality.Measured },
],
TimeZone = Berlin,
});
var runs = CoverageBuilder.Build(rows, Berlin);
var opening = Assert.Single(rows, r => r.OpeningBalance);
bool BookedIn(AnalysisBucket bucket) => opening.Time >= bucket.From && opening.Time < bucket.To;
var dayBefore = CoverageEvaluator.Evaluate(Day(2026, 3, 9), runs, Berlin, BookedIn(Day(2026, 3, 9)));
var firstDay = CoverageEvaluator.Evaluate(Day(2026, 3, 10), runs, Berlin, BookedIn(Day(2026, 3, 10)));
Assert.Equal(midnight, opening.Time);
Assert.False(dayBefore.OpeningBalance);
Assert.Equal(BucketStatus.Missing, dayBefore.Status);
Assert.True(firstDay.OpeningBalance);
Assert.Equal(BucketStatus.Partial, firstDay.Status);
Assert.Equal(ValueIssue.OpeningBalance, firstDay.Issue);
}
[Theory]
[InlineData(CoverageGapReason.UnexplainedDecrease)]
[InlineData(CoverageGapReason.ResetWithoutPrevious)]
public void A_register_discontinuity_is_named_as_the_reason_a_bucket_is_partial_or_missing(CoverageGapReason reason)
{
CoverageRun[] runs =
[
Run(At(2026, 1, 1), At(2026, 3, 5, 12), ResolutionClass.Day),
Gap(At(2026, 3, 5, 12), At(2026, 3, 9, 12), reason),
Run(At(2026, 3, 9, 12), At(2026, 5, 1), ResolutionClass.Day),
];
var month = Evaluate(Month(2026, 3), runs);
var inside = Evaluate(Day(2026, 3, 7), runs);
Assert.Equal(BucketStatus.Partial, month.Status);
Assert.Equal(ValueIssue.RegisterDiscontinuity, month.Issue);
Assert.Equal([reason], month.Gaps);
Assert.Equal(BucketStatus.Missing, inside.Status);
Assert.Equal(ValueIssue.RegisterDiscontinuity, inside.Issue);
}
[Fact]
public void Gap_runs_never_count_as_coverage_and_never_make_a_bucket_unresolved()
{
var coverage = Evaluate(Month(2026, 3), Gap(At(2026, 1, 1), At(2026, 6, 1), CoverageGapReason.SampleGap));
Assert.Equal(BucketStatus.Missing, coverage.Status);
Assert.Equal(ValueIssue.SampleGap, coverage.Issue);
Assert.Equal(TimeSpan.Zero, coverage.Covered);
}
[Fact]
public void Several_gap_reasons_are_listed_once_each_most_significant_first()
{
var coverage = Evaluate(
Month(2026, 3),
Run(At(2026, 3, 1), At(2026, 3, 5), ResolutionClass.Hour),
Gap(At(2026, 3, 5), At(2026, 3, 6), CoverageGapReason.SampleGap),
Run(At(2026, 3, 6), At(2026, 3, 10), ResolutionClass.Hour),
Gap(At(2026, 3, 10), At(2026, 3, 11), CoverageGapReason.UnexplainedDecrease),
Run(At(2026, 3, 11), At(2026, 3, 20), ResolutionClass.Hour),
Gap(At(2026, 3, 20), At(2026, 3, 21), CoverageGapReason.SampleGap),
Run(At(2026, 3, 21), At(2026, 4, 1), ResolutionClass.Hour));
Assert.Equal([CoverageGapReason.UnexplainedDecrease, CoverageGapReason.SampleGap], coverage.Gaps);
Assert.Equal(ValueIssue.RegisterDiscontinuity, coverage.Issue);
}
[Fact]
public void Only_available_and_partial_buckets_carry_a_number()
{
var water = MonthLabels(2022, 1, 2023, 1);
var available = Evaluate(Month(2022, 12), water).ToValue(14, Provenance.Imported);
var partial = Evaluate(Day(2026, 3, 10), HourlyWithOutage).ToValue(3.5, Provenance.Measured);
var unresolved = Evaluate(Day(2022, 12, 15), water).ToValue(14, Provenance.Imported);
var missing = Evaluate(Month(2030, 1), water).ToValue(0, Provenance.None);
Assert.Equal(new BucketValue(14, BucketStatus.Available, Provenance.Imported), available);
Assert.Equal(new BucketValue(3.5, BucketStatus.Partial, Provenance.Measured, ValueIssue.SampleGap), partial);
Assert.Equal(new BucketValue(null, BucketStatus.Unresolved, Provenance.Imported, ValueIssue.CoarseResolution), unresolved);
Assert.Equal(BucketValue.Missing(), missing);
}
// ---- Virtual meters: joint evaluation -----------------------------------------------------------------
private static readonly IReadOnlyList<CoverageRun>[] MonthlyAndHourlySources =
[
[MonthLabels(2026, 1, 2026, 8)],
[Run(At(2026, 3, 15, 12), At(2026, 9, 1), ResolutionClass.Hour)],
];
[Fact]
public void A_virtual_bucket_is_as_coarse_as_its_coarsest_source()
{
Assert.Equal(BucketStatus.Available, CoverageEvaluator.EvaluateJoint(Month(2026, 4), MonthlyAndHourlySources, Berlin, false).Status);
var day = CoverageEvaluator.EvaluateJoint(Day(2026, 4, 10), MonthlyAndHourlySources, Berlin, false);
Assert.Equal(BucketStatus.Unresolved, day.Status);
Assert.Equal(ResolutionClass.Month, day.Resolution);
}
[Fact]
public void A_virtual_bucket_is_partial_where_its_sources_only_partly_overlap()
{
var march = CoverageEvaluator.EvaluateJoint(Month(2026, 3), MonthlyAndHourlySources, Berlin, false);
Assert.Equal(BucketStatus.Partial, march.Status);
Assert.Equal(At(2026, 3, 15, 12), march.FirstCovered);
}
[Fact]
public void A_missing_source_makes_the_virtual_bucket_missing()
{
var february = CoverageEvaluator.EvaluateJoint(Month(2026, 2), MonthlyAndHourlySources, Berlin, false);
var august = CoverageEvaluator.EvaluateJoint(Month(2026, 8), MonthlyAndHourlySources, Berlin, false);
Assert.Equal(BucketStatus.Missing, february.Status);
Assert.Equal(ValueIssue.MissingSource, february.Issue);
Assert.Equal(BucketStatus.Missing, august.Status);
Assert.Equal(ValueIssue.MissingSource, august.Issue);
}
[Fact]
public void Sources_covering_different_halves_of_a_bucket_cover_none_of_it_jointly()
{
IReadOnlyList<CoverageRun>[] sources =
[
[Run(At(2026, 6, 1), At(2026, 6, 16), ResolutionClass.Hour)],
[Run(At(2026, 6, 16), At(2026, 7, 1), ResolutionClass.Hour)],
];
var june = CoverageEvaluator.EvaluateJoint(Month(2026, 6), sources, Berlin, false);
Assert.Equal(BucketStatus.Missing, june.Status);
Assert.Equal(ValueIssue.NoCoverage, june.Issue);
}
[Fact]
public void A_source_whose_long_interval_is_clipped_by_the_other_sources_start_still_makes_the_bucket_unresolved()
{
// A books 1 January 15 March in March. B only starts on 1 March, so the joint run is the clipped
// piece 115 March, which on its own looks like a harmless interval inside March.
IReadOnlyList<CoverageRun>[] sources =
[
[Run(At(2026, 1, 1), At(2026, 3, 15), ResolutionClass.Coarse), Run(At(2026, 3, 15), At(2026, 6, 1), ResolutionClass.Day)],
[Run(At(2026, 3, 1), At(2026, 6, 1), ResolutionClass.Hour)],
];
var march = CoverageEvaluator.EvaluateJoint(Month(2026, 3), sources, Berlin, false);
Assert.Equal(BucketStatus.Unresolved, march.Status);
Assert.Equal(BucketStatus.Available, CoverageEvaluator.EvaluateJoint(Month(2026, 4), sources, Berlin, false).Status);
}
[Fact]
public void A_sources_opening_balance_reaches_the_virtual_bucket()
{
var firstReading = At(2026, 4, 10, 8);
IReadOnlyList<CoverageRun>[] sources =
[
[Run(firstReading, At(2026, 6, 1), ResolutionClass.Hour)],
[Run(At(2026, 1, 1), At(2026, 6, 1), ResolutionClass.Hour)],
];
var april = CoverageEvaluator.EvaluateJoint(Month(2026, 4), sources, Berlin, openingBalanceInBucket: true);
var may = CoverageEvaluator.EvaluateJoint(Month(2026, 5), sources, Berlin, openingBalanceInBucket: false);
Assert.True(april.OpeningBalance);
Assert.Equal(BucketStatus.Partial, april.Status);
Assert.Equal(ValueIssue.OpeningBalance, april.Issue);
Assert.Equal(BucketStatus.Available, may.Status);
}
[Fact]
public void A_virtual_meter_without_sources_covers_nothing()
{
Assert.Equal(BucketStatus.Missing, CoverageEvaluator.EvaluateJoint(Month(2026, 4), [], Berlin, false).Status);
}
// ---- Zero-length runs, series, now ------------------------------------------------------------------
[Fact]
public void A_zero_length_run_inside_a_bucket_covers_nothing_and_leaves_it_missing()
{
var instant = At(2026, 3, 10, 14);
var coverage = Evaluate(Day(2026, 3, 10), Run(instant, instant, ResolutionClass.Day));
Assert.Equal(BucketStatus.Missing, coverage.Status);
Assert.Equal(ValueIssue.NoCoverage, coverage.Issue);
Assert.Null(coverage.Resolution);
}
[Fact]
public void A_series_is_evaluated_bucket_for_bucket_exactly_as_one_bucket_at_a_time()
{
// Unsorted, overlapping runs with holes and gaps, and buckets of every size in no particular order.
CoverageRun[] runs =
[
Run(At(2026, 3, 13, 9), At(2026, 5, 1), ResolutionClass.Hour),
Gap(At(2026, 3, 10, 14), At(2026, 3, 13, 9), CoverageGapReason.SampleGap),
MonthLabels(2025, 1, 2026, 1),
Single(At(2026, 1, 1), At(2026, 2, 20), ResolutionClass.Coarse),
Run(At(2026, 2, 20), At(2026, 3, 10, 14), ResolutionClass.Day, divided: true),
Run(At(2026, 4, 1), At(2026, 4, 3), ResolutionClass.Hour),
Run(At(2026, 6, 5), At(2026, 6, 5), ResolutionClass.Hour),
];
var buckets = new List<AnalysisBucket> { Year(2025), Month(2026, 3), Week(2026, 3, 11) };
for (var day = new DateOnly(2026, 6, 10); day >= new DateOnly(2025, 12, 20); day = day.AddDays(-1))
{
buckets.Add(Day(day.Year, day.Month, day.Day));
}
buckets.Add(Month(2026, 1));
var flags = buckets.Select(b => b.FirstDay == new DateOnly(2026, 3, 13)).ToList();
var series = CoverageEvaluator.EvaluateSeries(buckets, runs, Berlin, flags);
Assert.Equal(buckets.Count, series.Count);
for (var i = 0; i < buckets.Count; i++)
{
var one = CoverageEvaluator.Evaluate(buckets[i], runs, Berlin, flags[i]);
Assert.Equal(one with { Gaps = NoGaps }, series[i] with { Gaps = NoGaps });
Assert.Equal(one.Gaps, series[i].Gaps);
}
}
[Fact]
public void A_joint_series_is_evaluated_bucket_for_bucket_exactly_as_one_bucket_at_a_time()
{
IReadOnlyList<CoverageRun>[] sources =
[
[MonthLabels(2026, 1, 2026, 8), Gap(At(2026, 8, 1), At(2026, 8, 3), CoverageGapReason.ResetWithoutPrevious)],
[Run(At(2026, 3, 15, 12), At(2026, 9, 1), ResolutionClass.Hour)],
];
var buckets = Enumerable.Range(1, 9).Select(m => Month(2026, m)).Concat([Day(2026, 4, 10), Week(2026, 3, 16)]).Reverse().ToList();
var series = CoverageEvaluator.EvaluateJointSeries(buckets, sources, Berlin, openingBalanceInBucket: null);
for (var i = 0; i < buckets.Count; i++)
{
var one = CoverageEvaluator.EvaluateJoint(buckets[i], sources, Berlin, openingBalanceInBucket: false);
Assert.Equal(one with { Gaps = NoGaps }, series[i] with { Gaps = NoGaps });
Assert.Equal(one.Gaps, series[i].Gaps);
}
}
[Fact]
public void A_series_needs_one_opening_balance_flag_per_bucket()
{
Assert.Throws<ArgumentException>(() =>
CoverageEvaluator.EvaluateSeries([Day(2026, 3, 1), Day(2026, 3, 2)], [], Berlin, [true]));
}
[Fact]
public void A_live_meters_day_and_month_to_date_are_complete_up_to_its_last_sample()
{
// m2 #9: five-minute samples, the last at 09:55, now 10:00. The five minutes since cannot have a row yet.
var now = At(2026, 9, 19, 10);
CoverageRun[] samples = [Run(At(2026, 8, 1), At(2026, 9, 19, 9, 55), ResolutionClass.Hour, divided: true)];
var today = CoverageEvaluator.Evaluate(CutAt(Day(2026, 9, 19), now), samples, Berlin, false, now);
var monthToDate = CoverageEvaluator.Evaluate(CutAt(Month(2026, 9), now), samples, Berlin, false, now);
Assert.Equal(BucketStatus.Available, today.Status);
Assert.Equal(BucketStatus.Available, monthToDate.Status);
Assert.Equal(At(2026, 9, 19, 9, 55), today.LastCovered);
}
[Fact]
public void Daily_readings_leave_today_complete_until_the_next_reading_is_due()
{
// Read at 06:00 each day: today's bucket ends at now (10:00), four hours after the last reading.
var now = At(2026, 9, 19, 10);
CoverageRun[] daily = [Run(At(2026, 9, 1, 6), At(2026, 9, 19, 6), ResolutionClass.Day, divided: true)];
Assert.Equal(BucketStatus.Available, CoverageEvaluator.Evaluate(CutAt(Day(2026, 9, 19), now), daily, Berlin, false, now).Status);
}
[Fact]
public void Coverage_that_stops_more_than_one_interval_before_now_is_partial()
{
var now = At(2026, 9, 19, 10);
CoverageRun[] hourly = [Run(At(2026, 9, 1), At(2026, 9, 19, 8, 30), ResolutionClass.Hour, divided: true)];
var today = CoverageEvaluator.Evaluate(CutAt(Day(2026, 9, 19), now), hourly, Berlin, false, now);
Assert.Equal(BucketStatus.Partial, today.Status);
Assert.Equal(ValueIssue.PartialCoverage, today.Issue);
}
[Fact]
public void A_known_hole_before_now_is_a_shortfall_not_lag()
{
var now = At(2026, 9, 19, 10);
CoverageRun[] runs =
[
Run(At(2026, 9, 1), At(2026, 9, 19, 9, 30), ResolutionClass.Hour, divided: true),
Gap(At(2026, 9, 19, 9, 30), At(2026, 9, 19, 9, 55), CoverageGapReason.SampleGap),
];
var today = CoverageEvaluator.Evaluate(CutAt(Day(2026, 9, 19), now), runs, Berlin, false, now);
Assert.Equal(BucketStatus.Partial, today.Status);
Assert.Equal(ValueIssue.SampleGap, today.Issue);
}
[Fact]
public void The_up_to_date_tolerance_applies_only_to_a_bucket_that_ends_at_now()
{
// The same shortfall in a finished day is a shortfall.
var now = At(2026, 9, 19, 10);
CoverageRun[] hourly = [Run(At(2026, 9, 1), At(2026, 9, 18, 23, 55), ResolutionClass.Hour, divided: true)];
Assert.Equal(BucketStatus.Partial, CoverageEvaluator.Evaluate(Day(2026, 9, 18), hourly, Berlin, false, now).Status);
Assert.Equal(BucketStatus.Partial, CoverageEvaluator.Evaluate(Day(2026, 9, 18), hourly, Berlin, false).Status);
}
[Fact]
public void Monthly_readings_leave_the_month_to_date_complete_until_the_next_one_is_due()
{
// Read on the 15th: September to date is covered to the 15th, and the next reading is not due until October.
var now = At(2026, 9, 19, 10);
CoverageRun[] monthly = [Run(At(2026, 5, 15), At(2026, 9, 15), ResolutionClass.Month, divided: true, last: At(2026, 9, 1))];
var september = CoverageEvaluator.Evaluate(CutAt(Month(2026, 9), now), monthly, Berlin, false, now);
Assert.Equal(BucketStatus.Available, september.Status);
}
[Fact]
public void The_running_months_label_row_is_recorded_after_now_so_the_month_to_date_is_missing()
{
// A-04, evaluated from stored runs: the September row closes on 1 October, so nothing covers September
// yet, and a zero would not be a true zero.
var now = At(2026, 9, 19, 10);
CoverageRun[] stored = [MonthLabels(2026, 1, 2026, 10)];
var september = CoverageEvaluator.Evaluate(CutAt(Month(2026, 9), now), stored, Berlin, false, now);
var august = CoverageEvaluator.Evaluate(Month(2026, 8), stored, Berlin, false, now);
Assert.Equal(BucketStatus.Missing, september.Status);
Assert.Equal(BucketStatus.Available, august.Status);
}
[Fact]
public void A_future_stamped_daily_reading_does_not_cost_yesterday_its_coverage()
{
// m2 #5: readings at 06:00 with one already stamped tomorrow. Only the interval containing now is lost.
var now = At(2026, 9, 19, 10);
CoverageRun[] stored = [Run(At(2026, 9, 1, 6), At(2026, 9, 20, 6), ResolutionClass.Day, divided: true, last: At(2026, 9, 19, 6))];
Assert.Equal(BucketStatus.Available, CoverageEvaluator.Evaluate(Day(2026, 9, 18), stored, Berlin, false, now).Status);
Assert.Equal(BucketStatus.Available, CoverageEvaluator.Evaluate(CutAt(Day(2026, 9, 19), now), stored, Berlin, false, now).Status);
Assert.Equal(At(2026, 9, 19, 6), CoverageEvaluator.Evaluate(CutAt(Day(2026, 9, 19), now), stored, Berlin, false, now).LastCovered);
}
// ---- Another zone ------------------------------------------------------------------------------------
[Fact]
public void In_New_York_monthly_labels_resolve_New_York_months_and_not_their_days()
{
var water = MonthLabels(NewYork, 2026, 1, 2026, 7);
var march = CoverageEvaluator.Evaluate(Month(NewYork, 2026, 3), [water], NewYork, false);
var dstDay = CoverageEvaluator.Evaluate(Day(NewYork, 2026, 3, 8), [water], NewYork, false);
Assert.Equal(BucketStatus.Available, march.Status);
Assert.Equal(TimeSpan.FromDays(31) - TimeSpan.FromHours(1), march.Length);
Assert.Equal(BucketStatus.Unresolved, dstDay.Status);
Assert.Equal(TimeSpan.FromHours(23), dstDay.Length);
}
}