Meter detail: lead with periods and change, not register totals
ci / build-test (push) Successful in 1m23s
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:
@@ -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,43 +35,99 @@ else
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MudGrid Class="mb-2">
|
@if (_periods is { } p)
|
||||||
<MudItem xs="6" sm="3">
|
|
||||||
<MudPaper Class="pa-3" Elevation="2">
|
|
||||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Consumption</MudText>
|
|
||||||
<MudText Typo="Typo.h6">@Format.Number(_detail.TotalConsumption, 0) @_detail.Unit</MudText>
|
|
||||||
</MudPaper>
|
|
||||||
</MudItem>
|
|
||||||
@if (_detail.TotalGeneration != 0)
|
|
||||||
{
|
{
|
||||||
<MudItem xs="6" sm="3">
|
<MudGrid Class="mb-2">
|
||||||
|
<MudItem xs="12" sm="6" md="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>
|
||||||
</MudPaper>
|
@if (p.MonthIsPartial)
|
||||||
</MudItem>
|
{
|
||||||
}
|
|
||||||
<MudItem xs="6" sm="3">
|
|
||||||
<MudPaper Class="pa-3" Elevation="2">
|
|
||||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Readings</MudText>
|
|
||||||
<MudText Typo="Typo.h6">@_detail.ReadingCount</MudText>
|
|
||||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||||
@(_detail.FirstReadingTime?.ToString("yyyy-MM") ?? "—") … @(_detail.LastReadingTime?.ToString("yyyy-MM") ?? "—")
|
≈ @Format.Number(p.MonthProjected, 0) @p.Unit by month end
|
||||||
|
</MudText>
|
||||||
|
}
|
||||||
|
</MudPaper>
|
||||||
|
</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>
|
</MudText>
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
<MudItem xs="6" sm="3">
|
<MudItem xs="12" sm="6" md="3">
|
||||||
<MudPaper Class="pa-3" Elevation="2">
|
<MudPaper Class="pa-3" Elevation="2">
|
||||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Register span</MudText>
|
<MudText Typo="Typo.overline" Color="Color.Secondary">This year</MudText>
|
||||||
<MudText Typo="Typo.h6">
|
<MudText Typo="Typo.h6">@Format.Number(p.YearToDate, 0) @p.Unit</MudText>
|
||||||
@(_detail.FirstReadingValue is { } f ? Format.Number(f, 0) : "—") →
|
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||||
@(_detail.LastReadingValue is { } l ? Format.Number(l, 0) : "—")
|
@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>
|
</MudText>
|
||||||
<MudText Typo="Typo.caption" Color="Color.Secondary">baseline @Format.Number(_detail.InitialBaseline, 0)</MudText>
|
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
</MudGrid>
|
</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>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<MudExpansionPanels Elevation="0" Class="mb-2">
|
||||||
|
<MudExpansionPanel Text="Meter register details">
|
||||||
|
<div class="d-flex flex-wrap" style="gap:2rem">
|
||||||
|
<div>
|
||||||
|
<MudText Typo="Typo.overline" Color="Color.Secondary">Register span</MudText>
|
||||||
|
<MudText Typo="Typo.body1">
|
||||||
|
@(_detail.FirstReadingValue is { } f ? Format.Number(f, 0) : "—") →
|
||||||
|
@(_detail.LastReadingValue is { } l ? Format.Number(l, 0) : "—")
|
||||||
|
(baseline @Format.Number(_detail.InitialBaseline, 0))
|
||||||
|
</MudText>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<MudText Typo="Typo.overline" Color="Color.Secondary">Readings</MudText>
|
||||||
|
<MudText Typo="Typo.body1">
|
||||||
|
@_detail.ReadingCount ·
|
||||||
|
@(_detail.FirstReadingTime?.ToString("yyyy-MM") ?? "—") … @(_detail.LastReadingTime?.ToString("yyyy-MM") ?? "—")
|
||||||
|
</MudText>
|
||||||
|
</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})")">
|
||||||
@if (_detail.RecentReadings.Count == 0)
|
@if (_detail.RecentReadings.Count == 0)
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user