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; /// /// 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. /// [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); /// The frozen "now" of every request here (D-01). private static readonly DateTimeOffset Now = Local(Berlin, 2026, 9, 19, 14, 37); private readonly List _meters = []; private readonly List _types = []; private readonly List _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 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(() => 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 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 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; } /// A generation counter installed on 1 January 2026 whose months book the given amounts. private Task GenerationAsync(short type, params double[] months) => MonthlyAsync(type, MeterMode.GenerationCounter, months); /// A consumption counter installed on 1 January 2026 whose months book the given amounts. private Task ConsumptionAsync(short type, params double[] months) => MonthlyAsync(type, MeterMode.CumulativeCounter, months); private async Task 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 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); } /// Imported month labels ("Mai 2026"), stamped at 00:00 UTC on the 1st like the importer does. 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); } /// One reading per elapsed hour over each stretch, the register rising by one per hour, normalized in . 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)); /// The database's own sum of a meter's consumption over [from, to): the oracle a reader must match. private static async Task 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); } /// /// 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. /// private async Task 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); }