Meter detail: lead with periods and change, not register totals
ci / build-test (push) Successful in 1m23s

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
This commit is contained in:
2026-07-18 19:10:32 +02:00
parent 62d102c335
commit 95c51842e8
5 changed files with 435 additions and 32 deletions
@@ -8,6 +8,47 @@ public sealed record ReadingRow(DateTimeOffset Time, double Value, ReadingQualit
/// <summary>A normalized consumption row for the meter-detail table.</summary>
public sealed record ConsumptionDetailRow(DateTimeOffset Time, double Amount, ConsumptionKind Kind, ReadingQuality Quality);
/// <summary>One calendar month of a meter's normalized history, bucketed in the instance timezone.</summary>
public sealed record MeterMonthPoint(DateOnly Month, double Amount, double Cost);
/// <summary>
/// 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 <see cref="Label"/> says which.
/// </summary>
/// <remarks>
/// Month- and year-to-date are compared against a <em>projection</em> 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.
/// </remarks>
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<MeterMonthPoint> Last12Months)
{
/// <summary>Projected month against last month, as a fraction (+0.12 = 12% more). Null if no basis.</summary>
public double? MonthChange => Ratio(MonthProjected, LastMonth);
/// <summary>Projected year against last year, as a fraction. Null if no basis.</summary>
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;
}
/// <summary>A meter lifecycle/correction event row.</summary>
public sealed record EventRow(DateTimeOffset Time, MeterEventType Type, double? Amount, double? PrevValue, double? NewValue, string? Unit, string? Notes);
@@ -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;
/// <summary>
/// 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.
/// </summary>
public sealed class MeterPeriodService(
IDbContextFactory<MeterVaultDbContext> contextFactory,
Costing.CostService costs,
IOptions<MeterVaultOptions> options)
{
private readonly IDbContextFactory<MeterVaultDbContext> _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<MeterPeriodView?> 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<MonthlyRow>(
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));
}
/// <summary>
/// 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.
/// </summary>
private static double Project(double soFar, int elapsed, int total) =>
elapsed <= 0 ? soFar : soFar / elapsed * total;
private static double SumYear(Dictionary<DateOnly, double> byMonth, int year) =>
byMonth.Where(kv => kv.Key.Year == year).Sum(kv => kv.Value);
private static IReadOnlyList<MeterMonthPoint> BuildHistory(
Dictionary<DateOnly, double> months, Dictionary<DateOnly, double> costs, DateOnly thisMonth)
{
var history = new List<MeterMonthPoint>(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<Dictionary<DateOnly, double>> 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));
}
/// <summary>
/// Dapper row shape — it maps by column name, so a value tuple will not do, and Npgsql surfaces
/// a <c>date</c> column as <see cref="DateOnly"/>.
/// </summary>
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;
}
}
}
@@ -43,6 +43,7 @@ public static class DependencyInjection
services.AddScoped<Costing.CostService>();
services.AddScoped<Dashboard.DashboardService>();
services.AddScoped<Dashboard.SolarService>();
services.AddScoped<Dashboard.MeterPeriodService>();
services.AddScoped<Dashboard.ConsumableService>();
services.AddScoped<Dashboard.MeterDetailService>();
services.AddScoped<Dashboard.FlowService>();