ci / build-test (push) Successful in 2m41s
Three things a reported Heizoel page got wrong at once. Its tank is dipped a few times a year and its burner read every few months, which is exactly the shape the coverage rules had not been walked through. "No data" for data that exists. A tank books nothing until the next dipstick closes the interval, so the stretch after the last dipstick is covered by no run at all, and a bucket no run covers was reported missing. The burner, whose run reaches into the window, said "only coarser data" -- the honest answer -- so one card claimed there was nothing while the coverage panel beside it listed years of data. A bucket that no run covers, no gap overlaps and no opening balance explains now reports the meter's resolution when its preceding coverage is within one interval of its own class: it is not silent, it is read rarely. A meter that does book its own buckets and stops -- a dead hourly source, a sheet asked about a later month -- still reads missing. Auto answering twelve months with one bar. Coarse only means "longer than a month", so a dipstick taken each autumn straddles a New Year as surely as a month start: coarsening the chart to years bought nothing and cost every point. The planning resolution now caps coarse at month when a run crosses a local year edge, and a series that cannot resolve the natural size no longer coarsens the whole chart -- it is drawn at that size with its buckets marked, which the chart and table already explain. A page contradicting itself. The comparison line above the ranking was fed the leading measure's matched coverage but worded as if it spoke for the page, directly above a burner row that did compare. It now names the figure it is about. Alongside: the "largest changes" ranking no longer drops a meter whose change is not comparable. It ranks what can be ranked, then lists the rest with their values and the reason -- the tank had been vanishing from its own energy type. And every dated table now reads newest first, as lists are read; charts stay chronological left to right, and the CSV export stays ascending for spreadsheets. A-41 to A-43 in the note record the three rules.
897 lines
41 KiB
C#
897 lines
41 KiB
C#
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);
|
||
}
|
||
|
||
// ---- Waiting for a measurement coarser than the bucket (A-41) ------------------------------------
|
||
|
||
[Fact]
|
||
public void Buckets_after_a_dipstick_wait_for_the_next_one_instead_of_reading_as_no_data()
|
||
{
|
||
// A heating-oil tank dipped on 5 October 2024 and 20 September 2025: one interval, coarser than a month.
|
||
// "Last 12 months" opens eleven days after the last dipstick, so nothing covers it — but the tank is
|
||
// neither silent nor broken, it is read twice a year. Every bucket says "only coarser data", with that
|
||
// resolution, and none of them claims a number.
|
||
var dipsticks = Single(At(2024, 10, 5, 10), At(2025, 9, 20, 10), ResolutionClass.Coarse);
|
||
|
||
var october = Evaluate(Month(2025, 10), dipsticks);
|
||
var august = Evaluate(Month(2026, 8), dipsticks);
|
||
|
||
Assert.Equal(BucketStatus.Unresolved, october.Status);
|
||
Assert.Equal(ValueIssue.CoarseResolution, october.Issue);
|
||
Assert.Equal(ResolutionClass.Coarse, october.Resolution);
|
||
Assert.Equal(TimeSpan.Zero, october.Covered);
|
||
Assert.Null(october.ToValue(0, Provenance.None).Value);
|
||
Assert.Equal(BucketStatus.Unresolved, august.Status);
|
||
}
|
||
|
||
[Fact]
|
||
public void A_bucket_before_a_meters_first_reading_is_missing_not_waiting()
|
||
{
|
||
// Nothing precedes it: the tank had not been dipped once, so there is no measurement on its way.
|
||
var dipsticks = Single(At(2024, 10, 5, 10), At(2025, 9, 20, 10), ResolutionClass.Coarse);
|
||
|
||
Assert.Equal(BucketStatus.Missing, Evaluate(Month(2024, 3), dipsticks).Status);
|
||
}
|
||
|
||
[Fact]
|
||
public void A_meter_that_books_its_own_buckets_still_reads_as_no_data_where_it_has_none()
|
||
{
|
||
// The rule is only for data too coarse for the bucket: an hourly source that went silent in March, and a
|
||
// monthly sheet that ends in March, both book their buckets as they go, so a bucket they do not cover
|
||
// really has no data.
|
||
var hourly = Run(At(2026, 1, 1), At(2026, 3, 15), ResolutionClass.Hour);
|
||
var sheet = MonthLabels(2026, 1, 2026, 4);
|
||
|
||
Assert.Equal(BucketStatus.Missing, Evaluate(Month(2026, 4), hourly).Status);
|
||
Assert.Equal(BucketStatus.Missing, Evaluate(Day(2026, 4, 2), hourly).Status);
|
||
Assert.Equal(BucketStatus.Missing, Evaluate(Month(2026, 5), sheet).Status);
|
||
Assert.Equal(BucketStatus.Missing, Evaluate(Year(2027), sheet).Status);
|
||
}
|
||
|
||
[Fact]
|
||
public void A_monthly_sheet_waits_a_month_for_its_next_row_and_then_has_no_data()
|
||
{
|
||
// Monthly rows cannot fill a day bucket at all, so the days right after the sheet ends are unresolved —
|
||
// the April row will book them. Two years on, the sheet is not late but over (A-04, LimitOf).
|
||
var sheet = MonthLabels(2026, 1, 2026, 4);
|
||
|
||
Assert.Equal(BucketStatus.Unresolved, Evaluate(Day(2026, 4, 2), sheet).Status);
|
||
Assert.Equal(ResolutionClass.Month, Evaluate(Day(2026, 4, 2), sheet).Resolution);
|
||
Assert.Equal(BucketStatus.Missing, Evaluate(Day(2028, 4, 2), sheet).Status);
|
||
}
|
||
|
||
[Fact]
|
||
public void A_known_hole_keeps_its_own_reason_rather_than_waiting()
|
||
{
|
||
// A gap run overlapping the bucket is a hole someone has to explain, not a measurement on its way.
|
||
CoverageRun[] runs =
|
||
[
|
||
Single(At(2024, 10, 5, 10), At(2025, 9, 20, 10), ResolutionClass.Coarse),
|
||
Gap(At(2025, 9, 20, 10), At(2026, 2, 1), CoverageGapReason.UnexplainedDecrease),
|
||
];
|
||
|
||
var coverage = Evaluate(Month(2025, 11), runs);
|
||
|
||
Assert.Equal(BucketStatus.Missing, coverage.Status);
|
||
Assert.Equal(ValueIssue.RegisterDiscontinuity, coverage.Issue);
|
||
}
|
||
|
||
[Fact]
|
||
public void A_series_reads_the_waiting_buckets_exactly_as_one_by_one()
|
||
{
|
||
var dipsticks = Single(At(2024, 10, 5, 10), At(2025, 9, 20, 10), ResolutionClass.Coarse);
|
||
List<AnalysisBucket> months = [.. Enumerable.Range(0, 12).Select(i => Month(2025 + ((9 + i) / 12), (((9 + i) % 12) + 1)))];
|
||
|
||
var series = CoverageEvaluator.EvaluateSeries(months, [dipsticks], Berlin, null);
|
||
|
||
Assert.Equal(months.Select(m => Evaluate(m, dipsticks).Status), series.Select(c => c.Status));
|
||
Assert.All(series, c => Assert.Equal(BucketStatus.Unresolved, c.Status));
|
||
Assert.All(series, c => Assert.Equal(ResolutionClass.Coarse, c.Resolution));
|
||
}
|
||
|
||
[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 1–15 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);
|
||
}
|
||
}
|