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
+118 -32
View File
@@ -1,5 +1,6 @@
@page "/meters/{Id:int}" @page "/meters/{Id:int}"
@inject MeterDetailService Details @inject MeterDetailService Details
@inject MeterPeriodService Periods
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory @inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@inject IDialogService DialogService @inject IDialogService DialogService
@@ -34,42 +35,98 @@ else
} }
</div> </div>
<MudGrid Class="mb-2"> @if (_periods is { } p)
<MudItem xs="6" sm="3"> {
<MudPaper Class="pa-3" Elevation="2"> <MudGrid Class="mb-2">
<MudText Typo="Typo.overline" Color="Color.Secondary">Consumption</MudText> <MudItem xs="12" sm="6" md="3">
<MudText Typo="Typo.h6">@Format.Number(_detail.TotalConsumption, 0) @_detail.Unit</MudText>
</MudPaper>
</MudItem>
@if (_detail.TotalGeneration != 0)
{
<MudItem xs="6" sm="3">
<MudPaper Class="pa-3" Elevation="2"> <MudPaper Class="pa-3" Elevation="2">
<MudText Typo="Typo.overline" Color="Color.Secondary">Generation</MudText> <MudText Typo="Typo.overline" Color="Color.Secondary">@p.Label this month</MudText>
<MudText Typo="Typo.h6">@Format.Number(_detail.TotalGeneration, 0) @_detail.Unit</MudText> <MudText Typo="Typo.h6">@Format.Number(p.MonthToDate, 0) @p.Unit</MudText>
@if (p.MonthIsPartial)
{
<MudText Typo="Typo.caption" Color="Color.Secondary">
≈ @Format.Number(p.MonthProjected, 0) @p.Unit by month end
</MudText>
}
</MudPaper> </MudPaper>
</MudItem> </MudItem>
<MudItem xs="12" sm="6" md="3">
<MudPaper Class="pa-3" Elevation="2">
<MudText Typo="Typo.overline" Color="Color.Secondary">vs last month</MudText>
<MudText Typo="Typo.h6">@ChangeText(p.MonthChange)</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">
last month @Format.Number(p.LastMonth, 0) @p.Unit
</MudText>
</MudPaper>
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudPaper Class="pa-3" Elevation="2">
<MudText Typo="Typo.overline" Color="Color.Secondary">This year</MudText>
<MudText Typo="Typo.h6">@Format.Number(p.YearToDate, 0) @p.Unit</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">
@ChangeText(p.YearChange) vs @Format.Number(p.LastYear, 0) last year
</MudText>
</MudPaper>
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudPaper Class="pa-3" Elevation="2">
<MudText Typo="Typo.overline" Color="Color.Secondary">Cost this year</MudText>
<MudText Typo="Typo.h6">@Format.Number(p.YearToDateCost, 2) @p.Currency</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">
≈ @Format.Number(p.YearProjectedCost, 0) @p.Currency full year
@(p.LastYearCost > 0 ? $"· {Format.Number(p.LastYearCost, 0)} last year" : "")
</MudText>
</MudPaper>
</MudItem>
</MudGrid>
@if (p.HasHistory)
{
<MudPaper Class="pa-3 mb-2" Elevation="2">
<MudText Typo="Typo.overline" Color="Color.Secondary">Last 12 months</MudText>
<div class="d-flex align-end mt-2" style="gap:.35rem; height:110px">
@foreach (var m in p.Last12Months)
{
<div class="d-flex flex-column align-center" style="flex:1; height:100%">
<div style="flex:1; display:flex; align-items:flex-end; width:100%">
<div title="@($"{m.Month:yyyy-MM}: {Format.Number(m.Amount, 0)} {p.Unit}")"
style="@BarStyle(m.Amount, p.Last12Months)"></div>
</div>
<MudText Typo="Typo.caption" Color="Color.Secondary">@m.Month.ToString("MMM")</MudText>
</div>
}
</div>
</MudPaper>
} }
<MudItem xs="6" sm="3"> }
<MudPaper Class="pa-3" Elevation="2">
<MudText Typo="Typo.overline" Color="Color.Secondary">Readings</MudText> <MudExpansionPanels Elevation="0" Class="mb-2">
<MudText Typo="Typo.h6">@_detail.ReadingCount</MudText> <MudExpansionPanel Text="Meter register details">
<MudText Typo="Typo.caption" Color="Color.Secondary"> <div class="d-flex flex-wrap" style="gap:2rem">
@(_detail.FirstReadingTime?.ToString("yyyy-MM") ?? "—") … @(_detail.LastReadingTime?.ToString("yyyy-MM") ?? "—") <div>
</MudText> <MudText Typo="Typo.overline" Color="Color.Secondary">Register span</MudText>
</MudPaper> <MudText Typo="Typo.body1">
</MudItem> @(_detail.FirstReadingValue is { } f ? Format.Number(f, 0) : "—") →
<MudItem xs="6" sm="3"> @(_detail.LastReadingValue is { } l ? Format.Number(l, 0) : "—")
<MudPaper Class="pa-3" Elevation="2"> (baseline @Format.Number(_detail.InitialBaseline, 0))
<MudText Typo="Typo.overline" Color="Color.Secondary">Register span</MudText> </MudText>
<MudText Typo="Typo.h6"> </div>
@(_detail.FirstReadingValue is { } f ? Format.Number(f, 0) : "—") → <div>
@(_detail.LastReadingValue is { } l ? Format.Number(l, 0) : "—") <MudText Typo="Typo.overline" Color="Color.Secondary">Readings</MudText>
</MudText> <MudText Typo="Typo.body1">
<MudText Typo="Typo.caption" Color="Color.Secondary">baseline @Format.Number(_detail.InitialBaseline, 0)</MudText> @_detail.ReadingCount ·
</MudPaper> @(_detail.FirstReadingTime?.ToString("yyyy-MM") ?? "—") … @(_detail.LastReadingTime?.ToString("yyyy-MM") ?? "—")
</MudItem> </MudText>
</MudGrid> </div>
<div>
<MudText Typo="Typo.overline" Color="Color.Secondary">Lifetime total</MudText>
<MudText Typo="Typo.body1">
@Format.Number(_detail.TotalGeneration != 0 ? _detail.TotalGeneration : _detail.TotalConsumption, 0) @_detail.Unit
</MudText>
</div>
</div>
</MudExpansionPanel>
</MudExpansionPanels>
<MudTabs Elevation="2" Rounded="true" ApplyEffectsToContainer="true" Class="mt-2"> <MudTabs Elevation="2" Rounded="true" ApplyEffectsToContainer="true" Class="mt-2">
<MudTabPanel Text="@($"Readings ({_detail.ReadingCount})")"> <MudTabPanel Text="@($"Readings ({_detail.ReadingCount})")">
@@ -279,6 +336,7 @@ else
public int Id { get; set; } public int Id { get; set; }
private MeterDetailView? _detail; private MeterDetailView? _detail;
private MeterPeriodView? _periods;
private bool _notFound; private bool _notFound;
private List<MeterSource> _sources = []; private List<MeterSource> _sources = [];
private List<IngestionEndpoint> _endpoints = []; private List<IngestionEndpoint> _endpoints = [];
@@ -289,15 +347,43 @@ else
protected override async Task OnParametersSetAsync() protected override async Task OnParametersSetAsync()
{ {
_detail = null; _detail = null;
_periods = null;
_notFound = false; _notFound = false;
_detail = await Details.GetAsync(Id); _detail = await Details.GetAsync(Id);
_notFound = _detail is null; _notFound = _detail is null;
if (_detail is not null) if (_detail is not null)
{ {
_periods = await Periods.GetAsync(Id);
await LoadSourcesAsync(); await LoadSourcesAsync();
} }
} }
/// <summary>
/// "+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.
/// </summary>
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<MeterMonthPoint> 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() private async Task LoadSourcesAsync()
{ {
await using var db = await DbFactory.CreateDbContextAsync(); await using var db = await DbFactory.CreateDbContextAsync();
@@ -8,6 +8,47 @@ public sealed record ReadingRow(DateTimeOffset Time, double Value, ReadingQualit
/// <summary>A normalized consumption row for the meter-detail table.</summary> /// <summary>A normalized consumption row for the meter-detail table.</summary>
public sealed record ConsumptionDetailRow(DateTimeOffset Time, double Amount, ConsumptionKind Kind, ReadingQuality Quality); 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> /// <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); 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<Costing.CostService>();
services.AddScoped<Dashboard.DashboardService>(); services.AddScoped<Dashboard.DashboardService>();
services.AddScoped<Dashboard.SolarService>(); services.AddScoped<Dashboard.SolarService>();
services.AddScoped<Dashboard.MeterPeriodService>();
services.AddScoped<Dashboard.ConsumableService>(); services.AddScoped<Dashboard.ConsumableService>();
services.AddScoped<Dashboard.MeterDetailService>(); services.AddScoped<Dashboard.MeterDetailService>();
services.AddScoped<Dashboard.FlowService>(); services.AddScoped<Dashboard.FlowService>();
@@ -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;
/// <summary>
/// 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.
/// </summary>
[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<int> 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();
}
}