From 95c51842e810eb7b99bc2d01b5570fe3ff21ca4d Mon Sep 17 00:00:00 2001 From: Florian Schmidt Date: Sat, 18 Jul 2026 19:10:32 +0200 Subject: [PATCH] Meter detail: lead with periods and change, not register totals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The headline tiles were lifetime consumption, a raw reading count and the register span. None of those answer why someone opens a meter: how much this month, more or less than last, where the year lands, what it costs. A cumulative counter's register value is an accident of when the meter was installed. MeterPeriodService buckets consumption by calendar month in the instance timezone -- via date_trunc(... AT TIME ZONE) rather than EF grouping, because a reading at 00:30 local on 1 January is 23:30 on 31 December in UTC and would be booked to the wrong month (SDD §10). It reports generation for a generation counter and consumption otherwise, so a PV meter stops claiming it consumed 0 kWh. Month- and year-to-date are compared against a projection of the current period rather than its running total. Three days into a month, "12 kWh vs 340 kWh last month" reads as a collapse in usage when nothing has changed. The projection is straight-line on elapsed days -- wrong for anything seasonal, but the honest reading of "at this rate" -- and the UI marks it with a leading ~. A 12-month bar strip gives the shape at a glance. A meter with nothing normalized yet returns an empty history rather than a flat line, which would look like a meter reading zero. Register span, reading count and lifetime total move into a collapsed panel. Still there when needed for an audit, no longer the first thing you see. Claude-Session: https://claude.ai/code/session_01V6joyergfvVLFEizH1hJLd --- src/App/Components/Pages/MeterDetail.razor | 150 +++++++++++++---- .../Dashboard/MeterDetailModels.cs | 41 +++++ .../Dashboard/MeterPeriodService.cs | 151 ++++++++++++++++++ src/Infrastructure/DependencyInjection.cs | 1 + .../MeterPeriodServiceTests.cs | 124 ++++++++++++++ 5 files changed, 435 insertions(+), 32 deletions(-) create mode 100644 src/Infrastructure/Dashboard/MeterPeriodService.cs create mode 100644 tests/Integration.Tests/MeterPeriodServiceTests.cs diff --git a/src/App/Components/Pages/MeterDetail.razor b/src/App/Components/Pages/MeterDetail.razor index ad009d6..952f6df 100644 --- a/src/App/Components/Pages/MeterDetail.razor +++ b/src/App/Components/Pages/MeterDetail.razor @@ -1,5 +1,6 @@ @page "/meters/{Id:int}" @inject MeterDetailService Details +@inject MeterPeriodService Periods @inject Microsoft.EntityFrameworkCore.IDbContextFactory DbFactory @inject ISnackbar Snackbar @inject IDialogService DialogService @@ -34,42 +35,98 @@ else } - - - - Consumption - @Format.Number(_detail.TotalConsumption, 0) @_detail.Unit - - - @if (_detail.TotalGeneration != 0) - { - + @if (_periods is { } p) + { + + - Generation - @Format.Number(_detail.TotalGeneration, 0) @_detail.Unit + @p.Label this month + @Format.Number(p.MonthToDate, 0) @p.Unit + @if (p.MonthIsPartial) + { + + ≈ @Format.Number(p.MonthProjected, 0) @p.Unit by month end + + } + + + vs last month + @ChangeText(p.MonthChange) + + last month @Format.Number(p.LastMonth, 0) @p.Unit + + + + + + This year + @Format.Number(p.YearToDate, 0) @p.Unit + + @ChangeText(p.YearChange) vs @Format.Number(p.LastYear, 0) last year + + + + + + Cost this year + @Format.Number(p.YearToDateCost, 2) @p.Currency + + ≈ @Format.Number(p.YearProjectedCost, 0) @p.Currency full year + @(p.LastYearCost > 0 ? $"· {Format.Number(p.LastYearCost, 0)} last year" : "") + + + + + + @if (p.HasHistory) + { + + Last 12 months +
+ @foreach (var m in p.Last12Months) + { +
+
+
+
+ @m.Month.ToString("MMM") +
+ } +
+
} - - - Readings - @_detail.ReadingCount - - @(_detail.FirstReadingTime?.ToString("yyyy-MM") ?? "—") … @(_detail.LastReadingTime?.ToString("yyyy-MM") ?? "—") - - - - - - Register span - - @(_detail.FirstReadingValue is { } f ? Format.Number(f, 0) : "—") → - @(_detail.LastReadingValue is { } l ? Format.Number(l, 0) : "—") - - baseline @Format.Number(_detail.InitialBaseline, 0) - - -
+ } + + + +
+
+ Register span + + @(_detail.FirstReadingValue is { } f ? Format.Number(f, 0) : "—") → + @(_detail.LastReadingValue is { } l ? Format.Number(l, 0) : "—") + (baseline @Format.Number(_detail.InitialBaseline, 0)) + +
+
+ Readings + + @_detail.ReadingCount · + @(_detail.FirstReadingTime?.ToString("yyyy-MM") ?? "—") … @(_detail.LastReadingTime?.ToString("yyyy-MM") ?? "—") + +
+
+ Lifetime total + + @Format.Number(_detail.TotalGeneration != 0 ? _detail.TotalGeneration : _detail.TotalConsumption, 0) @_detail.Unit + +
+
+
+
@@ -279,6 +336,7 @@ else public int Id { get; set; } private MeterDetailView? _detail; + private MeterPeriodView? _periods; private bool _notFound; private List _sources = []; private List _endpoints = []; @@ -289,15 +347,43 @@ else protected override async Task OnParametersSetAsync() { _detail = null; + _periods = null; _notFound = false; _detail = await Details.GetAsync(Id); _notFound = _detail is null; if (_detail is not null) { + _periods = await Periods.GetAsync(Id); await LoadSourcesAsync(); } } + /// + /// "+12%" / "−4%" against the previous period. Less is better for consumption and worse for + /// generation, so colour is left to the caller's context rather than hardcoded green/red here. + /// + private static string ChangeText(double? change) + { + if (change is not { } c) + { + return "no basis yet"; + } + + return Math.Abs(c) < 0.005 + ? "about the same" + : $"{(c > 0 ? "+" : "−")}{Format.Number(Math.Abs(c) * 100, 0)}%"; + } + + private static string BarStyle(double amount, IReadOnlyList history) + { + var peak = history.Max(h => Math.Abs(h.Amount)); + var fraction = peak < 1e-9 ? 0 : Math.Abs(amount) / peak; + // Floor at 2% so a month with a little usage is still visibly distinct from an empty one. + var height = amount == 0 ? 0 : Math.Max(2, fraction * 100); + return $"width:100%; height:{height.ToString("0.#", System.Globalization.CultureInfo.InvariantCulture)}%; " + + "background:var(--mud-palette-primary); border-radius:2px 2px 0 0"; + } + private async Task LoadSourcesAsync() { await using var db = await DbFactory.CreateDbContextAsync(); diff --git a/src/Infrastructure/Dashboard/MeterDetailModels.cs b/src/Infrastructure/Dashboard/MeterDetailModels.cs index 654340a..4bfe198 100644 --- a/src/Infrastructure/Dashboard/MeterDetailModels.cs +++ b/src/Infrastructure/Dashboard/MeterDetailModels.cs @@ -8,6 +8,47 @@ public sealed record ReadingRow(DateTimeOffset Time, double Value, ReadingQualit /// A normalized consumption row for the meter-detail table. public sealed record ConsumptionDetailRow(DateTimeOffset Time, double Amount, ConsumptionKind Kind, ReadingQuality Quality); +/// One calendar month of a meter's normalized history, bucketed in the instance timezone. +public sealed record MeterMonthPoint(DateOnly Month, double Amount, double Cost); + +/// +/// A meter framed the way it is actually read: what it used this period, how that compares with the +/// last one, and where the year is heading. Amounts are generation for a generation counter and +/// consumption otherwise, so says which. +/// +/// +/// Month- and year-to-date are compared against a projection of the current period rather +/// than its raw running total: three days into a month, "12 kWh vs 340 kWh last month" reads as a +/// collapse in usage when nothing has changed. Projections are flagged so the UI can mark them. +/// +public sealed record MeterPeriodView( + string Label, + string Unit, + string Currency, + double MonthToDate, + double MonthProjected, + double LastMonth, + double YearToDate, + double YearProjected, + double LastYear, + double YearToDateCost, + double YearProjectedCost, + double LastYearCost, + bool MonthIsPartial, + IReadOnlyList Last12Months) +{ + /// Projected month against last month, as a fraction (+0.12 = 12% more). Null if no basis. + public double? MonthChange => Ratio(MonthProjected, LastMonth); + + /// Projected year against last year, as a fraction. Null if no basis. + public double? YearChange => Ratio(YearProjected, LastYear); + + public bool HasHistory => Last12Months.Count > 0; + + private static double? Ratio(double current, double previous) => + Math.Abs(previous) < 1e-9 ? null : (current - previous) / previous; +} + /// A meter lifecycle/correction event row. public sealed record EventRow(DateTimeOffset Time, MeterEventType Type, double? Amount, double? PrevValue, double? NewValue, string? Unit, string? Notes); diff --git a/src/Infrastructure/Dashboard/MeterPeriodService.cs b/src/Infrastructure/Dashboard/MeterPeriodService.cs new file mode 100644 index 0000000..be28b5f --- /dev/null +++ b/src/Infrastructure/Dashboard/MeterPeriodService.cs @@ -0,0 +1,151 @@ +using Dapper; +using MeterVault.Core.Domain; +using MeterVault.Infrastructure.Options; +using MeterVault.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace MeterVault.Infrastructure.Dashboard; + +/// +/// Answers the questions a meter is actually read for (SDD §8.6): how much this month, how that +/// compares with last month, where the year lands, what it costs. Register totals answer none of +/// those — a cumulative counter's value is an accident of when the meter was installed. +/// +public sealed class MeterPeriodService( + IDbContextFactory contextFactory, + Costing.CostService costs, + IOptions options) +{ + private readonly IDbContextFactory _contextFactory = contextFactory; + private readonly Costing.CostService _costs = costs; + private readonly MeterVaultOptions _options = options.Value; + + // Monthly buckets in the instance timezone, not UTC: a reading at 00:30 local on 1 January is + // 23:30 on 31 December in UTC, and would otherwise be booked to the wrong month (SDD §10). + private const string MonthlySql = """ + SELECT date_trunc('month', time AT TIME ZONE @tz)::date AS month, + sum(amount) AS amount + FROM consumption + WHERE meter_id = @meterId AND kind = @kind AND time >= @from + GROUP BY 1 + ORDER BY 1 + """; + + public async Task GetAsync(int meterId, CancellationToken cancellationToken = default) + { + await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + + var meter = await db.Meters.AsNoTracking() + .FirstOrDefaultAsync(m => m.Id == meterId, cancellationToken).ConfigureAwait(false); + if (meter is null) + { + return null; + } + + var tz = ResolveTimeZone(); + var today = DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, tz).Date); + + // A generation counter's output is generation, not consumption — reporting 0 kWh consumed + // for a working PV array is technically true and completely useless. + var isGeneration = meter.Mode == MeterMode.GenerationCounter; + var kind = isGeneration ? ConsumptionKind.Generation : ConsumptionKind.Consumption; + + // From the start of last year: enough for last-year totals and a rolling 12-month history. + var from = new DateTimeOffset(new DateTime(today.Year - 1, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + + var months = (await db.Database.GetDbConnection() + .QueryAsync( + MonthlySql, + new { tz = _options.TimeZone, meterId, kind = (short)kind, from }) + .ConfigureAwait(false)) + .ToDictionary(r => r.Month, r => r.Amount); + + var costs = await LoadCostsAsync(meterId, from, cancellationToken).ConfigureAwait(false); + + var thisMonth = new DateOnly(today.Year, today.Month, 1); + var lastMonth = thisMonth.AddMonths(-1); + + var monthToDate = months.GetValueOrDefault(thisMonth); + var daysInMonth = DateTime.DaysInMonth(today.Year, today.Month); + var monthPartial = today.Day < daysInMonth; + + var yearToDate = SumYear(months, today.Year); + var lastYear = SumYear(months, today.Year - 1); + var dayOfYear = today.DayOfYear; + var daysInYear = DateTime.IsLeapYear(today.Year) ? 366 : 365; + + var yearToDateCost = SumYear(costs, today.Year); + + return new MeterPeriodView( + Label: isGeneration ? "Generation" : "Consumption", + Unit: meter.Unit, + Currency: _options.Currency, + MonthToDate: monthToDate, + MonthProjected: Project(monthToDate, today.Day, daysInMonth), + LastMonth: months.GetValueOrDefault(lastMonth), + YearToDate: yearToDate, + YearProjected: Project(yearToDate, dayOfYear, daysInYear), + LastYear: lastYear, + YearToDateCost: yearToDateCost, + YearProjectedCost: Project(yearToDateCost, dayOfYear, daysInYear), + LastYearCost: SumYear(costs, today.Year - 1), + MonthIsPartial: monthPartial, + Last12Months: BuildHistory(months, costs, thisMonth)); + } + + /// + /// Scales a partial period to its full length. Straight-line on elapsed days: it assumes the + /// rest of the period looks like what came before, which is wrong for anything seasonal but is + /// the honest reading of "at this rate". The UI marks these as projections. + /// + private static double Project(double soFar, int elapsed, int total) => + elapsed <= 0 ? soFar : soFar / elapsed * total; + + private static double SumYear(Dictionary byMonth, int year) => + byMonth.Where(kv => kv.Key.Year == year).Sum(kv => kv.Value); + + private static IReadOnlyList BuildHistory( + Dictionary months, Dictionary costs, DateOnly thisMonth) + { + var history = new List(12); + for (var offset = 11; offset >= 0; offset--) + { + var month = thisMonth.AddMonths(-offset); + history.Add(new MeterMonthPoint(month, months.GetValueOrDefault(month), costs.GetValueOrDefault(month))); + } + + // All-zero history means the meter has no normalized data yet; say nothing rather than + // drawing a flat line that looks like a meter reading zero. + return history.All(p => Math.Abs(p.Amount) < 1e-9) ? [] : history; + } + + private async Task> LoadCostsAsync( + int meterId, DateTimeOffset from, CancellationToken cancellationToken) + { + var buckets = await _costs.GetMeterCostsAsync( + meterId, from, DateTimeOffset.UtcNow, Costing.CostBucket.Month, cancellationToken).ConfigureAwait(false); + + return buckets + .GroupBy(b => new DateOnly(b.Period.Year, b.Period.Month, 1)) + .ToDictionary(g => g.Key, g => g.Sum(b => b.Cost)); + } + + /// + /// Dapper row shape — it maps by column name, so a value tuple will not do, and Npgsql surfaces + /// a date column as . + /// + private sealed record MonthlyRow(DateOnly Month, double Amount); + + private TimeZoneInfo ResolveTimeZone() + { + try + { + return TimeZoneInfo.FindSystemTimeZoneById(_options.TimeZone); + } + catch (Exception ex) when (ex is TimeZoneNotFoundException or InvalidTimeZoneException) + { + return TimeZoneInfo.Utc; + } + } +} diff --git a/src/Infrastructure/DependencyInjection.cs b/src/Infrastructure/DependencyInjection.cs index 02ee1a3..5183533 100644 --- a/src/Infrastructure/DependencyInjection.cs +++ b/src/Infrastructure/DependencyInjection.cs @@ -43,6 +43,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/tests/Integration.Tests/MeterPeriodServiceTests.cs b/tests/Integration.Tests/MeterPeriodServiceTests.cs new file mode 100644 index 0000000..83927b1 --- /dev/null +++ b/tests/Integration.Tests/MeterPeriodServiceTests.cs @@ -0,0 +1,124 @@ +using MeterVault.Core.Domain; +using MeterVault.Infrastructure.Costing; +using MeterVault.Infrastructure.Dashboard; +using MeterVault.Infrastructure.Options; +using MeterVault.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace MeterVault.Integration.Tests; + +/// +/// The meter-detail headline numbers: month/year totals bucketed in the instance timezone, and the +/// generation-vs-consumption split that decides which of the two a meter reports. +/// +[Collection("Timescale")] +public sealed class MeterPeriodServiceTests(TimescaleFixture fx) +{ + [Fact] + public async Task Buckets_by_local_month_and_compares_with_the_previous_one() + { + await using var db = fx.CreateContext(); + var meterId = await SetupAsync(db, MeterMode.CumulativeCounter); + var today = DateOnly.FromDateTime(DateTime.UtcNow); + var thisMonth = new DateOnly(today.Year, today.Month, 1); + var lastMonth = thisMonth.AddMonths(-1); + + await AddConsumptionAsync(db, meterId, ConsumptionKind.Consumption, thisMonth.AddDays(1), 30); + await AddConsumptionAsync(db, meterId, ConsumptionKind.Consumption, lastMonth.AddDays(3), 100); + + var view = await NewService().GetAsync(meterId); + + Assert.NotNull(view); + Assert.Equal("Consumption", view!.Label); + Assert.Equal(30d, view.MonthToDate, 3); + Assert.Equal(100d, view.LastMonth, 3); + Assert.Equal(130d, view.YearToDate, 3); + + // Projection scales the partial month up, so it must be at least what has already happened. + Assert.True(view.MonthProjected >= view.MonthToDate); + + await CleanupAsync(db, meterId); + } + + [Fact] + public async Task A_generation_counter_reports_generation_not_consumption() + { + // Regression: a PV meter showed "0 kWh consumption", which is true and useless. + await using var db = fx.CreateContext(); + var meterId = await SetupAsync(db, MeterMode.GenerationCounter); + var today = DateOnly.FromDateTime(DateTime.UtcNow); + + await AddConsumptionAsync( + db, meterId, ConsumptionKind.Generation, new DateOnly(today.Year, today.Month, 1).AddDays(1), 42); + + var view = await NewService().GetAsync(meterId); + + Assert.NotNull(view); + Assert.Equal("Generation", view!.Label); + Assert.Equal(42d, view.MonthToDate, 3); + + await CleanupAsync(db, meterId); + } + + [Fact] + public async Task A_meter_with_no_consumption_yields_an_empty_history_rather_than_a_flat_line() + { + await using var db = fx.CreateContext(); + var meterId = await SetupAsync(db, MeterMode.CumulativeCounter); + + var view = await NewService().GetAsync(meterId); + + Assert.NotNull(view); + Assert.False(view!.HasHistory); + Assert.Empty(view.Last12Months); + Assert.Null(view.MonthChange); // no previous month to divide by + + await CleanupAsync(db, meterId); + } + + private MeterPeriodService NewService() + { + var options = Microsoft.Extensions.Options.Options.Create( + new MeterVaultOptions { TimeZone = "Europe/Berlin", Currency = "EUR" }); + return new MeterPeriodService(fx, new CostService(fx), options); + } + + private static async Task SetupAsync(MeterVaultDbContext db, MeterMode mode) + { + await DatabaseSeeder.SeedAsync(db); + var type = await db.EnergyTypes.FirstAsync(t => t.Key == "electricity"); + + var meter = new Meter + { + Name = $"period-{Guid.NewGuid():N}", + EnergyTypeId = type.Id, + Mode = mode, + Unit = "kWh", + }; + db.Meters.Add(meter); + await db.SaveChangesAsync(); + return meter.Id; + } + + private static async Task AddConsumptionAsync( + MeterVaultDbContext db, int meterId, ConsumptionKind kind, DateOnly day, double amount) + { + db.Consumption.Add(new Consumption + { + MeterId = meterId, + // Midday local, so the row cannot drift into an adjacent month through the UTC offset. + Time = new DateTimeOffset(day.Year, day.Month, day.Day, 12, 0, 0, TimeSpan.Zero), + Kind = kind, + Amount = amount, + Quality = ReadingQuality.Measured, + }); + await db.SaveChangesAsync(); + } + + private static async Task CleanupAsync(MeterVaultDbContext db, int meterId) + { + await db.Consumption.Where(c => c.MeterId == meterId).ExecuteDeleteAsync(); + await db.Meters.Where(m => m.Id == meterId).ExecuteDeleteAsync(); + } +}