Files
MeterVault/tests/Integration.Tests/Analysis/AnalysisReaderTests.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

1171 lines
63 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 System.Text.Json.Nodes;
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Totals;
using MeterVault.Core.Analysis.Virtual;
using MeterVault.Core.Domain;
using MeterVault.Core.Normalization;
using MeterVault.Infrastructure.Analysis;
using MeterVault.Infrastructure.Import;
using MeterVault.Infrastructure.Ingestion;
using MeterVault.Infrastructure.Normalization;
using MeterVault.Infrastructure.Options;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using static MeterVault.Integration.Tests.Reconciliation.ReconciliationSupport;
namespace MeterVault.Integration.Tests.Analysis;
/// <summary>
/// The shared analysis reader against real rollups (brief §11): physical and virtual series, per-type totals,
/// missing versus zero, invalid calculations, local-time boundaries, rows recorded after now, comparisons and
/// freshness of the data after new readings. Everything runs on a frozen clock of 19 September 2026, 14:37 Berlin;
/// every test creates its own energy types and meters and removes them again.
/// </summary>
[Collection("Timescale")]
public sealed class AnalysisReaderTests(TimescaleFixture fx) : IAsyncLifetime
{
private const string BerlinId = "Europe/Berlin";
private const string NewYorkId = "America/New_York";
private static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById(BerlinId);
private static readonly TimeZoneInfo NewYork = TimeZoneInfo.FindSystemTimeZoneById(NewYorkId);
/// <summary>The frozen "now" of every request here (D-01).</summary>
private static readonly DateTimeOffset Now = Local(Berlin, 2026, 9, 19, 14, 37);
private readonly List<int> _meters = [];
private readonly List<short> _types = [];
private readonly List<int> _manualCosts = [];
public Task InitializeAsync() => Task.CompletedTask;
public async Task DisposeAsync()
{
await using var db = fx.CreateContext();
var ids = _meters.ToArray();
var batches = await db.Readings.Where(r => ids.Contains(r.MeterId) && r.ImportBatchId != null)
.Select(r => r.ImportBatchId!.Value).Distinct().ToListAsync();
await db.ManualCosts.Where(c => _manualCosts.Contains(c.Id)).ExecuteDeleteAsync();
await db.Consumption.Where(c => ids.Contains(c.MeterId)).ExecuteDeleteAsync();
await db.Readings.Where(r => ids.Contains(r.MeterId)).ExecuteDeleteAsync();
await db.MeterEvents.Where(e => ids.Contains(e.MeterId)).ExecuteDeleteAsync();
await db.MeterSources.Where(s => ids.Contains(s.MeterId)).ExecuteDeleteAsync();
await db.Tanks.Where(t => ids.Contains(t.MeterId)).ExecuteDeleteAsync();
await db.Meters.Where(m => ids.Contains(m.Id)).ExecuteDeleteAsync();
await db.ImportBatches.Where(b => batches.Contains(b.Id)).ExecuteDeleteAsync();
var types = _types.ToArray();
await db.EnergyTypes.Where(t => types.Contains(t.Id)).ExecuteDeleteAsync();
}
// ------------------------------------------------------------------------------------------------ virtual meters
[Fact]
public async Task A_two_source_generation_sum_reads_like_a_physical_meter()
{
// Brief §5.4: A = 100/80, B = 150/120 → A+B = 250/200, 450 in total, generation, in kWh.
var type = await TypeAsync();
var (a, b) = (await GenerationAsync(type, 100, 80), await GenerationAsync(type, 150, 120));
var sum = await VirtualAsync(type, $"m{a} + m{b}", QuantityKind.Generation, "kWh", VirtualCostRule.SourceCosts);
var result = await Reader().ReadAsync(Request(AnalysisScope.ForMeters([a, b, sum]), JanFeb(), BucketSize.Month));
var virtualSeries = result.SeriesFor(sum)!;
Assert.Equal(SeriesBasis.Virtual, virtualSeries.Basis);
Assert.Equal(QuantityKind.Generation, virtualSeries.Kind);
Assert.Equal("kWh", virtualSeries.Unit);
AssertValues(virtualSeries, 250, 200);
AssertAvailable(virtualSeries.Total, 450);
Assert.True(virtualSeries.IsAdditive);
Assert.All(virtualSeries.Values, v => Assert.True(v.Provenance.HasFlag(Provenance.Derived)));
Assert.Equal(VirtualMeterStatus.Valid, virtualSeries.Virtual!.Status);
Assert.Equal([a, b], virtualSeries.Virtual.PhysicalLeaves);
// Every source's own series and its part, with the path from the sum to it.
var contributions = virtualSeries.Contributions.ToDictionary(c => c.MeterId);
Assert.Equal([100d, 80d], contributions[a].Values.Select(v => v.Value!.Value));
Assert.Equal([150d, 120d], contributions[b].Values.Select(v => v.Value!.Value));
Assert.Equal([sum, a], contributions[a].DependencyPath);
Assert.Equal(180, contributions[a].UsedTotal);
// The physical sources read the same numbers on their own.
AssertValues(result.SeriesFor(a)!, 100, 80);
AssertValues(result.SeriesFor(b)!, 150, 120);
Assert.Equal(SeriesBasis.Physical, result.SeriesFor(a)!.Basis);
// Type history agrees: the generation total is the sources, the sum is an analysis view (D-22).
var typeResult = await Reader().ReadAsync(Request(AnalysisScope.ForEnergyType(type), JanFeb(), BucketSize.Month));
var generation = typeResult.MeasureFor(type, TotalsMeasure.Generation)!;
Assert.Equal([a, b], generation.MemberIds);
AssertValues(generation, 250, 200);
AssertAvailable(generation.Total, 450);
Assert.Equal(QuantityKind.Generation, generation.Kind);
Assert.Equal(MeterTotalsClass.AnalysisOnly, typeResult.Classification.Single(c => c.MeterId == sum).Entry.Class);
// Year buckets over the same months add up to the same total.
var yearly = await Reader().ReadAsync(Request(AnalysisScope.ForMeter(sum), JanFeb(), BucketSize.Year));
AssertValues(yearly.SeriesFor(sum)!, 450);
}
[Fact]
public async Task A_missing_source_is_unknown_and_an_observed_zero_is_a_value()
{
var type = await TypeAsync();
var a = await GenerationAsync(type, 100, 80);
var missing = await GenerationAsync(type, 150); // no reading closes February
var zero = await GenerationAsync(type, 150, 0); // February observed, and nothing moved
var withMissing = await VirtualAsync(type, $"m{a} + m{missing}", QuantityKind.Generation, "kWh", VirtualCostRule.SourceCosts);
var withZero = await VirtualAsync(type, $"m{a} + m{zero}", QuantityKind.Generation, "kWh", VirtualCostRule.SourceCosts);
var result = await Reader().ReadAsync(Request(AnalysisScope.ForMeters([withMissing, withZero]), JanFeb(), BucketSize.Month));
var incomplete = result.SeriesFor(withMissing)!;
AssertAvailable(incomplete.Values[0], 250);
Assert.Equal(BucketStatus.Missing, incomplete.Values[1].Status);
Assert.Null(incomplete.Values[1].Value);
Assert.Equal(ValueIssue.MissingSource, incomplete.Values[1].Issue);
Assert.Equal([withMissing, missing], incomplete.Values[1].DependencyPath);
Assert.Equal(BucketStatus.Partial, incomplete.Total.Status);
Assert.Equal(250, incomplete.Total.Value);
var complete = result.SeriesFor(withZero)!;
AssertValues(complete, 250, 80);
AssertAvailable(complete.Total, 330);
}
[Fact]
public async Task A_difference_stays_negative_and_nested_meters_resolve_once()
{
var type = await TypeAsync();
var (a, b, c) = (await GenerationAsync(type, 100, 80), await GenerationAsync(type, 150, 120), await GenerationAsync(type, 10, 20));
var sum = await VirtualAsync(type, $"m{a} + m{b}", QuantityKind.Generation, "kWh", VirtualCostRule.SourceCosts);
var difference = await VirtualAsync(type, $"m{a} - m{b}", QuantityKind.Generation, "kWh", VirtualCostRule.None);
var nested = await VirtualAsync(type, $"m{sum} + m{c}", QuantityKind.Generation, "kWh", VirtualCostRule.SourceCosts);
var nestedDifference = await VirtualAsync(type, $"m{difference} + m{c}", QuantityKind.Generation, "kWh", VirtualCostRule.None);
var result = await Reader().ReadAsync(Request(AnalysisScope.ForMeters([difference, nested, nestedDifference]), JanFeb(), BucketSize.Month));
// A B plots below zero; it is not the flow view's sum of its links.
AssertValues(result.SeriesFor(difference)!, -50, -40);
AssertAvailable(result.SeriesFor(difference)!.Total, -90);
var outer = result.SeriesFor(nested)!;
AssertValues(outer, 260, 220);
AssertAvailable(outer.Total, 480);
Assert.Equal([a, b, c], outer.Virtual!.PhysicalLeaves);
var inner = outer.Contributions.Single(x => x.MeterId == sum);
Assert.True(inner.IsVirtual);
Assert.Equal([nested, sum, a], inner.Nested.Single(x => x.MeterId == a).DependencyPath);
Assert.Equal([250d, 200d], inner.Values.Select(v => v.Value!.Value));
AssertValues(result.SeriesFor(nestedDifference)!, -40, -20);
}
[Fact]
public async Task A_dependency_loop_is_named_and_never_a_number()
{
var type = await TypeAsync();
var (a, b) = (await GenerationAsync(type, 100, 80), await GenerationAsync(type, 150, 120));
var x = await VirtualAsync(type, $"m{a}", QuantityKind.Generation, "kWh", VirtualCostRule.SourceCosts);
var y = await VirtualAsync(type, $"m{x} + m{b}", QuantityKind.Generation, "kWh", VirtualCostRule.SourceCosts);
await SetDefinitionAsync(x, $"m{y} + m{a}", QuantityKind.Generation, "kWh", VirtualCostRule.SourceCosts);
var result = await Reader().ReadAsync(Request(AnalysisScope.ForMeters([x, y]), JanFeb(), BucketSize.Month));
foreach (var (id, path) in new[] { (x, new[] { x, y, x }), (y, [y, x, y]) })
{
var series = result.SeriesFor(id)!;
Assert.Equal(VirtualMeterStatus.Invalid, series.Virtual!.Status);
Assert.All(series.Values.Append(series.Total), v =>
{
Assert.Equal(BucketStatus.Invalid, v.Status);
Assert.Null(v.Value);
Assert.Equal(ValueIssue.DependencyCycle, v.Issue);
Assert.Equal(path, v.DependencyPath);
});
}
Assert.Contains(result.Problems, p => p.Kind == AnalysisProblemKind.InvalidDefinition && p.MeterId == x
&& p.Virtual?.Kind == VirtualProblemKind.DependencyCycle);
}
[Fact]
public async Task A_division_by_zero_is_invalid_and_a_ratio_is_not_additive()
{
var type = await TypeAsync();
var a = await GenerationAsync(type, 100, 80);
var z = await GenerationAsync(type, 50, 0);
var ratio = await VirtualAsync(type, $"m{a} / m{z}", QuantityKind.Indicator, "kWh/kWh", VirtualCostRule.None);
var result = await Reader().ReadAsync(Request(AnalysisScope.ForMeter(ratio), JanFeb(), BucketSize.Month));
var series = result.SeriesFor(ratio)!;
AssertAvailable(series.Values[0], 2);
Assert.Equal(BucketStatus.Invalid, series.Values[1].Status);
Assert.Equal(ValueIssue.NonFinite, series.Values[1].Issue);
Assert.Null(series.Values[1].Value);
Assert.False(series.IsAdditive);
// The period total is the ratio of the totals, not the sum of the monthly ratios.
AssertAvailable(series.Total, 180d / 50d);
Assert.Equal(QuantityKind.Indicator, series.Kind);
}
// ------------------------------------------------------------------------------------------------ totals
[Fact]
public async Task An_overlapping_parent_and_child_total_the_parent_only()
{
var type = await TypeAsync();
var haus = await ConsumptionAsync(type, 300);
var auto = await ConsumptionAsync(type, 100);
await LinkAsync(haus, auto);
var result = await Reader().ReadAsync(
Request(AnalysisScope.ForEnergyType(type), January(), BucketSize.Month) with { IncludeMeterSeries = true });
var use = result.MeasureFor(type, TotalsMeasure.Use)!;
Assert.Equal([haus], use.MemberIds);
AssertValues(use, 300);
AssertAvailable(use.Total, 300);
var child = result.Classification.Single(c => c.MeterId == auto).Entry;
Assert.Equal(MeterTotalsClass.Breakdown, child.Class);
Assert.Equal(MeterTotalsReason.ContainedByLink, child.Reason);
Assert.Equal(haus, child.ParentId);
Assert.Equal(MeterTotalsClass.Breakdown, result.SeriesFor(auto)!.Totals!.Class);
AssertValues(result.SeriesFor(auto)!, 100);
}
[Fact]
public async Task A_virtual_view_of_counted_sources_is_never_added_twice()
{
var type = await TypeAsync();
var (a, b) = (await GenerationAsync(type, 100), await GenerationAsync(type, 150));
var sum = await VirtualAsync(type, $"m{a} + m{b}", QuantityKind.Generation, "kWh", VirtualCostRule.SourceCosts);
var auto = await Reader().ReadAsync(Request(AnalysisScope.ForEnergyType(type), January(), BucketSize.Month));
var generation = auto.MeasureFor(type, TotalsMeasure.Generation)!;
Assert.Equal([a, b], generation.MemberIds);
AssertAvailable(generation.Total, 250);
// With "always" the sum replaces its sources — the same 250, never 500 (D-23).
await using (var db = fx.CreateContext())
{
var meter = await db.Meters.SingleAsync(m => m.Id == sum);
var meta = JsonNode.Parse(meter.Meta)!.AsObject();
meta[TotalsOverrideTokens.MetaKey] = TotalsOverrideTokens.Always;
meter.Meta = meta.ToJsonString();
await db.SaveChangesAsync();
}
var always = await Reader().ReadAsync(Request(AnalysisScope.ForEnergyType(type), January(), BucketSize.Month));
var replaced = always.MeasureFor(type, TotalsMeasure.Generation)!;
Assert.Equal([sum], replaced.MemberIds);
AssertAvailable(replaced.Total, 250);
Assert.Equal(MeterTotalsClass.IncludedByOverride, always.Classification.Single(c => c.MeterId == sum).Entry.Class);
Assert.Equal(MeterTotalsClass.ExcludedByOverride, always.Classification.Single(c => c.MeterId == a).Entry.Class);
// The portfolio lists the type's measure with the same total.
var portfolio = await Reader().ReadAsync(Request(AnalysisScope.Portfolio, January(), BucketSize.Month));
AssertAvailable(portfolio.MeasureFor(type, TotalsMeasure.Generation)!.Total, 250);
}
[Fact]
public async Task A_retired_meter_and_its_successor_keep_a_total_complete()
{
// D-24: outside its service period a meter contributes a known zero — to a total and to a virtual sum — while
// its own series still says it has no data there.
var type = await TypeAsync();
var old = await MeterAsync(type, MeterMode.CumulativeCounter, "kWh", installedAt: new DateOnly(2026, 1, 1), retiredAt: new DateOnly(2026, 1, 31));
await ReadingsAsync(old, (Mid(2026, 2, 1), 100));
var successor = await MeterAsync(type, MeterMode.CumulativeCounter, "kWh", installedAt: new DateOnly(2026, 2, 1));
await ReadingsAsync(successor, (Mid(2026, 3, 1), 80));
var sum = await VirtualAsync(type, $"m{old} + m{successor}", QuantityKind.Consumption, "kWh", VirtualCostRule.SourceCosts);
var result = await Reader().ReadAsync(
Request(AnalysisScope.ForEnergyType(type), JanFeb(), BucketSize.Month) with { IncludeMeterSeries = true });
var use = result.MeasureFor(type, TotalsMeasure.Use)!;
Assert.Equal([old, successor], use.MemberIds);
AssertValues(use, 100, 80);
AssertAvailable(use.Total, 180);
AssertValues(result.SeriesFor(sum)!, 100, 80);
AssertAvailable(result.SeriesFor(sum)!.Total, 180);
Assert.Equal(new DateOnly(2026, 1, 1), result.SeriesFor(sum)!.Availability!.FirstDay);
Assert.Equal(new DateOnly(2026, 2, 28), result.SeriesFor(sum)!.Availability!.LastDay);
Assert.Equal(BucketStatus.Missing, result.SeriesFor(old)!.Values[1].Status);
Assert.Equal(BucketStatus.Missing, result.SeriesFor(successor)!.Values[0].Status);
Assert.Equal(BucketStatus.Partial, result.SeriesFor(old)!.Total.Status);
}
// ------------------------------------------------------------------------------------------------ history and time
[Fact]
public async Task Historical_only_data_has_nothing_this_month_and_names_its_latest_month()
{
await using var db = fx.CreateContext();
var meters = await ImportReferenceSheetsAsync(db);
var reader = Reader();
var scope = AnalysisScope.ForMeters([meters.Wasser, meters.SummeSolar, meters.Solar1]);
// Month to date on 19 September 2026: the sheets end in May.
var mtd = await reader.ReadAsync(new AnalysisRequest(scope, Resolve(PeriodPreset.MonthToDate)));
var water = mtd.SeriesFor(meters.Wasser)!;
Assert.Equal(BucketStatus.Missing, water.Total.Status);
Assert.Null(water.Total.Value);
Assert.All(water.Values, v => Assert.Equal(BucketStatus.Missing, v.Status));
Assert.Equal(new DateOnly(2026, 5, 1), water.Availability!.LatestMonth);
Assert.Equal(FreshnessState.Historical, water.Freshness.State);
Assert.Equal(new DateOnly(2026, 5, 1), mtd.Availability.Quantity!.LatestMonth);
Assert.Equal(new LatestPeriod(new DateOnly(2026, 5, 1), LatestPeriodBasis.Meters), mtd.Availability.Latest);
Assert.Equal(BucketStatus.Missing, mtd.SeriesFor(meters.SummeSolar)!.Total.Status);
var available = await reader.GetAvailabilityAsync(scope, Now);
Assert.Equal(new DateOnly(2026, 5, 1), available.Quantity!.LatestMonth);
Assert.Equal(mtd.Availability.Quantity.FirstDay, available.Quantity.FirstDay);
// The previous calendar year has quantities for the physical meters and the legacy virtual sum.
var year = await reader.ReadAsync(new AnalysisRequest(scope, Resolve(PeriodPreset.PreviousYear)) { Bucket = BucketSize.Month });
Assert.Equal(12, year.Plan.Buckets.Count);
var water2025 = year.SeriesFor(meters.Wasser)!;
AssertAvailable(water2025.Total, await DbSumAsync(db, meters.Wasser, Mid(2025, 1, 1), Mid(2026, 1, 1)));
Assert.All(water2025.Values, v => Assert.Equal(BucketStatus.Available, v.Status));
var summe = year.SeriesFor(meters.SummeSolar)!;
Assert.Equal(SeriesBasis.LegacyVirtual, summe.Basis);
Assert.Equal(VirtualMeterStatus.Legacy, summe.Virtual!.Status);
Assert.Equal(QuantityKind.Generation, summe.Kind);
var solar1 = await DbSumAsync(db, meters.Solar1, Mid(2025, 1, 1), Mid(2026, 1, 1));
var solar2 = await DbSumAsync(db, meters.Solar2, Mid(2025, 1, 1), Mid(2026, 1, 1));
Assert.Equal(BucketStatus.Available, summe.Total.Status);
Assert.Equal(solar1 + solar2, summe.Total.Value!.Value, 6);
Assert.Equal(ValueIssue.LegacyDefinition, summe.Total.Issue);
Assert.Contains(year.Problems, p => p.Kind == AnalysisProblemKind.LegacyDefinition && p.MeterId == meters.SummeSolar);
// Monthly legacy data stays monthly: day buckets are unresolved, the month as a whole is not.
var may = await reader.ReadAsync(new AnalysisRequest(AnalysisScope.ForMeter(meters.Wasser),
Custom(new DateOnly(2026, 5, 1), new DateOnly(2026, 5, 31))) { Bucket = BucketSize.Day });
var mayWater = may.SeriesFor(meters.Wasser)!;
Assert.Equal(31, mayWater.Values.Count);
Assert.All(mayWater.Values, v =>
{
Assert.Equal(BucketStatus.Unresolved, v.Status);
Assert.Null(v.Value);
});
AssertAvailable(mayWater.Total, await DbSumAsync(db, meters.Wasser, Mid(2026, 5, 1), Mid(2026, 6, 1)));
}
[Fact]
public async Task Monthly_labels_are_unresolved_by_day_and_auto_never_plans_finer_than_the_data()
{
var type = await TypeAsync("m³");
var meter = await MeterAsync(type, MeterMode.CumulativeCounter, "m3");
await LabelsAsync(meter, (2026, 3, 10), (2026, 4, 25), (2026, 5, 37), (2026, 6, 44));
var byDay = await Reader().ReadAsync(Request(AnalysisScope.ForMeter(meter), Custom(new DateOnly(2026, 5, 1), new DateOnly(2026, 5, 31)), BucketSize.Day));
Assert.All(byDay.SeriesFor(meter)!.Values, v => Assert.Equal(BucketStatus.Unresolved, v.Status));
AssertAvailable(byDay.SeriesFor(meter)!.Total, 12);
Assert.Equal("m³", byDay.SeriesFor(meter)!.Unit);
// Auto on the same month picks month buckets: the data is monthly (D-05).
var auto = await Reader().ReadAsync(Request(AnalysisScope.ForMeter(meter), Custom(new DateOnly(2026, 5, 1), new DateOnly(2026, 5, 31)), BucketSize.Auto));
Assert.Equal(BucketSize.Month, auto.Plan.Size);
AssertValues(auto.SeriesFor(meter)!, 12);
}
[Fact]
public async Task Local_days_follow_the_instance_zone_across_new_year_and_daylight_saving()
{
var type = await TypeAsync();
var meter = await MeterAsync(type, MeterMode.CumulativeCounter, "kWh");
// One kWh per elapsed hour, around New Year and both 2025/2026 Berlin DST changes.
await HourlyAsync(meter, Berlin,
(Local(Berlin, 2025, 10, 25, 23), Local(Berlin, 2025, 10, 27, 0)),
(Local(Berlin, 2025, 12, 30, 23), Local(Berlin, 2026, 1, 2, 0)),
(Local(Berlin, 2026, 3, 28, 23), Local(Berlin, 2026, 3, 30, 0)));
async Task<double[]> DaysAsync(DateOnly first, DateOnly last, BucketSize size = BucketSize.Day)
{
var result = await Reader().ReadAsync(Request(AnalysisScope.ForMeter(meter), Custom(first, last), size));
return [.. result.SeriesFor(meter)!.Values.Select(v => v.Value ?? double.NaN)];
}
Assert.Equal(new[] { 24d, 24d }, await DaysAsync(new DateOnly(2025, 12, 31), new DateOnly(2026, 1, 1)));
Assert.Equal(new[] { 24d, 24d }, await DaysAsync(new DateOnly(2025, 12, 31), new DateOnly(2026, 1, 1), BucketSize.Year));
Assert.Equal(new[] { 23d }, await DaysAsync(new DateOnly(2026, 3, 29), new DateOnly(2026, 3, 29)));
Assert.Equal(new[] { 25d }, await DaysAsync(new DateOnly(2025, 10, 26), new DateOnly(2025, 10, 26)));
var spring = await Reader().ReadAsync(Request(AnalysisScope.ForMeter(meter), Custom(new DateOnly(2026, 3, 29), new DateOnly(2026, 3, 29)), BucketSize.Day));
AssertAvailable(spring.SeriesFor(meter)!.Values[0], 23);
}
[Fact]
public async Task A_zone_behind_utc_cuts_its_own_days_and_data_cut_in_another_zone_is_pending()
{
var type = await TypeAsync();
var meter = await MeterAsync(type, MeterMode.CumulativeCounter, "kWh");
await HourlyAsync(meter, NewYork, (Local(NewYork, 2025, 12, 30, 23), Local(NewYork, 2026, 1, 2, 0)));
var reader = Reader(NewYorkId);
var period = PeriodResolver.Resolve(PeriodPreset.Custom, new DateOnly(2025, 12, 31), new DateOnly(2026, 1, 1), Now, NewYork);
var result = await reader.ReadAsync(new AnalysisRequest(AnalysisScope.ForMeter(meter), period) { Bucket = BucketSize.Day });
Assert.Equal([24d, 24d], result.SeriesFor(meter)!.Values.Select(v => v.Value!.Value));
Assert.Equal(Local(NewYork, 2025, 12, 31, 0), result.Plan.Buckets[0].From);
// The same rows read in Berlin were cut in New York: "being prepared", never "no data" (D-16).
var berlin = await Reader().ReadAsync(Request(AnalysisScope.ForMeter(meter), Custom(new DateOnly(2025, 12, 31), new DateOnly(2026, 1, 1)), BucketSize.Day));
var pending = berlin.SeriesFor(meter)!;
Assert.True(pending.IsPending);
Assert.All(pending.Values, v => Assert.Equal(ValueIssue.AnalysisPending, v.Issue));
Assert.Contains(berlin.Problems, p => p.Kind == AnalysisProblemKind.AnalysisPending && p.MeterId == meter);
// A reader in the wrong zone cannot be handed a Berlin period.
await Assert.ThrowsAsync<ArgumentException>(() => reader.ReadAsync(Request(AnalysisScope.ForMeter(meter), January(), BucketSize.Day)));
}
[Fact]
public async Task Rows_that_close_after_now_are_left_out_and_reported()
{
var type = await TypeAsync();
var live = await MeterAsync(type, MeterMode.CumulativeCounter, "kWh", installedAt: new DateOnly(2026, 9, 1));
var readings = Enumerable.Range(1, 19).Select(d => (Local(Berlin, 2026, 9, d, 6), 10d * (d - 1))).ToList();
readings.Add((Local(Berlin, 2026, 9, 19, 20), 185)); // later today, after now
readings.Add((Local(Berlin, 2026, 9, 25, 6), 250)); // a device clock six days ahead
await ReadingsAsync(live, [.. readings]);
var label = await MeterAsync(type, MeterMode.CumulativeCounter, "kWh");
await LabelsAsync(label, (2026, 8, 100), (2026, 9, 130)); // "September 2026" closes on 1 October
var result = await Reader().ReadAsync(new AnalysisRequest(AnalysisScope.ForMeters([live, label]), Resolve(PeriodPreset.MonthToDate)));
Assert.Equal(BucketSize.Day, result.Plan.Size);
Assert.Equal(19, result.Plan.Buckets.Count);
var liveSeries = result.SeriesFor(live)!;
AssertAvailable(liveSeries.Total, 180);
AssertAvailable(liveSeries.Values[^1], 10);
var future = Assert.Single(liveSeries.RecordedAfterNow);
Assert.Equal(new RecordedAfterNow(live, 2, 70, new DateOnly(2026, 9, 19), new DateOnly(2026, 9, 25)), future);
var labelSeries = result.SeriesFor(label)!;
Assert.Equal(BucketStatus.Missing, labelSeries.Total.Status);
Assert.All(labelSeries.Values, v => Assert.Null(v.Value));
Assert.Equal(new RecordedAfterNow(label, 1, 30, new DateOnly(2026, 9, 1), new DateOnly(2026, 9, 1)), Assert.Single(labelSeries.RecordedAfterNow));
Assert.Equal(new DateOnly(2026, 8, 1), labelSeries.Availability!.LatestMonth);
Assert.Contains(result.Problems, p => p.Kind == AnalysisProblemKind.RecordedAfterNow && p.MeterId == live);
Assert.Contains(result.Problems, p => p.Kind == AnalysisProblemKind.RecordedAfterNow && p.MeterId == label);
// A range entirely in the future has not occurred; its rows are only reported.
var october = await Reader().ReadAsync(new AnalysisRequest(AnalysisScope.ForMeter(live), Custom(new DateOnly(2026, 9, 20), new DateOnly(2026, 9, 30))));
Assert.True(october.NotYetOccurred);
Assert.Empty(october.Plan.Buckets);
Assert.Equal(ValueIssue.NotYetOccurred, october.SeriesFor(live)!.Total.Issue);
Assert.Equal(65, Assert.Single(october.SeriesFor(live)!.RecordedAfterNow).Amount);
// On the 1st, the label row of the current month sits inside today's partial day: it closes on 1 October,
// so it is left out of today and reported, although it is stamped before now.
var firstOfMonth = PeriodResolver.Resolve(PeriodPreset.MonthToDate, null, null, Local(Berlin, 2026, 9, 1, 10), Berlin);
var early = await Reader().ReadAsync(new AnalysisRequest(AnalysisScope.ForMeter(label), firstOfMonth) { Bucket = BucketSize.Day });
var earlyLabel = early.SeriesFor(label)!;
Assert.Null(Assert.Single(earlyLabel.Values).Value);
Assert.Equal(new RecordedAfterNow(label, 1, 30, new DateOnly(2026, 9, 1), new DateOnly(2026, 9, 1)), Assert.Single(earlyLabel.RecordedAfterNow));
}
[Fact]
public async Task An_all_zero_year_is_visible_and_a_net_balance_stays_signed()
{
var type = await TypeAsync();
var still = await MeterAsync(type, MeterMode.CumulativeCounter, "kWh", installedAt: new DateOnly(2025, 1, 1), baseline: 500);
// A register that never moved all of 2025: every month is a measured zero, from 1 February 2025 to 1 January 2026.
await ReadingsAsync(still, [.. Enumerable.Range(1, 12).Select(i =>
(GapAttribution.LocalMidnight(new DateOnly(2025, 1, 1).AddMonths(i), Berlin), 500d))]);
var year = await Reader().ReadAsync(new AnalysisRequest(AnalysisScope.ForMeter(still), Resolve(PeriodPreset.PreviousYear)) { Bucket = BucketSize.Month });
var series = year.SeriesFor(still)!;
Assert.Equal(12, series.Values.Count);
Assert.All(series.Values, v => AssertAvailable(v, 0));
AssertAvailable(series.Total, 0);
var load = await ConsumptionAsync(type, 100, 80);
var generation = await GenerationAsync(type, 150, 120);
var net = await VirtualAsync(type, $"m{load} - m{generation}", QuantityKind.Net, "kWh", VirtualCostRule.None);
var balance = await Reader().ReadAsync(Request(AnalysisScope.ForMeter(net), JanFeb(), BucketSize.Month));
var netSeries = balance.SeriesFor(net)!;
Assert.Equal(QuantityKind.Net, netSeries.Kind);
AssertValues(netSeries, -50, -40);
AssertAvailable(netSeries.Total, -90);
}
[Fact]
public async Task New_readings_and_corrections_show_in_the_next_read()
{
var type = await TypeAsync();
var meter = await ConsumptionAsync(type, 100);
var other = await ConsumptionAsync(type, 50, 50);
var sum = await VirtualAsync(type, $"m{meter} + m{other}", QuantityKind.Consumption, "kWh", VirtualCostRule.SourceCosts);
var scope = AnalysisScope.ForMeters([meter, sum]);
var before = await Reader().ReadAsync(Request(scope, JanFeb(), BucketSize.Month));
Assert.Equal(BucketStatus.Missing, before.SeriesFor(meter)!.Values[1].Status);
Assert.Equal(BucketStatus.Missing, before.SeriesFor(sum)!.Values[1].Status);
// A new reading through ingestion recomputes the meter's rollups in the same transaction.
await IngestAsync(meter, Mid(2026, 3, 1), 180);
var after = await Reader().ReadAsync(Request(scope, JanFeb(), BucketSize.Month));
AssertValues(after.SeriesFor(meter)!, 100, 80);
AssertValues(after.SeriesFor(sum)!, 150, 130);
// A correction of that reading, and then its removal.
await IngestAsync(meter, Mid(2026, 3, 1), 190);
AssertValues((await Reader().ReadAsync(Request(scope, JanFeb(), BucketSize.Month))).SeriesFor(meter)!, 100, 90);
await using (var db = fx.CreateContext())
{
await db.Readings.Where(r => r.MeterId == meter && r.Time == Mid(2026, 3, 1)).ExecuteDeleteAsync();
}
await RecomputeAsync(BerlinId, meter);
var removed = await Reader().ReadAsync(Request(scope, JanFeb(), BucketSize.Month));
Assert.Equal(BucketStatus.Missing, removed.SeriesFor(meter)!.Values[1].Status);
Assert.Equal(BucketStatus.Missing, removed.SeriesFor(sum)!.Values[1].Status);
}
// ------------------------------------------------------------------------------------------------ comparison
[Fact]
public async Task A_comparison_is_confident_only_over_the_coverage_both_periods_share()
{
var type = await TypeAsync();
var meter = await MeterAsync(type, MeterMode.CumulativeCounter, "kWh", installedAt: new DateOnly(2026, 8, 1));
var august = Enumerable.Range(1, 31).Select(d => (Local(Berlin, 2026, 8, d, 6), 5d * (d - 1)));
var september = Enumerable.Range(1, 19).Select(d => (Local(Berlin, 2026, 9, d, 6), 155d + (10d * (d - 1))));
await ReadingsAsync(meter, [.. august, .. september]);
var request = new AnalysisRequest(AnalysisScope.ForMeter(meter), Resolve(PeriodPreset.MonthToDate))
{
Comparison = new ComparisonRequest(ComparisonKind.PreviousPeriod),
};
var result = await Reader().ReadAsync(request);
var comparison = result.Comparison!;
Assert.True(comparison.IsApplicable);
Assert.Equal(new DateOnly(2026, 8, 1), comparison.Period!.FirstDay);
Assert.True(comparison.Resolution.Period!.IsCutOff);
Assert.Equal(result.Plan.Buckets.Count, comparison.Buckets.Count);
var series = result.SeriesFor(meter)!.Comparison!;
Assert.Equal(result.Plan.Buckets.Count, series.Values.Count);
var piece = Assert.Single(series.Matched.Pieces);
Assert.Equal(new DateOnly(2026, 9, 1), piece.Current.FirstDay);
Assert.Equal(new DateOnly(2026, 8, 1), piece.Comparison.FirstDay);
Assert.Equal(piece.Current.LastDay.Day, piece.Comparison.LastDay.Day);
await using var db = fx.CreateContext();
var current = await DbSumAsync(db, meter, piece.Current.From, piece.Current.To);
var previous = await DbSumAsync(db, meter, piece.Comparison.From, piece.Comparison.To);
Assert.Equal(current, series.CurrentMatched!.Value, 9);
Assert.Equal(previous, series.ComparisonMatched!.Value, 9);
Assert.Equal(current - previous, series.Change.Absolute!.Value, 9);
Assert.Equal((current - previous) / previous * 100, series.Change.Percent!.Value, 6);
Assert.True(series.Change.Direction > 0);
// No comparison with all history, and nothing to compare without data on the other side.
var none = await Reader().ReadAsync(request with { Comparison = new ComparisonRequest(ComparisonKind.PreviousYear) });
Assert.False(none.SeriesFor(meter)!.Comparison!.Matched.IsComparable);
Assert.False(none.SeriesFor(meter)!.Comparison!.Change.IsAvailable);
}
[Fact]
public async Task A_total_compares_with_the_same_months_a_year_earlier()
{
var type = await TypeAsync();
var a = await MeterAsync(type, MeterMode.CumulativeCounter, "kWh", installedAt: new DateOnly(2025, 1, 1));
var b = await MeterAsync(type, MeterMode.CumulativeCounter, "kWh", installedAt: new DateOnly(2025, 1, 1));
// Monthly readings from February 2025 to March 2026: A adds 10 a month in 2025 and 20 in 2026, B 5 and 7.
(DateTimeOffset, double)[] Monthly(double perMonth2025, double perMonth2026)
{
var register = 0d;
var list = new List<(DateTimeOffset, double)>();
for (var month = new DateOnly(2025, 2, 1); month <= new DateOnly(2026, 3, 1); month = month.AddMonths(1))
{
register += month.Year == 2025 || month.Month == 1 ? perMonth2025 : perMonth2026;
list.Add((GapAttribution.LocalMidnight(month, Berlin), register));
}
return [.. list];
}
await ReadingsAsync(a, Monthly(10, 20));
await ReadingsAsync(b, Monthly(5, 7));
var result = await Reader().ReadAsync(Request(AnalysisScope.ForEnergyType(type), JanFeb(), BucketSize.Month) with
{
Comparison = new ComparisonRequest(ComparisonKind.PreviousYear),
});
var use = result.MeasureFor(type, TotalsMeasure.Use)!;
AssertValues(use, 27, 27);
var comparison = use.Comparison!;
Assert.Equal([15d, 15d], comparison.Values.Select(v => v.Value!.Value));
AssertAvailable(comparison.Total, 30);
var piece = Assert.Single(comparison.Matched.Pieces);
Assert.Equal(new DateOnly(2026, 1, 1), piece.Current.FirstDay);
Assert.Equal(new DateOnly(2026, 2, 28), piece.Current.LastDay);
Assert.Equal(new DateOnly(2025, 1, 1), piece.Comparison.FirstDay);
Assert.Equal(54, comparison.CurrentMatched!.Value, 9);
Assert.Equal(30, comparison.ComparisonMatched!.Value, 9);
Assert.Equal(24, comparison.Change.Absolute!.Value, 9);
Assert.Equal(80, comparison.Change.Percent!.Value, 9);
Assert.Equal(new DateOnly(2025, 1, 1), result.Comparison!.Period!.FirstDay);
Assert.Equal(new DateOnly(2025, 2, 28), result.Comparison.Period.LastDay);
// A virtual difference over the same meters compares its formula over the shared coverage: (40 14) vs (20 10).
var difference = await VirtualAsync(type, $"m{a} - m{b}", QuantityKind.Consumption, "kWh", VirtualCostRule.None);
var virtualResult = await Reader().ReadAsync(Request(AnalysisScope.ForMeter(difference), JanFeb(), BucketSize.Month) with
{
Comparison = new ComparisonRequest(ComparisonKind.PreviousYear),
});
var virtualComparison = virtualResult.SeriesFor(difference)!.Comparison!;
Assert.Equal([5d, 5d], virtualComparison.Values.Select(v => v.Value!.Value));
Assert.Equal(26, virtualComparison.CurrentMatched!.Value, 9);
Assert.Equal(10, virtualComparison.ComparisonMatched!.Value, 9);
Assert.Equal(160, virtualComparison.Change.Percent!.Value, 9);
}
[Fact]
public async Task An_opening_balance_makes_its_bucket_partial_and_says_so()
{
// A-01: a first reading without an install date books the register against the baseline with an unknown start.
var type = await TypeAsync();
var meter = await MeterAsync(type, MeterMode.CumulativeCounter, "kWh");
await ReadingsAsync(meter, (Local(Berlin, 2026, 1, 10, 12), 1000), (Local(Berlin, 2026, 1, 20, 12), 1010), (Local(Berlin, 2026, 2, 10, 12), 1030));
var result = await Reader().ReadAsync(Request(AnalysisScope.ForMeter(meter), JanFeb(), BucketSize.Month));
var january = result.SeriesFor(meter)!.Values[0];
Assert.Equal(BucketStatus.Partial, january.Status);
Assert.Equal(ValueIssue.OpeningBalance, january.Issue);
Assert.True(january.Provenance.HasFlag(Provenance.OpeningBalance));
await using var db = fx.CreateContext();
Assert.Equal(await DbSumAsync(db, meter, Mid(2026, 1, 1), Mid(2026, 2, 1)), january.Value!.Value, 9);
Assert.True(result.SeriesFor(meter)!.Coverage![0].OpeningBalance);
var february = result.SeriesFor(meter)!.Values[1];
Assert.Equal(BucketStatus.Partial, february.Status);
Assert.Equal(ValueIssue.PartialCoverage, february.Issue);
Assert.True(february.Provenance.HasFlag(Provenance.Estimated));
Assert.False(february.Provenance.HasFlag(Provenance.OpeningBalance));
}
[Fact]
public async Task Live_sources_are_live_or_stale_by_their_own_rhythm()
{
var type = await TypeAsync();
var live = await MeterAsync(type, MeterMode.CumulativeCounter, "kWh", installedAt: new DateOnly(2026, 9, 1));
var stale = await MeterAsync(type, MeterMode.CumulativeCounter, "kWh", installedAt: new DateOnly(2026, 9, 1));
var polled = await MeterAsync(type, MeterMode.CumulativeCounter, "kWh", installedAt: new DateOnly(2026, 9, 1));
await using (var db = fx.CreateContext())
{
db.MeterSources.AddRange(
new MeterSource { MeterId = live, SourceType = SourceType.Mqtt, Config = """{"topic":"tele/a/SENSOR"}""" },
new MeterSource { MeterId = stale, SourceType = SourceType.Tasmota, Config = """{"topic":"tele/b/SENSOR"}""" },
new MeterSource { MeterId = polled, SourceType = SourceType.HomeAssistant, Config = """{"entityId":"sensor.c","pollMinutes":1440}""" });
await db.SaveChangesAsync();
}
// Hourly up to 14:00 today; hourly until three days ago; polled daily, last read yesterday morning.
await ReadingsAsync(live, [.. Enumerable.Range(0, 15).Select(h => (Local(Berlin, 2026, 9, 19, h), (double)h))]);
await ReadingsAsync(stale, [.. Enumerable.Range(0, 25).Select(h => (Local(Berlin, 2026, 9, 15, 0).AddHours(h), (double)h))]);
await ReadingsAsync(polled, (Local(Berlin, 2026, 9, 17, 6), 1), (Local(Berlin, 2026, 9, 18, 6), 2));
var result = await Reader().ReadAsync(new AnalysisRequest(AnalysisScope.ForMeters([live, stale, polled]), Resolve(PeriodPreset.MonthToDate)));
Assert.Equal(FreshnessState.Live, result.SeriesFor(live)!.Freshness.State);
Assert.Equal(Local(Berlin, 2026, 9, 19, 14), result.SeriesFor(live)!.Freshness.LastActivity);
Assert.Equal(FreshnessState.Stale, result.SeriesFor(stale)!.Freshness.State);
Assert.Equal(TimeSpan.FromHours(3), result.SeriesFor(stale)!.Freshness.StaleAfter);
Assert.Equal(FreshnessState.Live, result.SeriesFor(polled)!.Freshness.State);
Assert.Equal(TimeSpan.FromDays(3), result.SeriesFor(polled)!.Freshness.StaleAfter);
Assert.Contains(result.Problems, p => p.Kind == AnalysisProblemKind.StaleSource && p.MeterId == stale);
Assert.DoesNotContain(result.Problems, p => p.Kind == AnalysisProblemKind.StaleSource && p.MeterId != stale);
}
// ------------------------------------------------------------------------------------------------ limits, availability
[Fact]
public async Task Limits_are_refused_before_anything_is_read()
{
var tooMany = await Reader().ReadAsync(new AnalysisRequest(AnalysisScope.ForMeters(Enumerable.Range(1, 7)), January()));
Assert.Equal(AnalysisRefusal.TooManySeries, tooMany.Refusal);
Assert.Empty(tooMany.Series);
// A caller that reads more than it charts may raise the limit; unknown meters are then named, not read.
var raised = await Reader().ReadAsync(new AnalysisRequest(AnalysisScope.ForMeters([-1, -2, -3, -4, -5, -6, -7]), January()) { MaxSeries = 10 });
Assert.Equal(AnalysisRefusal.None, raised.Refusal);
Assert.Equal(7, raised.Problems.Count(p => p.Kind == AnalysisProblemKind.UnknownMeter));
var days = await Reader().ReadAsync(new AnalysisRequest(AnalysisScope.Portfolio, Resolve(PeriodPreset.Last24Months)) { Bucket = BucketSize.Day });
Assert.Equal(AnalysisRefusal.TooManyPoints, days.Refusal);
Assert.Equal(BucketSize.Week, days.Plan.Suggested);
}
[Fact]
public async Task Cost_availability_includes_manual_costs_and_names_its_basis()
{
var type = await TypeAsync();
var meter = await ConsumptionAsync(type, 100, 80);
await using (var db = fx.CreateContext())
{
var cost = new ManualCost { MeterId = meter, PeriodStart = new DateOnly(2026, 4, 10), PeriodEnd = new DateOnly(2026, 4, 30), Amount = 12 };
db.ManualCosts.Add(cost);
await db.SaveChangesAsync();
_manualCosts.Add(cost.Id);
}
var available = await Reader().GetAvailabilityAsync(AnalysisScope.ForMeter(meter), Now);
Assert.Equal(new DateOnly(2026, 2, 1), available.Quantity!.LatestMonth);
Assert.Equal(new LatestPeriod(new DateOnly(2026, 4, 1), LatestPeriodBasis.Manual), available.Latest);
Assert.Equal(new DateOnly(2026, 4, 10), available.Cost!.LastDay);
Assert.Equal(new DateOnly(2026, 1, 1), available.Cost.FirstDay);
}
// ------------------------------------------------------------------------------------------------ review fixes
[Fact]
public async Task A_tank_draw_booked_in_this_month_but_used_last_month_is_not_a_confident_month_to_date()
{
// Review F1 (D-14): dipsticks on 3 August (1000 L) and 2 September (700 L). The whole 300 L draw is booked on
// 2 September although 29 of its 30 days lie in August, so September to date cannot claim it as an actual.
var type = await TypeAsync("L");
var tank = await MeterAsync(type, MeterMode.ConsumableBalance, "L");
await using (var db = fx.CreateContext())
{
db.Tanks.Add(new Tank { MeterId = tank, Capacity = 5000, Unit = "L" });
db.MeterEvents.AddRange(
new MeterEvent { MeterId = tank, EventType = MeterEventType.TankLevel, Time = Local(Berlin, 2026, 8, 3, 10), Amount = 1000, Unit = "L" },
new MeterEvent { MeterId = tank, EventType = MeterEventType.TankLevel, Time = Local(Berlin, 2026, 9, 2, 10), Amount = 700, Unit = "L" });
await db.SaveChangesAsync();
}
await RecomputeAsync(BerlinId, tank);
var mtd = await Reader().ReadAsync(new AnalysisRequest(AnalysisScope.ForMeter(tank), Resolve(PeriodPreset.MonthToDate)) { Bucket = BucketSize.Month });
var series = mtd.SeriesFor(tank)!;
Assert.Equal(BucketStatus.Unresolved, series.Total.Status);
Assert.Equal(BucketStatus.Unresolved, Assert.Single(series.Values).Status);
// The year holds the whole interval: it is resolved (partial, the tank was first read on 3 August).
var year = await Reader().ReadAsync(new AnalysisRequest(AnalysisScope.ForMeter(tank), Resolve(PeriodPreset.YearToDate)) { Bucket = BucketSize.Year });
Assert.Equal(BucketStatus.Partial, year.SeriesFor(tank)!.Total.Status);
Assert.Equal(300, year.SeriesFor(tank)!.Total.Value!.Value, 9);
}
[Fact]
public async Task Burner_hours_read_mid_month_leave_their_months_unresolved()
{
// Review F1: hours read on 15 January, 14 February and 16 March. February would receive the whole
// 15 Jan - 14 Feb interval, more than half of which lies in January.
var type = await TypeAsync("h");
var burner = await MeterAsync(type, MeterMode.RuntimeCounter, "h", installedAt: new DateOnly(2026, 1, 15), baseline: 1000);
await ReadingsAsync(burner, (Local(Berlin, 2026, 1, 15, 10), 1000), (Local(Berlin, 2026, 2, 14, 10), 1300), (Local(Berlin, 2026, 3, 16, 10), 1350));
var quarter = await Reader().ReadAsync(Request(AnalysisScope.ForMeter(burner), Custom(new DateOnly(2026, 1, 1), new DateOnly(2026, 3, 31)), BucketSize.Month));
var series = quarter.SeriesFor(burner)!;
Assert.Equal([BucketStatus.Unresolved, BucketStatus.Unresolved, BucketStatus.Unresolved], series.Values.Select(v => v.Status));
Assert.All(series.Values, v => Assert.Null(v.Value));
Assert.Equal(BucketStatus.Partial, series.Total.Status);
Assert.Equal(350, series.Total.Value!.Value, 9);
}
[Fact]
public async Task A_reading_stamped_weeks_ahead_does_not_make_this_month_a_confident_zero()
{
// Review F4 (D-04, A-04): monthly readings on 20 July and 20 August; the next one typed as 5 October instead of
// 5 September. Its September share closes on 30 September, after now, so September to date has no actual yet —
// it must not read as a measured zero.
var type = await TypeAsync();
var meter = await MeterAsync(type, MeterMode.CumulativeCounter, "kWh", installedAt: new DateOnly(2026, 7, 1));
await ReadingsAsync(meter, (Local(Berlin, 2026, 7, 20, 9), 100), (Local(Berlin, 2026, 8, 20, 9), 200), (Local(Berlin, 2026, 10, 5, 9), 350));
var mtd = await Reader().ReadAsync(new AnalysisRequest(AnalysisScope.ForMeter(meter), Resolve(PeriodPreset.MonthToDate)) { Bucket = BucketSize.Month });
var series = mtd.SeriesFor(meter)!;
Assert.Equal(BucketStatus.Missing, series.Total.Status);
Assert.Null(series.Total.Value);
Assert.True(Assert.Single(series.RecordedAfterNow).Amount > 0);
// August keeps the share of 20 August - 5 October that closed on 1 September: it is an actual.
var august = await Reader().ReadAsync(Request(AnalysisScope.ForMeter(meter), Custom(new DateOnly(2026, 8, 1), new DateOnly(2026, 8, 31)), BucketSize.Month));
Assert.Equal(BucketStatus.Available, august.SeriesFor(meter)!.Total.Status);
Assert.True(august.SeriesFor(meter)!.Total.Value > 64);
}
[Fact]
public async Task A_carried_forward_label_after_the_current_month_does_not_make_it_a_confident_zero()
{
// Review F4: sheet rows July - October, October carrying September's register forward (a placeholder that is
// not all zero). The standstill joins the month run, so now falls inside an earlier interval of that run.
var type = await TypeAsync();
var meter = await MeterAsync(type, MeterMode.CumulativeCounter, "kWh");
await LabelsAsync(meter, (2026, 7, 100), (2026, 8, 130), (2026, 9, 160), (2026, 10, 160));
var mtd = await Reader().ReadAsync(new AnalysisRequest(AnalysisScope.ForMeter(meter), Resolve(PeriodPreset.MonthToDate)) { Bucket = BucketSize.Month });
Assert.Equal(BucketStatus.Missing, mtd.SeriesFor(meter)!.Total.Status);
var august = await Reader().ReadAsync(Request(AnalysisScope.ForMeter(meter), Custom(new DateOnly(2026, 8, 1), new DateOnly(2026, 8, 31)), BucketSize.Month));
AssertAvailable(august.SeriesFor(meter)!.Total, 30);
}
[Fact]
public async Task A_day_whose_rows_are_withheld_as_recorded_after_now_is_not_a_confident_zero()
{
// Review F5 (A-05, D-14): daily readings at 06:00 since 31 August and the current month's sheet row ("September
// 2026", stamped 1 September 02:00). The label closes on 1 October, so the whole of 1 September is withheld — its
// real 2.5 kWh share of the 31 Aug - 1 Sep interval with it. That day, and the month so far, are not complete.
var type = await TypeAsync();
var meter = await MeterAsync(type, MeterMode.CumulativeCounter, "kWh", installedAt: new DateOnly(2026, 8, 31));
await ReadingsAsync(meter, [.. Enumerable.Range(0, 20).Select(d => (Local(Berlin, 2026, 8, 31, 6).AddDays(d), 10d * d))]);
await LabelsAsync(meter, (2026, 9, 200));
var mtd = await Reader().ReadAsync(new AnalysisRequest(AnalysisScope.ForMeter(meter), Resolve(PeriodPreset.MonthToDate)) { Bucket = BucketSize.Day });
var series = mtd.SeriesFor(meter)!;
Assert.Equal(BucketStatus.Partial, series.Values[0].Status);
Assert.Equal(ValueIssue.RecordedAfterNow, series.Values[0].Issue);
AssertAvailable(series.Values[1], 10);
Assert.Equal(BucketStatus.Partial, series.Total.Status);
Assert.Equal(ValueIssue.RecordedAfterNow, series.Total.Issue);
Assert.Equal(180, series.Total.Value!.Value, 9);
Assert.Equal(12.5, Assert.Single(series.RecordedAfterNow).Amount, 9);
}
[Fact]
public async Task A_comparison_over_coverage_with_nightly_holes_sums_its_pieces_in_the_database()
{
// Review F3 (D-15): a power sensor pushing on change, silent at night. Every night is a sample gap, so the
// coverage both years share is one piece per day. The pieces' partial days are summed by the database over their
// exact bounds; loading their rows would stream the sensor's whole history into memory.
var type = await TypeAsync();
var sensor = await MeterAsync(type, MeterMode.InstantRate, "kW");
var samples = new List<(DateTimeOffset, double)>();
foreach (var year in new[] { 2025, 2026 })
{
for (var day = 5; day <= 9; day++)
{
for (var minutes = 6 * 60; minutes <= 20 * 60; minutes += 10)
{
samples.Add((Local(Berlin, year, 1, day).AddMinutes(minutes), 1 + ((day + minutes / 10) % 4) + (year - 2025)));
}
}
}
await ReadingsAsync(sensor, [.. samples]);
var request = Request(AnalysisScope.ForMeter(sensor), January(), BucketSize.Month) with
{
Comparison = new ComparisonRequest(ComparisonKind.PreviousYear),
};
await using var db = fx.CreateContext();
var catalog = await AnalysisCatalog.LoadAsync(db, Berlin);
await db.Database.OpenConnectionAsync();
var run = new AnalysisRun(db, catalog, request, Berlin);
var result = await run.ExecuteAsync(BucketPlanner.Plan(request.Period, BucketSize.Month), default);
var comparison = result.SeriesFor(sensor)!.Comparison!;
Assert.Equal(5, comparison.Matched.Pieces.Count);
var leaf = run.Leaves[sensor];
Assert.True(leaf.RequestedRawDays.Count <= 4, $"{leaf.RequestedRawDays.Count} days of rows loaded for the matched pieces");
Assert.Equal(10, leaf.RequestedWindows.Count);
// The matched figures are the database's own sums over the pieces.
double current = 0, previous = 0;
foreach (var piece in comparison.Matched.Pieces)
{
current += await DbSumAsync(db, sensor, piece.Current.From, piece.Current.To);
previous += await DbSumAsync(db, sensor, piece.Comparison.From, piece.Comparison.To);
}
Assert.True(current > previous);
Assert.Equal(current, comparison.CurrentMatched!.Value, 9);
Assert.Equal(previous, comparison.ComparisonMatched!.Value, 9);
}
// ------------------------------------------------------------------------------------------------ helpers
private AnalysisReader Reader(string zone = BerlinId) =>
new(fx, Microsoft.Extensions.Options.Options.Create(new MeterVaultOptions { TimeZone = zone }));
private static AnalysisRequest Request(AnalysisScope scope, ResolvedPeriod period, BucketSize size) => new(scope, period) { Bucket = size };
private static ResolvedPeriod Resolve(PeriodPreset preset) => PeriodResolver.Resolve(preset, null, null, Now, Berlin);
private static ResolvedPeriod Custom(DateOnly first, DateOnly last) => PeriodResolver.Resolve(PeriodPreset.Custom, first, last, Now, Berlin);
private static ResolvedPeriod January() => Custom(new DateOnly(2026, 1, 1), new DateOnly(2026, 1, 31));
private static ResolvedPeriod JanFeb() => Custom(new DateOnly(2026, 1, 1), new DateOnly(2026, 2, 28));
private static DateTimeOffset Local(TimeZoneInfo zone, int year, int month, int day, int hour = 0, int minute = 0)
{
var wall = new DateTime(year, month, day, hour, minute, 0);
return new DateTimeOffset(wall, zone.GetUtcOffset(wall)).ToUniversalTime();
}
private static DateTimeOffset Mid(int year, int month, int day) => GapAttribution.LocalMidnight(new DateOnly(year, month, day), Berlin);
private static void AssertAvailable(BucketValue value, double expected)
{
Assert.Equal(BucketStatus.Available, value.Status);
Assert.NotNull(value.Value);
Assert.Equal(expected, value.Value.Value, 9);
}
private static void AssertValues(AnalysisSeries series, params double[] expected)
{
Assert.Equal(expected.Length, series.Values.Count);
for (var i = 0; i < expected.Length; i++)
{
AssertAvailable(series.Values[i], expected[i]);
}
}
private async Task<short> TypeAsync(string unit = "kWh")
{
await using var db = fx.CreateContext();
var type = new EnergyType
{
Key = $"analysis-{Guid.NewGuid():N}",
DisplayName = "Analysis test",
BaseUnit = unit,
DefaultMode = MeterMode.CumulativeCounter,
};
db.EnergyTypes.Add(type);
await db.SaveChangesAsync();
_types.Add(type.Id);
return type.Id;
}
private async Task<int> MeterAsync(
short type, MeterMode mode, string unit, DateOnly? installedAt = null, double baseline = 0, string meta = "{}", DateOnly? retiredAt = null)
{
await using var db = fx.CreateContext();
var meter = new Meter
{
Name = $"reader-{Guid.NewGuid():N}",
EnergyTypeId = type,
Mode = mode,
Unit = unit,
InstalledAt = installedAt,
RetiredAt = retiredAt,
InitialBaseline = baseline,
Meta = meta,
};
db.Meters.Add(meter);
await db.SaveChangesAsync();
_meters.Add(meter.Id);
return meter.Id;
}
/// <summary>A generation counter installed on 1 January 2026 whose months book the given amounts.</summary>
private Task<int> GenerationAsync(short type, params double[] months) => MonthlyAsync(type, MeterMode.GenerationCounter, months);
/// <summary>A consumption counter installed on 1 January 2026 whose months book the given amounts.</summary>
private Task<int> ConsumptionAsync(short type, params double[] months) => MonthlyAsync(type, MeterMode.CumulativeCounter, months);
private async Task<int> MonthlyAsync(short type, MeterMode mode, double[] months)
{
// Installed at the start of January, so the first reading covers January from its install date (no opening balance).
var meter = await MeterAsync(type, mode, "kWh", installedAt: new DateOnly(2026, 1, 1));
var register = 0d;
var readings = new List<(DateTimeOffset, double)>();
for (var i = 0; i < months.Length; i++)
{
register += months[i];
readings.Add((Mid(2026, 2 + i, 1), register));
}
await ReadingsAsync(meter, [.. readings]);
return meter;
}
private async Task<int> VirtualAsync(short type, string expression, QuantityKind kind, string unit, VirtualCostRule rule)
{
var meta = VirtualDefinitionJson.Write("{}", new VirtualDefinition(expression, kind, unit, rule));
var meter = await MeterAsync(type, MeterMode.Virtual, unit, meta: meta);
await RecomputeAsync(BerlinId, meter);
return meter;
}
private async Task SetDefinitionAsync(int meterId, string expression, QuantityKind kind, string unit, VirtualCostRule rule)
{
await using var db = fx.CreateContext();
var meter = await db.Meters.SingleAsync(m => m.Id == meterId);
meter.Meta = VirtualDefinitionJson.Write(meter.Meta, new VirtualDefinition(expression, kind, unit, rule));
await db.SaveChangesAsync();
}
private async Task LinkAsync(int from, int to)
{
await using var db = fx.CreateContext();
db.MeterLinks.Add(new MeterLink { FromMeterId = from, ToMeterId = to });
await db.SaveChangesAsync();
}
private async Task ReadingsAsync(int meterId, params (DateTimeOffset Time, double Value)[] readings)
{
await using (var db = fx.CreateContext())
{
db.Readings.AddRange(readings.Select(r => new Reading
{
MeterId = meterId,
Time = r.Time.ToUniversalTime(),
Value = r.Value,
Quality = ReadingQuality.Manual,
}));
await db.SaveChangesAsync();
}
await RecomputeAsync(BerlinId, meterId);
}
/// <summary>Imported month labels ("Mai 2026"), stamped at 00:00 UTC on the 1st like the importer does.</summary>
private async Task LabelsAsync(int meterId, params (int Year, int Month, double Value)[] labels)
{
await using (var db = fx.CreateContext())
{
db.Readings.AddRange(labels.Select(l => new Reading
{
MeterId = meterId,
Time = new DateTimeOffset(l.Year, l.Month, 1, 0, 0, 0, TimeSpan.Zero),
Value = l.Value,
Quality = ReadingQuality.Imported,
Flags = ReadingFlags.MonthLabel,
}));
await db.SaveChangesAsync();
}
await RecomputeAsync(BerlinId, meterId);
}
/// <summary>One reading per elapsed hour over each stretch, the register rising by one per hour, normalized in <paramref name="zone"/>.</summary>
private async Task HourlyAsync(int meterId, TimeZoneInfo zone, params (DateTimeOffset From, DateTimeOffset To)[] stretches)
{
var register = 0d;
await using (var db = fx.CreateContext())
{
foreach (var (from, to) in stretches)
{
for (var t = from; t <= to; t = t.AddHours(1))
{
db.Readings.Add(new Reading { MeterId = meterId, Time = t.ToUniversalTime(), Value = register, Quality = ReadingQuality.Measured });
register++;
}
// A standstill between the stretches keeps each stretch's first hour out of the numbers.
register--;
}
await db.SaveChangesAsync();
}
await RecomputeAsync(zone.Id, meterId);
}
private async Task IngestAsync(int meterId, DateTimeOffset time, double value)
{
await using var db = fx.CreateContext();
var ingestion = new IngestionService(db, Normalization(db, BerlinId));
await ingestion.IngestByMeterAsync(meterId, time, value);
}
private async Task RecomputeAsync(string zone, params int[] meterIds)
{
await using var db = fx.CreateContext();
await using var tx = await db.Database.BeginTransactionAsync();
foreach (var id in meterIds)
{
await Normalization(db, zone).RecomputeMeterAsync(id, null);
}
await db.SaveChangesAsync();
await tx.CommitAsync();
}
private static NormalizationService Normalization(MeterVaultDbContext db, string zone) =>
new(db, NormalizationEngine.CreateDefault(),
Microsoft.Extensions.Options.Options.Create(new MeterVaultOptions { TimeZone = zone }),
new FixedTimeProvider(Now));
/// <summary>The database's own sum of a meter's consumption over <c>[from, to)</c>: the oracle a reader must match.</summary>
private static async Task<double> DbSumAsync(MeterVaultDbContext db, int meterId, DateTimeOffset from, DateTimeOffset to)
{
var start = from.ToUniversalTime();
var end = to.ToUniversalTime();
return await db.Consumption.Where(c => c.MeterId == meterId && c.Time >= start && c.Time < end).SumAsync(c => c.Amount);
}
/// <summary>
/// The reference meters under fresh names, the three sheets imported into them in Berlin, and the seed's legacy
/// "Summe Solar": virtual, no expression, fed by links from Solar 1 and Solar 2.
/// </summary>
private async Task<ReferenceMeters> ImportReferenceSheetsAsync(MeterVaultDbContext db)
{
await DatabaseSeeder.SeedAsync(db);
var electricity = (await db.EnergyTypes.FirstAsync(t => t.Key == "electricity")).Id;
var water = (await db.EnergyTypes.FirstAsync(t => t.Key == "water")).Id;
var oil = (await db.EnergyTypes.FirstAsync(t => t.Key == "heating_oil")).Id;
var suffix = Guid.NewGuid().ToString("N");
Meter Create(string name, short type, MeterMode mode, string unit, double baseline = 0) =>
new() { Name = $"{name} {suffix}", EnergyTypeId = type, Mode = mode, Unit = unit, InitialBaseline = baseline };
var haus = Create("Haus", electricity, MeterMode.CumulativeCounter, "kWh");
var netz = Create("Netz", electricity, MeterMode.CumulativeCounter, "kWh");
var auto = Create("Auto", electricity, MeterMode.CumulativeCounter, "kWh");
var solar1 = Create("Solar 1", electricity, MeterMode.GenerationCounter, "kWh");
var solar2 = Create("Solar 2", electricity, MeterMode.GenerationCounter, "kWh");
var wasser = Create("Wasser", water, MeterMode.CumulativeCounter, "m3", baseline: 820);
var tank = Create("Öltank", oil, MeterMode.ConsumableBalance, "L");
var burner = Create("Brenner", oil, MeterMode.RuntimeCounter, "h");
var summe = Create("Summe Solar", electricity, MeterMode.Virtual, "kWh");
db.Meters.AddRange(haus, netz, auto, solar1, solar2, wasser, tank, burner, summe);
await db.SaveChangesAsync();
_meters.AddRange([haus.Id, netz.Id, auto.Id, solar1.Id, solar2.Id, wasser.Id, tank.Id, burner.Id, summe.Id]);
db.Tanks.Add(new Tank
{
MeterId = tank.Id,
Capacity = 7000,
Unit = "L",
Calibration = MeterConfigFactory.SerializeCalibration(new CalibrationCurve(ReferenceProfiles.OilLitresPerCm)),
});
db.MeterLinks.AddRange(
new MeterLink { FromMeterId = solar1.Id, ToMeterId = summe.Id },
new MeterLink { FromMeterId = solar2.Id, ToMeterId = summe.Id });
await db.SaveChangesAsync();
var ids = new ReferenceMeterIds(haus.Id, netz.Id, auto.Id, solar1.Id, solar2.Id, wasser.Id, tank.Id, burner.Id);
var import = new ImportService(db, Normalization(db, BerlinId));
foreach (var (profile, file) in new[]
{
(ReferenceProfiles.Electricity(ids), Electricity),
(ReferenceProfiles.Water(ids), Water),
(ReferenceProfiles.HeatingOil(ids), Oil),
})
{
await import.CommitAsync(Stage(profile, file), file, null);
}
await RecomputeAsync(BerlinId, summe.Id);
db.ChangeTracker.Clear();
return new ReferenceMeters(solar1.Id, solar2.Id, wasser.Id, summe.Id);
}
private sealed record ReferenceMeters(int Solar1, int Solar2, int Wasser, int SummeSolar);
}