ci / build-test (push) Successful in 2m31s
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.
411 lines
19 KiB
C#
411 lines
19 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.AnalysisTestTime;
|
|
using static MeterVault.Core.Tests.TestData;
|
|
|
|
namespace MeterVault.Core.Tests.Analysis;
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
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<CoverageRun> Coverage(MeterMode mode, TimeZoneInfo zone, IReadOnlyList<Reading> readings, IReadOnlyList<MeterEvent>? 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);
|
|
}
|
|
}
|