| @batch.Id |
@(batch.SourceName ?? "—") |
+
+ @* What the batch wrote to, so an import can be checked where it landed — and a
+ revert weighed against what it will take away. *@
+ @if (_targets.TryGetValue(batch.Id, out var targets))
+ {
+ @foreach (var meter in targets.Meters)
+ {
+ @meter.Name
+ }
+ @foreach (var category in targets.Categories)
+ {
+ @category
+ }
+ }
+ else
+ {
+ —
+ }
+ |
@batch.RowCount |
@batch.CreatedAt.LocalDateTime.ToString("yyyy-MM-dd HH:mm") |
@@ -130,6 +149,7 @@
private string _profileName = "Strom";
private StagedImport? _preview;
private List _batches = [];
+ private Dictionary _targets = [];
private int? _reverting;
protected override async Task OnInitializedAsync()
@@ -145,8 +165,69 @@
.OrderByDescending(b => b.Id)
.Take(25)
.ToListAsync();
+ _targets = await LoadTargetsAsync(db, [.. _batches.Where(b => b.RevertedAt is null).Select(b => b.Id)]);
}
+ ///
+ /// The meters and cost categories each batch wrote rows for. Read from the rows themselves rather
+ /// than the stored mapping, which the reference import does not have and which cannot say what a
+ /// partly failed or reverted batch actually left behind.
+ ///
+ private static async Task> LoadTargetsAsync(MeterVaultDbContext db, List batchIds)
+ {
+ if (batchIds.Count == 0)
+ {
+ return [];
+ }
+
+ var readingMeters = await db.Readings.AsNoTracking()
+ .Where(r => r.ImportBatchId != null && batchIds.Contains(r.ImportBatchId.Value))
+ .Select(r => new { Batch = r.ImportBatchId!.Value, r.MeterId })
+ .Distinct()
+ .ToListAsync();
+ var eventMeters = await db.MeterEvents.AsNoTracking()
+ .Where(e => e.ImportBatchId != null && batchIds.Contains(e.ImportBatchId.Value))
+ .Select(e => new { Batch = e.ImportBatchId!.Value, e.MeterId })
+ .Distinct()
+ .ToListAsync();
+ var costs = await db.ManualCosts.AsNoTracking()
+ .Where(c => c.ImportBatchId != null && batchIds.Contains(c.ImportBatchId.Value))
+ .Select(c => new { Batch = c.ImportBatchId!.Value, c.MeterId, c.CategoryId })
+ .Distinct()
+ .ToListAsync();
+
+ var meterPairs = readingMeters.Select(r => (r.Batch, MeterId: (int?)r.MeterId))
+ .Concat(eventMeters.Select(e => (e.Batch, MeterId: (int?)e.MeterId)))
+ .Concat(costs.Select(c => (c.Batch, c.MeterId)))
+ .Where(p => p.MeterId is not null)
+ .Select(p => (p.Batch, MeterId: p.MeterId!.Value))
+ .Distinct()
+ .ToList();
+ var categoryPairs = costs.Where(c => c.CategoryId is not null).Select(c => (c.Batch, CategoryId: c.CategoryId!.Value)).Distinct().ToList();
+
+ var meterIds = meterPairs.Select(p => p.MeterId).Distinct().ToList();
+ var categoryIds = categoryPairs.Select(p => p.CategoryId).Distinct().ToList();
+ var meterNames = await db.Meters.AsNoTracking().Where(m => meterIds.Contains(m.Id))
+ .ToDictionaryAsync(m => m.Id, m => m.Name);
+ var categoryNames = await db.CostCategories.AsNoTracking().Where(c => categoryIds.Contains(c.Id))
+ .ToDictionaryAsync(c => c.Id, c => c.Name);
+
+ return batchIds
+ .Select(id => (Id: id, Targets: new BatchTargets(
+ [.. meterPairs.Where(p => p.Batch == id && meterNames.ContainsKey(p.MeterId))
+ .Select(p => new MeterTarget(p.MeterId, meterNames[p.MeterId]))
+ .OrderBy(m => m.Name, StringComparer.CurrentCulture)],
+ [.. categoryPairs.Where(p => p.Batch == id && categoryNames.ContainsKey(p.CategoryId))
+ .Select(p => categoryNames[p.CategoryId])
+ .Order(StringComparer.CurrentCulture)])))
+ .Where(t => t.Targets.Meters.Count > 0 || t.Targets.Categories.Count > 0)
+ .ToDictionary(t => t.Id, t => t.Targets);
+ }
+
+ private sealed record MeterTarget(int Id, string Name);
+
+ private sealed record BatchTargets(IReadOnlyList Meters, IReadOnlyList Categories);
+
private async Task RevertAsync(ImportBatch batch)
{
if (!await Confirm.ConfirmAsync(Dialogs, S.Import_RevertConfirmTitle,
diff --git a/src/App/Components/Pages/MeterDetail.razor b/src/App/Components/Pages/MeterDetail.razor
index 33154d4..e280f58 100644
--- a/src/App/Components/Pages/MeterDetail.razor
+++ b/src/App/Components/Pages/MeterDetail.razor
@@ -7,12 +7,16 @@
@inject NavigationManager Nav
@inject IServiceScopeFactory Scopes
@inject Microsoft.Extensions.Options.IOptions Options
+@inject ILogger Logger
+@inject DraftStore Drafts
+@implements IDisposable
+@using System.Globalization
@using Microsoft.EntityFrameworkCore
@using Microsoft.Extensions.DependencyInjection
@using MeterVault.Infrastructure.Ingestion
@using MudBlazor
-MeterVault — @S.Common_Meter
+MeterVault — @(_detail?.Name ?? S.Common_Meter)
@if (_detail is null)
{
@@ -39,16 +43,85 @@ else
.mv-keypad .mud-button { height: 56px; font-size: 1.35rem; }
-
-
+ @* The header carries the meter's actions, above the figures: on a phone the tabs sit a long
+ scroll down, and the things people come here to do — enter a reading, record a swap, fix a
+ setting — should not depend on finding the right tab first. *@
+
+
@_detail.Name
- @_detail.EnergyType
+
+ @_detail.EnergyType
+
@_detail.Mode.Display()
@if (!_detail.IsActive)
{
@S.MeterDetail_Retired
}
+
+
+ @if (TakesReadings)
+ {
+
+ @S.MeterDetail_AddReading
+
+ }
+ else if (_detail.Mode == MeterMode.ConsumableBalance)
+ {
+
+ @S.MeterDetail_RecordTankLevel
+
+ }
+
+ @foreach (var type in MeterEventRules.RecordableFor(_detail.Mode))
+ {
+ var chosen = type;
+ @($"{chosen.Display()}…")
+ }
+
+
+
+
+
+ @if (IdentityLine() is { Length: > 0 } identity)
+ {
+ @identity
+ }
+ else
+ {
+
+ }
+
+ @if (_detail.Mode == MeterMode.ConsumableBalance && !_detail.HasTank)
+ {
+
+ @S.MeterDetail_NoTankConfigured
+ @S.MeterDetail_SetUpTank
+
+ }
+ else if (IsUnstarted)
+ {
+ @* A brand-new meter has nothing to show yet; say what makes it useful instead of a page of dashes. *@
+
+ @S.MeterDetail_GetStarted
+
+ @if (TakesReadings)
+ {
+ @S.MeterDetail_AddFirstReading
+ @S.MeterDetail_ConnectSource
+ }
+ else
+ {
+ @S.MeterDetail_RecordTankLevel
+ }
+
+
+ }
@if (_periods is { } p)
{
@@ -118,7 +191,7 @@ else
@if (_periods is null && _detail.Mode == MeterMode.Virtual)
{
- @S.MeterDetail_VirtualNotice @S.MeterDetail_VirtualNoticeTrends
+ @S.MeterDetail_VirtualNotice @S.MeterDetail_VirtualNoticeFlow
}
@@ -150,7 +223,7 @@ else
-
+
@if (_detail.Mode == MeterMode.Virtual)
{
@@ -158,6 +231,16 @@ else
@S.MeterDetail_VirtualNoReadings
}
+ else if (_detail.Mode == MeterMode.ConsumableBalance)
+ {
+ @* A tank's consumption comes from level and delivery events; a reading typed here would
+ save cleanly and change nothing, so the tab sends the user where it counts. *@
+
+ @S.MeterDetail_TankUsesEvents
+ @S.MeterDetail_GoToEvents
+
+ }
else
{
@@ -169,7 +252,10 @@ else
}
@if (_detail.RecentReadings.Count == 0)
{
- @S.MeterDetail_NoRawReadings
+ @if (_detail.Mode != MeterMode.ConsumableBalance)
+ {
+ @S.MeterDetail_NoRawReadings
+ }
}
else
{
@@ -177,7 +263,7 @@ else
@Loc.F(S.MeterDetail_RecentReadingsCaption, _detail.RecentReadings.Count, _tz.Id)
- | @S.MeterDetail_Time | @S.Common_Value | @S.MeterDetail_Quality | @S.MeterDetail_Flags |
+ | @S.MeterDetail_Time | @S.Common_Value | @S.MeterDetail_Quality | @S.MeterDetail_Flags | |
@foreach (var r in _detail.RecentReadings)
{
@@ -186,6 +272,15 @@ else
@Format.Number(r.Value, 2) @_detail.Unit |
@QualityChip(r.Quality) |
@r.Flags.Display() |
+
+ @if (r.Quality == ReadingQuality.Manual)
+ {
+
+
+
+ }
+ |
}
@@ -219,6 +314,17 @@ else
+
+ @EventsHint
+
+ @foreach (var type in MeterEventRules.RecordableFor(_detail.Mode))
+ {
+ var chosen = type;
+ @($"{chosen.Display()}…")
+ }
+
+
@if (_detail.Events.Count == 0)
{
@S.MeterDetail_NoEvents
@@ -226,16 +332,33 @@ else
else
{
- | @S.MeterDetail_Time | @S.Common_Type | @S.Common_Amount | @S.MeterDetail_PrevNew | @S.MeterDetail_Notes |
+ | @S.MeterDetail_Time | @S.Common_Type | @S.Common_Amount | @S.MeterDetail_PrevNew | @S.MeterDetail_Notes | |
@foreach (var e in _detail.Events)
{
- | @Local(e.Time).ToString("yyyy-MM-dd") |
- @e.Type.Display() |
+ @Local(e.Time).ToString("yyyy-MM-dd HH:mm") |
+
+ @e.Type.Display()
+ |
@(e.Amount is { } a ? $"{Format.Number(a, 2)} {e.Unit}" : "—") |
- @(e.PrevValue is { } p ? $"{Format.Number(p, 0)}→{Format.Number(e.NewValue ?? 0, 0)}" : "—") |
+ @PrevNewText(e) |
@e.Notes |
+
+ @if (e.ImportBatchId is not null)
+ {
+
+ @S.MeterDetail_Imported
+
+ }
+ else
+ {
+
+
+
+ }
+ |
}
@@ -244,6 +367,10 @@ else
+
+ @S.MeterDetail_ManageTariffs
+
@if (_detail.Tariffs.Count == 0)
{
@S.MeterDetail_NoTariffs
@@ -256,7 +383,7 @@ else
@foreach (var t in _detail.Tariffs)
{
- | @t.Scope.Display() @(t.ScopeId is { } id ? $"#{id}" : "") |
+ @ScopeText(t) |
@t.Component.Display() |
@Format.Number(t.Value, 4) |
@t.Unit |
@@ -338,11 +465,17 @@ else
@(_entry.Value is { } parsed ? $"= {Format.Number(parsed, 3)} {_detail.Unit}" : S.MeterDetail_EnterValue)
- @if (ChangeSinceLast is { } change)
+ @if (WouldBeRejected)
{
-
- @ChangeSinceText(change)@(WouldBeRejected ? S.MeterDetail_WillBeRejectedSuffix : "")
-
+ @* Names the likely cause, but deliberately is not a button: this line shows for most of
+ an ordinary entry (every prefix of 12351 is below 12345) and sits just above the
+ keypad, so a slightly high tap on the top keys would leave the reading mid-entry. The
+ swap and reset buttons are in the alert below and on the rejection message. *@
+ @S.MeterDetail_SwappedOrResetHint
+ }
+ else if (ChangeSinceLast is { } change)
+ {
+ @ChangeSinceText(change)
}
@@ -355,18 +488,18 @@ else
-
-
@S.Common_Now
+ StartIcon="@Icons.Material.Filled.Schedule" OnClick="@(() => _readingWhen.SetNow())">@S.Common_Now
@Loc.F(S.MeterDetail_LocalTimeIn, _tz.Id)
@* Everything below here can reflow freely: the dialog's buttons sit outside this scroll
area, so nothing the user is aiming at moves. *@
- @if (EnteredTimeSkipped)
+ @if (_readingWhen.IsSkipped)
{
@Loc.F(S.MeterDetail_SkippedTime, _tz.Id)
@@ -376,9 +509,19 @@ else
{
@Loc.F(S.MeterDetail_DecreaseWarning, Format.Number(_detail.LastReadingValue ?? 0, 2), _detail.Unit)
+
+ @S.MeterDetail_RecordSwap
+ @S.MeterDetail_RecordReset
+
}
- @if (ReplacesRecentReading)
+ @if (ReplacesSwapStart)
+ {
+ @S.MeterDetail_ReplaceSwapStartNotice
+ }
+ else if (ReplacesRecentReading)
{
@S.MeterDetail_ReplaceNotice
@@ -415,23 +558,37 @@ else
@type.Display()
}
- @if (RequiredEndpointType(_sourceEdit.SourceType) is { } needed)
+ @if (SourceRouting.RequiredEndpoint(_sourceEdit.SourceType) is { } needed)
{
- if (ConnectorsFor(needed).Count == 0)
+ @* Every way to a missing connector leads back here with it picked, so setting one up is a
+ detour rather than a dead end that loses the meter. *@
+ var usable = ConnectorsFor(needed);
+ if (usable.Count == 0)
{
- @Loc.F(S.MeterDetail_NoConnectorYet, needed.Display()) @S.MeterDetail_CreateConnectorLink
- @S.MeterDetail_CreateConnectorHint
+ @if (_endpoints.FirstOrDefault(e => e.Type == needed && !e.IsEnabled) is { } disabled)
+ {
+ @Loc.F(S.MeterDetail_ConnectorOnlyDisabled, disabled.Name) @S.MeterDetail_EnableConnectorLink
+ }
+ else
+ {
+ @Loc.F(S.MeterDetail_NoConnectorYet, needed.Display()) @S.MeterDetail_CreateConnectorLink @S.MeterDetail_CreateConnectorHint
+ }
}
else
{
-
- @foreach (var e in ConnectorsFor(needed))
+
+ @foreach (var e in usable)
{
@e.Name
}
+
+
+ @S.MeterDetail_AnotherConnector
+
+
}
}
@if (_sourceEdit.SourceType == SourceType.HomeAssistant)
@@ -463,32 +620,70 @@ else
- @S.Common_Cancel
+ @S.Common_Cancel
@S.Common_Save
+
+
}
+
+
@code {
[Parameter]
public int Id { get; set; }
+ /// Which tab to open: one of .
+ [SupplyParameterFromQuery(Name = "tab")]
+ public string? Tab { get; set; }
+
+ /// A dialog to open once the page is interactive; see .
+ [SupplyParameterFromQuery(Name = "action")]
+ public string? Action { get; set; }
+
+ /// With the source action: the existing source to open instead of a new one.
+ [SupplyParameterFromQuery(Name = MeterLinks.ParamSource)]
+ public int? SourceParam { get; set; }
+
+ /// With the source action: the source type to preset.
+ [SupplyParameterFromQuery(Name = MeterLinks.ParamSourceType)]
+ public string? SourceTypeParam { get; set; }
+
+ /// With the source action: the connector to preselect, typically one just created for it.
+ [SupplyParameterFromQuery(Name = MeterLinks.ParamConnector)]
+ public int? ConnectorParam { get; set; }
+
private MeterDetailView? _detail;
private MeterPeriodView? _periods;
private bool _notFound;
+ private int? _loadedId;
+ private string? _appliedTab;
+ private string? _pendingAction;
+ private SourcePreset? _pendingSource;
+ private bool _droppingAction;
+ private bool _refreshBeforeAction;
+ private int _tabIndex;
private List _sources = [];
private List _endpoints = [];
private bool _sourceOpen;
private SourceEdit _sourceEdit = new();
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
+ private MeterEventDialog? _eventDialog;
+ private MeterEditor? _editor;
private bool _readingOpen;
private bool _readingSaving;
private readonly ReadingEntry _entry = new();
- private DateTime? _readingDate;
- private TimeSpan? _readingTime;
+ private LocalTimeEntry _readingWhen = new(TimeZoneInfo.Utc);
private TimeZoneInfo _tz = TimeZoneInfo.Utc;
+ ///
+ /// A reading typed before the user detoured into recording a swap. It is handed back to the
+ /// reading dialog once the swap is saved, so the detour costs no retyping.
+ ///
+ private (string Text, DateTimeOffset? At)? _resumeReading;
+
/// Phone-dialpad order, ending in the row the thumb reaches last: separator, zero, backspace.
private static readonly string[] Keypad = ["7", "8", "9", "4", "5", "6", "1", "2", "3", ",", "0", "⌫"];
@@ -499,42 +694,195 @@ else
///
private const InputMode DecimalKeyboard = InputMode.@decimal;
- private static readonly MeterMode[] MonotonicModes =
- [MeterMode.CumulativeCounter, MeterMode.GenerationCounter, MeterMode.RuntimeCounter];
-
- protected override void OnInitialized() => _tz = ResolveTimeZone(Options.Value.TimeZone);
+ protected override void OnInitialized()
+ {
+ _tz = LocalTimeEntry.Resolve(Options.Value.TimeZone);
+ _readingWhen = new LocalTimeEntry(_tz);
+ }
protected override async Task OnParametersSetAsync()
{
- _detail = null;
- _periods = null;
- _notFound = false;
- _readingOpen = false;
- _detail = await Details.GetAsync(Id);
- _notFound = _detail is null;
- if (_detail is not null)
+ // Only a different meter reloads. The query string changes too — a deep link into a tab, or
+ // the action being dropped once consumed — and neither should blank and refetch the page.
+ var freshLoad = _loadedId != Id;
+ if (freshLoad)
{
- _periods = await Periods.GetAsync(Id);
- await LoadSourcesAsync();
+ _detail = null;
+ _periods = null;
+ _notFound = false;
+ _readingOpen = false;
+ _resumeReading = null;
+ // Per-meter view state: another meter opens on its first tab, and an action meant for the
+ // previous meter (say, a stale link to one that no longer exists) must not fire on this one.
+ _tabIndex = 0;
+ _pendingAction = null;
+ _pendingSource = null;
+ _droppingAction = false;
+ _refreshBeforeAction = false;
+ _detail = await Details.GetAsync(Id);
+ _notFound = _detail is null;
+ if (_detail is not null)
+ {
+ _periods = await Periods.GetAsync(Id);
+ await LoadSourcesAsync();
+ }
+
+ _loadedId = Id;
+ }
+
+ // A link's tab wins when it changes, or when the link also carries an action; otherwise the tab
+ // the user clicked since is kept, including when the action is dropped from the address.
+ if (Tab is not null
+ && (!string.Equals(Tab, _appliedTab, StringComparison.OrdinalIgnoreCase) || !string.IsNullOrEmpty(Action)))
+ {
+ _tabIndex = MeterLinks.TabIndex(Tab);
+ }
+
+ _appliedTab = Tab;
+
+ if (!string.IsNullOrEmpty(Action))
+ {
+ _pendingAction = Action;
+ _pendingSource = new SourcePreset(
+ SourceParam,
+ Enum.TryParse(SourceTypeParam, ignoreCase: true, out var sourceType) ? sourceType : null,
+ ConnectorParam);
+ // Arriving on a page already showing this meter: reload first, so the dialog is prefilled
+ // from what is stored now rather than from when the page was opened.
+ _refreshBeforeAction |= !freshLoad;
+ }
+ else
+ {
+ _droppingAction = false;
}
}
- // Readings are stored UTC (SDD §10) and shown in the instance timezone, so a value entered at
- // 18:00 reads back as 18:00 rather than as its UTC instant.
- private static TimeZoneInfo ResolveTimeZone(string id)
+ ///
+ /// Opens a deep-linked dialog. After render, because only the interactive render can show one — a
+ /// prerendered page has no circuit to drive it.
+ ///
+ ///
+ /// The action is dropped from the address first and the dialog opened once that navigation
+ /// has come back. The other order fails on a fresh load (bookmark, shared link, new tab): a circuit's
+ /// first location change makes MudBlazor's dialog provider dismiss every open dialog, so the dialog
+ /// would flash and close. Dropping it also means a reload does not reopen it.
+ ///
+ protected override async Task OnAfterRenderAsync(bool firstRender)
{
- try
+ if (_pendingAction is not { } action || _detail is null)
{
- return TimeZoneInfo.FindSystemTimeZoneById(id);
+ return;
}
- catch (Exception ex) when (ex is TimeZoneNotFoundException or InvalidTimeZoneException)
+
+ if (!string.IsNullOrEmpty(Action))
{
- return TimeZoneInfo.Utc;
+ if (!_droppingAction)
+ {
+ _droppingAction = true;
+ Nav.NavigateTo(Nav.GetUriWithQueryParameters(new Dictionary
+ {
+ ["action"] = null,
+ [MeterLinks.ParamSource] = null,
+ [MeterLinks.ParamSourceType] = null,
+ [MeterLinks.ParamConnector] = null,
+ }), replace: true);
+ }
+
+ return;
}
+
+ _pendingAction = null;
+ var sourcePreset = _pendingSource;
+ _pendingSource = null;
+ if (_refreshBeforeAction)
+ {
+ _refreshBeforeAction = false;
+ await ReloadAsync();
+ if (_detail is null)
+ {
+ StateHasChanged();
+ return;
+ }
+ }
+
+ switch (action.ToLowerInvariant())
+ {
+ case MeterLinks.ActionReading when TakesReadings:
+ OpenReading();
+ break;
+ case MeterLinks.ActionEdit:
+ await _editor!.OpenAsync(Id);
+ break;
+ case MeterLinks.ActionSource:
+ OpenSourceFromLink(sourcePreset);
+ break;
+ default:
+ if (MeterLinks.EventFor(action) is { } type && MeterEventRules.CanRecord(_detail.Mode, type))
+ {
+ await OpenEventAsync(type);
+ }
+
+ break;
+ }
+
+ StateHasChanged();
}
+ private bool TakesReadings => _detail is not null && MeterEventRules.TakesReadings(_detail.Mode);
+
+ private bool IsUnstarted =>
+ _detail is { ReadingCount: 0, Events.Count: 0 } && _sources.Count == 0 && _detail.Mode != MeterMode.Virtual;
+
+ private string EventsHint => _detail?.Mode switch
+ {
+ MeterMode.ConsumableBalance => S.MeterDetail_EventsHintTank,
+ MeterMode.CumulativeCounter or MeterMode.GenerationCounter or MeterMode.RuntimeCounter => S.MeterDetail_EventsHintRegister,
+ _ => S.MeterDetail_EventsHintNote,
+ };
+
private DateTimeOffset Local(DateTimeOffset instant) => TimeZoneInfo.ConvertTime(instant, _tz);
+ private string IdentityLine()
+ {
+ if (_detail is null)
+ {
+ return string.Empty;
+ }
+
+ var parts = new List(3);
+ if (!string.IsNullOrWhiteSpace(_detail.SerialNumber))
+ {
+ parts.Add(Loc.F(S.MeterDetail_SerialValue, _detail.SerialNumber));
+ }
+
+ if (!string.IsNullOrWhiteSpace(_detail.Location))
+ {
+ parts.Add(_detail.Location);
+ }
+
+ var device = string.Join(' ', new[] { _detail.Manufacturer, _detail.Model }.Where(s => !string.IsNullOrWhiteSpace(s)));
+ if (device.Length > 0)
+ {
+ parts.Add(device);
+ }
+
+ return string.Join(" · ", parts);
+ }
+
+ private string ScopeText(TariffRow tariff) => tariff.Scope switch
+ {
+ TariffScope.Meter => S.MeterDetail_ScopeThisMeter,
+ TariffScope.EnergyType => $"{tariff.Scope.Display()}: {_detail?.EnergyType}",
+ _ => tariff.Scope.Display(),
+ };
+
+ private static string PrevNewText(EventRow e) => (e.PrevValue, e.NewValue) switch
+ {
+ (null, null) => "—",
+ ({ } prev, var next) => $"{Format.Number(prev, 2)} → {Format.Number(next ?? 0, 2)}",
+ (null, { } next) => $"→ {Format.Number(next, 2)}",
+ };
+
///
/// "+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.
@@ -557,18 +905,27 @@ else
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)}%; "
+ return $"width:100%; height:{height.ToString("0.#", CultureInfo.InvariantCulture)}%; "
+ "background:var(--mud-palette-primary); border-radius:2px 2px 0 0";
}
+ private async Task ReloadAsync()
+ {
+ _detail = await Details.GetAsync(Id);
+ _notFound = _detail is null;
+ _periods = _detail is null ? null : await Periods.GetAsync(Id);
+ await LoadSourcesAsync();
+ }
+
private void OpenReading()
{
- if (_detail is null)
+ if (_detail is null || !TakesReadings)
{
return;
}
- SetNow();
+ _resumeReading = null;
+ _readingWhen.SetNow();
// Prefilling the last reading is what makes this quick standing at the meter: a register only
// moves in its final digits, so backspace-and-retype beats keying six digits from scratch.
// Falls back to the configured baseline while the meter has no readings at all.
@@ -576,13 +933,6 @@ else
_readingOpen = true;
}
- private void SetNow()
- {
- var now = TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, _tz);
- _readingDate = now.Date;
- _readingTime = new TimeSpan(now.Hour, now.Minute, 0);
- }
-
private void OnReadingTyped(string? value) => _entry.SetText(value);
private void PressKey(string key)
@@ -614,37 +964,9 @@ else
: Loc.F(S.MeterDetail_NoReadingsPrefill, Format.Number(detail.InitialBaseline, 2), detail.Unit);
}
- /// The wall-clock instant the two pickers describe, read in the instance timezone.
- private DateTime? EnteredWallClock =>
- _readingDate is { } date ? date.Date + (_readingTime ?? TimeSpan.Zero) : null;
+ private DateTimeOffset? EnteredUtc => _readingWhen.Utc;
- ///
- /// True when the chosen local time falls in a spring-forward gap and so names no instant at all.
- /// Converting it would throw, so the dialog blocks the save and says why instead.
- ///
- private bool EnteredTimeSkipped =>
- EnteredWallClock is { } wall && _tz.IsInvalidTime(DateTime.SpecifyKind(wall, DateTimeKind.Unspecified));
-
- ///
- /// An ambiguous autumn hour resolves to standard time, 's default. The
- /// two candidate instants are an hour apart on one hour of one night a year — well inside the
- /// precision of a timestamp somebody typed by hand.
- ///
- private DateTimeOffset? EnteredUtc
- {
- get
- {
- if (EnteredWallClock is not { } wall || EnteredTimeSkipped)
- {
- return null;
- }
-
- var unspecified = DateTime.SpecifyKind(wall, DateTimeKind.Unspecified);
- return new DateTimeOffset(TimeZoneInfo.ConvertTimeToUtc(unspecified, _tz), TimeSpan.Zero);
- }
- }
-
- private bool IsMonotonic => _detail is not null && Array.IndexOf(MonotonicModes, _detail.Mode) >= 0;
+ private bool IsMonotonic => _detail is not null && MeterEventRules.IsMonotonic(_detail.Mode);
private bool IsBackdated => EnteredUtc is { } entered && _detail?.LastReadingTime is { } last && entered < last;
@@ -654,13 +976,21 @@ else
private double? ChangeSinceLast =>
!IsBackdated && _entry.Value is { } value && _detail?.LastReadingValue is { } last ? value - last : null;
+ ///
+ /// A swap or reset recorded after the latest reading and no later than the entered time: it
+ /// explains a lower value, exactly as the ingestion guard sees it.
+ ///
+ private bool BoundaryExplainsDecrease =>
+ EnteredUtc is { } entered && _detail is { LastReadingTime: { } last }
+ && _detail.Events.Any(e => MeterEventRules.IsRegisterBoundary(e.Type) && e.Time > last && e.Time <= entered);
+
///
/// Mirrors the ingestion guard closely enough to warn before saving rather than after. The
/// service compares against the reading immediately before the entered time; this page only
/// holds the latest one, so a backdated entry gets no verdict rather than a wrong one.
///
private bool WouldBeRejected =>
- IsMonotonic && !IsBackdated && _entry.Value is { } value
+ IsMonotonic && !IsBackdated && !BoundaryExplainsDecrease && _entry.Value is { } value
&& _detail?.LastReadingValue is { } last && value < last;
///
@@ -670,8 +1000,17 @@ else
private bool ReplacesRecentReading =>
EnteredUtc is { } entered && _detail is not null && _detail.RecentReadings.Any(r => r.Time == entered);
+ ///
+ /// The reading being replaced is the new register's start value a swap wrote. Replacing it with
+ /// the real value at that instant is correct — the swap still anchors the maths — so say that
+ /// instead of the generic "replaces a reading", which reads like a warning.
+ ///
+ private bool ReplacesSwapStart =>
+ EnteredUtc is { } entered && _detail is not null
+ && _detail.RecentReadings.Any(r => r.Time == entered && (r.Flags & (ReadingFlags.MeterSwap | ReadingFlags.CounterReset)) != 0);
+
private bool CanSaveReading =>
- !_readingSaving && _entry.Value is not null && EnteredWallClock is not null && !EnteredTimeSkipped;
+ !_readingSaving && _entry.Value is not null && _readingWhen.WallClock is not null && !_readingWhen.IsSkipped;
private string ChangeSinceText(double change) =>
Math.Abs(change) < 1e-9
@@ -705,9 +1044,14 @@ else
Snackbar.Add(Loc.F(S.MeterDetail_ReadingReplaced, Format.Number(value, 2), _detail.Unit), Severity.Success);
break;
case IngestionOutcome.RejectedDecrease:
- // Leave the dialog open: the typed value is still on screen to correct, and the
- // alternative fix — recording a reset or swap — is a decision, not a retry.
- Snackbar.Add(S.MeterDetail_ReadingRejected, Severity.Error);
+ // Leave the dialog open with the value still on screen, and offer the fix right on
+ // the message: recording a swap or reset is a decision, not a retry.
+ Snackbar.Add(S.MeterDetail_ReadingRejected, Severity.Error, config =>
+ {
+ config.Action = S.MeterDetail_RecordSwap;
+ config.ActionColor = Color.Inherit;
+ config.OnClick = _ => InvokeAsync(() => SwitchToEventAsync(MeterEventType.MeterSwap));
+ });
return;
default:
Snackbar.Add(S.MeterDetail_MeterGone, Severity.Error);
@@ -715,8 +1059,8 @@ else
}
_readingOpen = false;
- _detail = await Details.GetAsync(Id);
- _periods = _detail is null ? null : await Periods.GetAsync(Id);
+ _resumeReading = null;
+ await ReloadAsync();
}
finally
{
@@ -724,6 +1068,131 @@ else
}
}
+ ///
+ /// From the reading dialog into the swap/reset dialog, at the time the reading was being entered —
+ /// the latest the swap can have happened. The typed value is kept and handed back afterwards.
+ ///
+ private async Task SwitchToEventAsync(MeterEventType type)
+ {
+ _resumeReading = (_entry.Text, EnteredUtc);
+ _readingOpen = false;
+ await OpenEventAsync(type, EnteredUtc, keepResume: true);
+ }
+
+ private async Task OpenEventAsync(MeterEventType type, DateTimeOffset? at = null, bool keepResume = false)
+ {
+ if (_eventDialog is null)
+ {
+ return;
+ }
+
+ // Opened from the reading dialog, the user returns to that dialog afterwards, so leave the tab
+ // alone; opened on its own, show the list the new event is about to appear in.
+ if (!keepResume)
+ {
+ _resumeReading = null;
+ _tabIndex = MeterLinks.TabIndex(MeterLinks.TabEvents);
+ }
+
+ await _eventDialog.OpenAsync(type, at);
+ }
+
+ private async Task OnEventSavedAsync(MeterEventType type)
+ {
+ await ReloadAsync();
+
+ // Back to the reading that prompted the swap, with the typed digits still there.
+ if (_resumeReading is { } resume && MeterEventRules.IsRegisterBoundary(type) && _detail is not null)
+ {
+ _resumeReading = null;
+ _entry.SetText(resume.Text);
+ if (resume.At is { } at)
+ {
+ _readingWhen.Set(at);
+ }
+ else
+ {
+ _readingWhen.SetNow();
+ }
+
+ _tabIndex = MeterLinks.TabIndex(MeterLinks.TabReadings);
+ _readingOpen = true;
+ }
+ }
+
+ private void OnEventCancelled()
+ {
+ // Abandoning the swap returns to the reading as it was, rather than silently losing it.
+ if (_resumeReading is { } resume)
+ {
+ _resumeReading = null;
+ _entry.SetText(resume.Text);
+ if (resume.At is { } at)
+ {
+ _readingWhen.Set(at);
+ }
+
+ _readingOpen = true;
+ }
+ }
+
+ private async Task OnMeterSavedAsync((int MeterId, bool Created) saved) => await ReloadAsync();
+
+ private async Task DeleteReadingAsync(ReadingRow reading)
+ {
+ if (_detail is null
+ || !await Confirm.DeleteAsync(DialogService, S.MeterDetail_DeleteReadingTitle,
+ Loc.F(S.MeterDetail_DeleteReadingConfirm, Format.Number(reading.Value, 2), _detail.Unit, Local(reading.Time).ToString("yyyy-MM-dd HH:mm"))))
+ {
+ return;
+ }
+
+ await RunServiceAsync(
+ service => service.DeleteManualReadingAsync(Id, reading.Time), S.MeterDetail_ReadingDeleted, "Deleting a manual reading");
+ }
+
+ private async Task DeleteEventAsync(EventRow meterEvent)
+ {
+ if (_detail is null)
+ {
+ return;
+ }
+
+ var message = MeterEventRules.IsRegisterBoundary(meterEvent.Type)
+ ? Loc.F(S.MeterDetail_DeleteBoundaryConfirm, meterEvent.Type.Display(), Local(meterEvent.Time).ToString("yyyy-MM-dd HH:mm"))
+ : Loc.F(S.MeterDetail_DeleteEventConfirm, meterEvent.Type.Display(), Local(meterEvent.Time).ToString("yyyy-MM-dd HH:mm"));
+ if (!await Confirm.DeleteAsync(DialogService, S.MeterDetail_DeleteEvent, message))
+ {
+ return;
+ }
+
+ await RunServiceAsync(
+ service => service.DeleteEventAsync(Id, meterEvent.Id), S.MeterDetail_EventDeleted, "Deleting an event");
+ }
+
+ ///
+ /// Runs one event-service operation in its own scope and reports the outcome. A failure has already
+ /// been rolled back by the service's transaction, so it is reported rather than allowed to end the
+ /// circuit.
+ ///
+ private async Task RunServiceAsync(Func> operation, string successText, string what)
+ {
+ try
+ {
+ await using var scope = Scopes.CreateAsyncScope();
+ var result = await operation(scope.ServiceProvider.GetRequiredService());
+ Snackbar.Add(result.Succeeded ? successText : MeterEventText.Problem(result.Problem),
+ result.Succeeded ? Severity.Success : Severity.Error);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ Logger.LogError(ex, "{What} on meter {MeterId} failed", what, Id);
+ Snackbar.Add(S.MeterEvent_ActionFailed, Severity.Error);
+ }
+
+ await ReloadAsync();
+ }
+
private async Task LoadSourcesAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
@@ -739,6 +1208,71 @@ else
: config.Topic ?? "—";
}
+ private void OpenNewSource()
+ {
+ _tabIndex = MeterLinks.TabIndex(MeterLinks.TabSources);
+ OpenSource(null);
+ }
+
+ ///
+ /// Opens the source dialog a link asked for — typically the way back from the connector page, with
+ /// the source it left, its type and the connector just saved for it.
+ ///
+ private void OpenSourceFromLink(SourcePreset? preset)
+ {
+ _tabIndex = MeterLinks.TabIndex(MeterLinks.TabSources);
+ OpenSource(preset?.SourceId is { } sourceId ? _sources.FirstOrDefault(s => s.Id == sourceId) : null);
+ if (preset is null)
+ {
+ return;
+ }
+
+ // Back from the connector page: everything typed before the detour comes back with the dialog. A
+ // link that names a source type is only ever that way back; other links open the dialog fresh.
+ if (preset.Type is not null && Drafts.TryTake(SourceDraftKey(preset.SourceId), out var draft))
+ {
+ draft.Id = _sourceEdit.Id;
+ _sourceEdit = draft;
+ }
+
+ var connector = preset.ConnectorId is { } connectorId ? _endpoints.FirstOrDefault(e => e.Id == connectorId) : null;
+ if ((preset.Type ?? (connector is null ? null : SourceRouting.DefaultSourceFor(connector.Type))) is { } type
+ && type != _sourceEdit.SourceType)
+ {
+ OnSourceTypeChanged(type);
+ }
+
+ // Only a connector that can serve the source: a disabled or mismatched one would be refused on save.
+ if (connector is { IsEnabled: true } && SourceRouting.Serves(connector.Type, _sourceEdit.SourceType))
+ {
+ _sourceEdit.EndpointId = connector.Id;
+ }
+ }
+
+ /// The source being edited, or null for a new one — what a detour to the connector page returns to.
+ private int? SourceIdOrNull => _sourceEdit.Id == 0 ? null : _sourceEdit.Id;
+
+ private string SourceDraftKey(int? sourceId) =>
+ $"meter:{Id.ToString(CultureInfo.InvariantCulture)}:source:{sourceId?.ToString(CultureInfo.InvariantCulture) ?? "new"}";
+
+ private void CancelSource()
+ {
+ _sourceOpen = false;
+ Drafts.Discard(SourceDraftKey(SourceIdOrNull));
+ }
+
+ ///
+ /// Leaving the page with the source dialog open — the connector links in it do exactly that — keeps
+ /// what was typed for the way back. Disposal runs after every input already sent has been applied.
+ ///
+ public void Dispose()
+ {
+ if (_sourceOpen && _loadedId is { } meterId && meterId == Id)
+ {
+ Drafts.Save(SourceDraftKey(SourceIdOrNull), _sourceEdit.Clone());
+ }
+ }
+
private void OpenSource(MeterSource? source)
{
if (source is null)
@@ -774,7 +1308,7 @@ else
{
// A live source without a matching connector has no connection details and would silently
// never ingest, so refuse it here rather than letting it look configured.
- if (RequiredEndpointType(_sourceEdit.SourceType) is { } needed)
+ if (SourceRouting.RequiredEndpoint(_sourceEdit.SourceType) is { } needed)
{
var selected = _endpoints.FirstOrDefault(e => e.Id == _sourceEdit.EndpointId);
if (selected is null)
@@ -845,6 +1379,7 @@ else
await db.SaveChangesAsync();
_sourceOpen = false;
+ Drafts.Discard(SourceDraftKey(SourceIdOrNull));
Snackbar.Add(S.MeterDetail_SourceSaved, Severity.Success);
await LoadSourcesAsync();
}
@@ -865,17 +1400,6 @@ else
private static string? Trim(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
- ///
- /// Which connector kind a source type needs, or null if it needs none (manual/import/virtual).
- /// Tasmota has no endpoint kind of its own — it is served by an MQTT broker connector.
- ///
- private static EndpointType? RequiredEndpointType(SourceType sourceType) => sourceType switch
- {
- SourceType.HomeAssistant => EndpointType.HomeAssistant,
- SourceType.Mqtt or SourceType.Tasmota => EndpointType.MqttBroker,
- _ => null,
- };
-
// Only enabled connectors can ingest: both MQTT and HA workers filter on IsEnabled, so offering
// a disabled one would produce a source that saves cleanly and then never runs.
private List ConnectorsFor(EndpointType type) =>
@@ -889,7 +1413,7 @@ else
///
private string? ConnectorProblem(MeterSource source)
{
- if (RequiredEndpointType(source.SourceType) is not { } needed)
+ if (SourceRouting.RequiredEndpoint(source.SourceType) is not { } needed)
{
return null;
}
@@ -910,17 +1434,18 @@ else
{
_sourceEdit.SourceType = sourceType;
- var needed = RequiredEndpointType(sourceType);
+ var needed = SourceRouting.RequiredEndpoint(sourceType);
var selected = _endpoints.FirstOrDefault(e => e.Id == _sourceEdit.EndpointId);
if (needed is null || (selected is not null && selected.Type != needed))
{
_sourceEdit.EndpointId = null;
}
- // Sole candidate: preselect it, so the common single-broker / single-HA setup is one click.
- if (needed is not null && _sourceEdit.EndpointId is null)
+ // Sole candidate: preselect it, so the common single-broker / single-HA setup is one click. Only
+ // enabled ones count — the picker offers nothing else, so a disabled pick would be invisible.
+ if (needed is { } kind && _sourceEdit.EndpointId is null)
{
- var candidates = _endpoints.Where(e => e.Type == needed).ToList();
+ var candidates = ConnectorsFor(kind);
if (candidates.Count == 1)
{
_sourceEdit.EndpointId = candidates[0].Id;
@@ -928,8 +1453,13 @@ else
}
}
+ /// What a link presets in the source dialog; see .
+ private sealed record SourcePreset(int? SourceId, SourceType? Type, int? ConnectorId);
+
private sealed class SourceEdit
{
+ public SourceEdit Clone() => (SourceEdit)MemberwiseClone();
+
public int Id { get; set; }
public SourceType SourceType { get; set; } = SourceType.HomeAssistant;
public int? EndpointId { get; set; }
diff --git a/src/App/Components/Pages/Meters.razor b/src/App/Components/Pages/Meters.razor
index c69ada7..b7f832e 100644
--- a/src/App/Components/Pages/Meters.razor
+++ b/src/App/Components/Pages/Meters.razor
@@ -1,8 +1,8 @@
@page "/meters"
@inject Microsoft.EntityFrameworkCore.IDbContextFactory DbFactory
-@inject MeterVault.Core.Normalization.INormalizationEngine Engine
@inject ISnackbar Snackbar
@inject IDialogService DialogService
+@inject NavigationManager Nav
@using Microsoft.EntityFrameworkCore
@using MudBlazor
@@ -10,7 +10,7 @@
@S.Common_Meters
-
+
@S.Meters_AddMeter
@@ -21,10 +21,19 @@
}
else
{
-
+ @* The whole row opens the meter — on a phone this table collapses to cards, where a name link
+ is a small target between edit and delete. Grouped by energy type, because that is how people
+ look for a meter ("the water one"), not by the order they were created in. *@
+
+
+
+
@S.Common_Name
- @S.Common_Type
@S.Common_Mode
@S.Common_Unit
@S.Meters_Sources
@@ -32,24 +41,53 @@ else
@S.Meters_Active
@S.Common_Actions
+
+
+ @context.Key
+
+
- @context.Name
- @context.EnergyType?.DisplayName
- @context.Mode.Display()
- @context.Unit
- @context.Sources.Count
-
+
+ @* The link stays a real link (keyboard, open in new tab) but must not also fire the row's
+ click, or one tap pushes the same page onto the history twice. *@
+ @context.Name
+ @if (!context.IsActive)
+ {
+ @S.MeterDetail_Retired
+ }
+
+ @context.Mode.Display()
+ @context.Unit
+ @context.Sources.Count
+
@{
var lastSeen = context.Sources.Where(s => s.LastSeenAt != null).Select(s => s.LastSeenAt).DefaultIfEmpty(null).Max();
}
@(lastSeen?.ToString("yyyy-MM-dd HH:mm") ?? "—")
- @(context.IsActive ? S.Meters_Yes : S.Meters_No)
+ @(context.IsActive ? S.Meters_Yes : S.Meters_No)
-
-
+ @* Buttons inside a clickable row must not also open the meter. *@
+
+ @if (MeterLinks.QuickEntry(context.Id, context.Mode) is { } entry)
+ {
+
+
+
+ }
+
+
+
+
+ @if (_meters.Count > 0)
+ {
+ @Loc.F(S.Meters_NoSearchMatch, _search)
+ }
+
@if (_meters.Count == 0)
@@ -60,252 +98,66 @@ else
}
}
-
-
- @(_working.Id == 0 ? S.Meters_NewMeter : Loc.F(S.Meters_EditMeter, _working.Name))
-
-
-
-
- @foreach (var t in _energyTypes)
- {
- @t.DisplayName
- }
-
-
- @foreach (var mode in Enum.GetValues())
- {
- @mode.Display()
- }
-
- @if (_working.Mode == MeterMode.Virtual)
- {
-
- @S.Meters_VirtualHelp
-
- }
- else if (_working.Mode == MeterMode.InstantRate)
- {
-
- @S.Meters_InstantRateHelpBefore @S.Meters_InstantRateHelpPerHour @S.Meters_InstantRateHelpAfter
-
- }
-
-
-
- @S.Meters_RoleNone
- total_load
- grid_import
- grid_export
-
-
- @foreach (var m in AvailableUpstream())
- {
- @m.Name
- }
-
-
-
-
-
-
-
-
- @if (_working.Id != 0 && _working.RecomputeNeeded)
- {
- @S.Meters_RecomputeNotice
- }
-
-
- @S.Common_Cancel
- @S.Common_Save
-
-
+
@code {
private List? _meters;
- private List _energyTypes = [];
- private List _allLinks = [];
- private bool _editOpen;
- private EditModel _working = new();
- private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
+ private MeterEditor? _editor;
+ private string? _search;
+
+ private readonly TableGroupDefinition _byEnergyType = new()
+ {
+ Indentation = false,
+ Expandable = false,
+ Selector = m => m.EnergyType?.DisplayName ?? "—",
+ };
protected override Task OnInitializedAsync() => LoadAsync();
private async Task LoadAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
- _energyTypes = await db.EnergyTypes.AsNoTracking().OrderBy(t => t.DisplayName).ToListAsync();
- _allLinks = await db.MeterLinks.AsNoTracking().ToListAsync();
- _meters = await db.Meters
+ var meters = await db.Meters
.AsNoTracking()
.Include(m => m.EnergyType)
.Include(m => m.Sources)
- .OrderBy(m => m.EnergyTypeId).ThenBy(m => m.Name)
.ToListAsync();
+
+ // Grouping follows item order, so sort by the group label first. Retired meters sink to the
+ // bottom of their group: their history still counts, but nobody reads them any more.
+ _meters = meters
+ .OrderBy(m => m.EnergyType?.DisplayName, StringComparer.CurrentCultureIgnoreCase)
+ .ThenBy(m => !m.IsActive)
+ .ThenBy(m => m.Name, StringComparer.CurrentCultureIgnoreCase)
+ .ToList();
}
- // Upstream candidates: same energy type, not self, and not a descendant (would create a cycle).
- private IEnumerable AvailableUpstream()
+ private bool Matches(Meter meter)
{
- if (_meters is null)
+ if (string.IsNullOrWhiteSpace(_search))
{
- return [];
+ return true;
}
- var descendants = Descendants(_working.Id);
- return _meters.Where(m => m.EnergyTypeId == _working.EnergyTypeId && m.Id != _working.Id && !descendants.Contains(m.Id));
+ var term = _search.Trim();
+ return Contains(meter.Name) || Contains(meter.SerialNumber) || Contains(meter.Location)
+ || Contains(meter.EnergyType?.DisplayName) || Contains(meter.Mode.Display());
+
+ bool Contains(string? value) => value?.Contains(term, StringComparison.CurrentCultureIgnoreCase) == true;
}
- private HashSet Descendants(int meterId)
+ /// A new meter goes straight to its own page, where adding readings or a source is the next step.
+ private async Task OnSavedAsync((int MeterId, bool Created) saved)
{
- var result = new HashSet();
- if (meterId == 0)
+ if (saved.Created)
{
- return result;
- }
-
- var queue = new Queue();
- queue.Enqueue(meterId);
- while (queue.Count > 0)
- {
- var current = queue.Dequeue();
- foreach (var link in _allLinks.Where(l => l.FromMeterId == current))
- {
- if (result.Add(link.ToMeterId))
- {
- queue.Enqueue(link.ToMeterId);
- }
- }
- }
-
- return result;
- }
-
- private string UpstreamText(IReadOnlyList ids)
- {
- var names = ids.Select(idText => int.TryParse(idText, out var id) ? _meters?.FirstOrDefault(m => m.Id == id)?.Name ?? idText : idText);
- return string.Join(", ", names);
- }
-
- private void OpenEdit(Meter? meter)
- {
- if (meter is null)
- {
- _working = new EditModel { EnergyTypeId = _energyTypes.FirstOrDefault()?.Id ?? 0 };
- }
- else
- {
- _working = new EditModel
- {
- Id = meter.Id,
- Name = meter.Name,
- EnergyTypeId = meter.EnergyTypeId,
- Mode = meter.Mode,
- OriginalMode = meter.Mode,
- Unit = meter.Unit,
- InitialBaseline = meter.InitialBaseline,
- OriginalBaseline = meter.InitialBaseline,
- Role = MeterMeta.Role(meter.Meta) ?? "",
- Location = meter.Location,
- SerialNumber = meter.SerialNumber,
- Manufacturer = meter.Manufacturer,
- Model = meter.Model,
- IsActive = meter.IsActive,
- Upstream = _allLinks.Where(l => l.ToMeterId == meter.Id).Select(l => l.FromMeterId).ToHashSet(),
- };
- }
- _editOpen = true;
- }
-
- private async Task SaveAsync()
- {
- if (string.IsNullOrWhiteSpace(_working.Name) || string.IsNullOrWhiteSpace(_working.Unit) || _working.EnergyTypeId == 0)
- {
- Snackbar.Add(S.Meters_RequiredFields, Severity.Warning);
+ Nav.NavigateTo(MeterLinks.Detail(saved.MeterId));
return;
}
- await using var db = await DbFactory.CreateDbContextAsync();
- int meterId;
- if (_working.Id == 0)
- {
- var meter = new Meter
- {
- Name = _working.Name.Trim(),
- EnergyTypeId = _working.EnergyTypeId,
- Mode = _working.Mode,
- Unit = _working.Unit.Trim(),
- InitialBaseline = _working.InitialBaseline,
- Meta = MeterMeta.SetRole("{}", _working.Role),
- Location = Trim(_working.Location),
- SerialNumber = Trim(_working.SerialNumber),
- Manufacturer = Trim(_working.Manufacturer),
- Model = Trim(_working.Model),
- IsActive = _working.IsActive,
- };
- db.Meters.Add(meter);
- await db.SaveChangesAsync();
- meterId = meter.Id;
- }
- else
- {
- await using var tx = await db.Database.BeginTransactionAsync();
- var existing = await db.Meters.FirstAsync(m => m.Id == _working.Id);
- existing.Name = _working.Name.Trim();
- existing.EnergyTypeId = _working.EnergyTypeId;
- existing.Mode = _working.Mode;
- existing.Unit = _working.Unit.Trim();
- existing.InitialBaseline = _working.InitialBaseline;
- existing.Meta = MeterMeta.SetRole(existing.Meta, _working.Role);
- existing.Location = Trim(_working.Location);
- existing.SerialNumber = Trim(_working.SerialNumber);
- existing.Manufacturer = Trim(_working.Manufacturer);
- existing.Model = Trim(_working.Model);
- existing.IsActive = _working.IsActive;
- existing.UpdatedAt = DateTimeOffset.UtcNow;
- await db.SaveChangesAsync();
-
- if (_working.RecomputeNeeded)
- {
- var normalization = new MeterVault.Infrastructure.Normalization.NormalizationService(db, Engine);
- await normalization.RecomputeMeterAsync(existing.Id, null);
- await db.SaveChangesAsync();
- }
-
- await tx.CommitAsync();
- meterId = existing.Id;
- }
-
- await SyncUpstreamAsync(db, meterId, _working.Upstream);
-
- _editOpen = false;
- Snackbar.Add(S.Common_Saved, Severity.Success);
await LoadAsync();
}
- /// Reconciles the meter's incoming flow links to the selected upstream meters.
- private static async Task SyncUpstreamAsync(MeterVault.Infrastructure.Persistence.MeterVaultDbContext db, int meterId, IEnumerable desiredUpstream)
- {
- var desired = desiredUpstream.Where(id => id != meterId).ToHashSet();
- var existing = await db.MeterLinks.Where(l => l.ToMeterId == meterId).ToListAsync();
-
- foreach (var link in existing.Where(l => !desired.Contains(l.FromMeterId)))
- {
- db.MeterLinks.Remove(link);
- }
-
- foreach (var fromId in desired.Where(id => existing.All(l => l.FromMeterId != id)))
- {
- db.MeterLinks.Add(new MeterLink { FromMeterId = fromId, ToMeterId = meterId });
- }
-
- await db.SaveChangesAsync();
- }
-
private async Task DeleteAsync(Meter meter)
{
await using var db = await DbFactory.CreateDbContextAsync();
@@ -330,27 +182,4 @@ else
Snackbar.Add(S.Common_Deleted, Severity.Success);
await LoadAsync();
}
-
- private static string? Trim(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
-
- private sealed class EditModel
- {
- public int Id { get; set; }
- public string Name { get; set; } = "";
- public short EnergyTypeId { get; set; }
- public MeterMode Mode { get; set; } = MeterMode.CumulativeCounter;
- public MeterMode OriginalMode { get; set; } = MeterMode.CumulativeCounter;
- public string Unit { get; set; } = "";
- public double InitialBaseline { get; set; }
- public double OriginalBaseline { get; set; }
- public string Role { get; set; } = "";
- public string? Location { get; set; }
- public string? SerialNumber { get; set; }
- public string? Manufacturer { get; set; }
- public string? Model { get; set; }
- public bool IsActive { get; set; } = true;
- public IReadOnlyCollection Upstream { get; set; } = new HashSet();
-
- public bool RecomputeNeeded => Mode != OriginalMode || Math.Abs(InitialBaseline - OriginalBaseline) > 1e-9;
- }
}
diff --git a/src/App/Components/Pages/Solar.razor b/src/App/Components/Pages/Solar.razor
index ed32320..15d0ac5 100644
--- a/src/App/Components/Pages/Solar.razor
+++ b/src/App/Components/Pages/Solar.razor
@@ -22,6 +22,7 @@ else if (!_summary.HasGeneration)
{
@S.Solar_NoGenerationLead @MeterMode.GenerationCounter.Display() @S.Solar_NoGenerationTail
+ @S.Nav_Meters@S.Solar_NoGenerationOrImport
@S.Nav_Import.
}
@@ -76,7 +77,7 @@ else
@foreach (var meter in _summary.Meters)
{
- | @meter.Name |
+ @meter.Name |
@Format.Number(meter.Generation, 0) kWh |
}
@@ -85,8 +86,8 @@ else
@if (!_summary.HasLoadContext)
{
- @S.Solar_TagMetersLead total_load @S.Solar_TagMetersMid grid_import
- @S.Solar_TagMetersTail
+ @S.Solar_TagMetersLead total_load @S.Solar_TagMetersMid grid_import@S.Solar_TagMetersTail
+ @S.Nav_Meters.
}
diff --git a/src/App/Components/Pages/Trends.razor b/src/App/Components/Pages/Trends.razor
index ff8b606..bffba6d 100644
--- a/src/App/Components/Pages/Trends.razor
+++ b/src/App/Components/Pages/Trends.razor
@@ -30,7 +30,10 @@
@code {
private int _months = 24;
- private bool _loading = true;
+
+ // Starts idle: LoadAsync is the one that sets it. Starting busy made the very first load bail out
+ // on its own guard, so the page never got past the progress bar and Apply stayed disabled.
+ private bool _loading;
private IReadOnlyList _points = [];
protected override Task OnInitializedAsync() => LoadAsync();
diff --git a/src/App/Components/Shared/MeterEditor.razor b/src/App/Components/Shared/MeterEditor.razor
new file mode 100644
index 0000000..1d89fc3
--- /dev/null
+++ b/src/App/Components/Shared/MeterEditor.razor
@@ -0,0 +1,559 @@
+@using Microsoft.EntityFrameworkCore
+@using MeterVault.Core.Normalization
+@using MeterVault.Infrastructure.Normalization
+@using MeterVault.Infrastructure.Persistence
+@inject IDbContextFactory DbFactory
+@inject INormalizationEngine Engine
+@inject Microsoft.Extensions.Options.IOptions Options
+@inject ISnackbar Snackbar
+
+@* The one meter editor, shared by the meter list and the meter's own page: a meter's settings are
+ edited where the user is looking at it rather than only from a pencil in a list. *@
+
+
+ @(_working.Id == 0 ? S.Meters_NewMeter : Loc.F(S.Meters_EditMeter, _working.Name))
+
+
+
+
+ @foreach (var t in _energyTypes)
+ {
+ @t.DisplayName
+ }
+
+
+ @foreach (var mode in Enum.GetValues())
+ {
+ @mode.Display()
+ }
+
+ @if (_working.Mode == MeterMode.Virtual)
+ {
+
+ @S.Meters_VirtualHelp
+
+ }
+ else if (_working.Mode == MeterMode.InstantRate)
+ {
+
+ @S.Meters_InstantRateHelpBefore @S.Meters_InstantRateHelpPerHour @S.Meters_InstantRateHelpAfter
+
+ }
+
+ @if (_working.Mode != MeterMode.ConsumableBalance)
+ {
+
+ }
+
+ @if (_working.Mode == MeterMode.ConsumableBalance)
+ {
+
+ @S.Meters_TankSection
+ @S.Meters_TankHelp
+
+
+
+
+
+
+
+
+
+
+ @foreach (var rateMode in Enum.GetValues())
+ {
+ @rateMode.Display()
+ }
+
+ @if (_working.RateMode == TankRateMode.Fixed)
+ {
+
+ }
+
+
+ }
+
+
+ @S.Meters_RoleNone
+ total_load
+ grid_import
+ grid_export
+
+
+ @foreach (var m in AvailableUpstream())
+ {
+ @m.Name
+ }
+
+ @* A meter only has a cost once a category counts it, and nothing else on the way to the
+ dashboard says so — so the membership is chosen where the meter is set up. *@
+ @if (_categories.Count == 0)
+ {
+
+ @S.Meters_NoCostCategories @S.Nav_CostCategories
+
+ }
+ else
+ {
+
+ @foreach (var category in _categories)
+ {
+ @category.Name
+ }
+
+ }
+
+
+
+
+
+
+
+ @if (OffersSwapInstead)
+ {
+ @* Retiring and re-creating splits one meter's history in two and drops its sources, flow
+ links and cost categories. When the physical meter was replaced, a swap is the fix. *@
+
+ @S.Meters_RetireSwapHint
+
+
+ @S.Meters_RecordSwapInstead
+
+
+
+ }
+ @if (_working.Id != 0 && _working.RecomputeNeeded)
+ {
+ @S.Meters_RecomputeNotice
+ }
+
+
+ @S.Common_Cancel
+ @S.Common_Save
+
+
+
+@code {
+ private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
+
+ private bool _open;
+ private bool _saving;
+ private EditModel _working = new();
+ private List _energyTypes = [];
+ private List _meters = [];
+ private List _allLinks = [];
+ private List _categories = [];
+ private List _typeMemberships = [];
+
+ /// Raised after a save, with the meter's id and whether it was just created.
+ [Parameter]
+ public EventCallback<(int MeterId, bool Created)> Saved { get; set; }
+
+ ///
+ /// Raised when the user, about to retire a register, chooses to record a meter swap instead. The
+ /// host decides where that dialog lives; the editor has already closed without saving.
+ ///
+ [Parameter]
+ public EventCallback SwapInsteadRequested { get; set; }
+
+ public async Task OpenNewAsync()
+ {
+ await LoadListsAsync();
+ var type = _energyTypes.FirstOrDefault();
+ _working = new EditModel();
+ if (type is not null)
+ {
+ ApplyTypeDefaults(type, previous: null);
+ }
+
+ _open = true;
+ StateHasChanged();
+ }
+
+ public async Task OpenAsync(int meterId)
+ {
+ await LoadListsAsync();
+ await using var db = await DbFactory.CreateDbContextAsync();
+ var meter = await db.Meters.AsNoTracking().FirstOrDefaultAsync(m => m.Id == meterId);
+ if (meter is null)
+ {
+ Snackbar.Add(S.MeterDetail_MeterGone, Severity.Error);
+ return;
+ }
+
+ var tank = await db.Tanks.AsNoTracking().FirstOrDefaultAsync(t => t.MeterId == meterId);
+ var calibration = MeterConfigFactory.ParseCalibration(tank?.Calibration);
+ _working = new EditModel
+ {
+ Id = meter.Id,
+ Name = meter.Name,
+ EnergyTypeId = meter.EnergyTypeId,
+ Mode = meter.Mode,
+ OriginalMode = meter.Mode,
+ Unit = meter.Unit,
+ InitialBaseline = meter.InitialBaseline,
+ OriginalBaseline = meter.InitialBaseline,
+ Role = MeterMeta.Role(meter.Meta) ?? "",
+ Location = meter.Location,
+ SerialNumber = meter.SerialNumber,
+ Manufacturer = meter.Manufacturer,
+ Model = meter.Model,
+ IsActive = meter.IsActive,
+ OriginalIsActive = meter.IsActive,
+ Upstream = _allLinks.Where(l => l.ToMeterId == meter.Id).Select(l => l.FromMeterId).ToHashSet(),
+ Categories = (await db.CostCategoryMembers.AsNoTracking()
+ .Where(m => m.MeterId == meter.Id)
+ .Select(m => m.CategoryId)
+ .ToListAsync()).ToHashSet(),
+ HasTank = tank is not null,
+ TankCapacity = tank?.Capacity,
+ TankUnit = tank?.Unit ?? meter.Unit,
+ VolumePerCm = calibration?.VolumePerUnit,
+ CalibrationOffset = calibration?.Offset ?? 0,
+ RateMode = tank?.RateMode ?? TankRateMode.Empirical,
+ FixedRate = tank?.FixedRate,
+ };
+ _working.OriginalTank = _working.TankSignature;
+ _open = true;
+ StateHasChanged();
+ }
+
+ private string TankUnitLabel => string.IsNullOrWhiteSpace(_working.TankUnit) ? _working.Unit : _working.TankUnit.Trim();
+
+ private bool OffersSwapInstead =>
+ _working.Id != 0 && _working.OriginalIsActive && !_working.IsActive
+ && MeterEventRules.IsMonotonic(_working.OriginalMode) && SwapInsteadRequested.HasDelegate;
+
+ private async Task LoadListsAsync()
+ {
+ await using var db = await DbFactory.CreateDbContextAsync();
+ _energyTypes = await db.EnergyTypes.AsNoTracking().OrderBy(t => t.DisplayName).ToListAsync();
+ _allLinks = await db.MeterLinks.AsNoTracking().ToListAsync();
+ _meters = await db.Meters.AsNoTracking().OrderBy(m => m.Name).ToListAsync();
+ _categories = await db.CostCategories.AsNoTracking().OrderBy(c => c.Sort).ThenBy(c => c.Name).ToListAsync();
+ _typeMemberships = await db.CostCategoryMembers.AsNoTracking().Where(m => m.EnergyTypeId != null).ToListAsync();
+ }
+
+ ///
+ /// Categories that already count this meter through its energy type. They are named rather than
+ /// offered as a choice: the meter cannot leave them here, only the energy type can.
+ ///
+ private string CategoriesHelp
+ {
+ get
+ {
+ var inherited = _categories
+ .Where(c => _typeMemberships.Any(m => m.CategoryId == c.Id && m.EnergyTypeId == _working.EnergyTypeId))
+ .Select(c => c.Name)
+ .ToList();
+ return inherited.Count == 0
+ ? S.Meters_CostCategoriesHelp
+ : Loc.F(S.Meters_CostCategoriesInherited, string.Join(", ", inherited));
+ }
+ }
+
+ private string CategoryText(IReadOnlyList ids) =>
+ string.Join(", ", ids.Select(idText =>
+ int.TryParse(idText, out var id) ? _categories.FirstOrDefault(c => c.Id == id)?.Name ?? idText : idText));
+
+ ///
+ /// A new meter takes its energy type's default mode and base unit, so an oil meter does not
+ /// silently land on "cumulative counter" in kWh. An edited meter keeps what it has.
+ ///
+ private void OnEnergyTypeChanged(short typeId)
+ {
+ var previous = _energyTypes.FirstOrDefault(t => t.Id == _working.EnergyTypeId);
+ _working.EnergyTypeId = typeId;
+ if (_working.Id == 0 && _energyTypes.FirstOrDefault(t => t.Id == typeId) is { } type)
+ {
+ ApplyTypeDefaults(type, previous);
+ }
+ }
+
+ private void ApplyTypeDefaults(EnergyType type, EnergyType? previous)
+ {
+ _working.EnergyTypeId = type.Id;
+ _working.Mode = type.DefaultMode;
+ // Only replace a unit the user has not typed themselves.
+ if (string.IsNullOrWhiteSpace(_working.Unit) || _working.Unit == previous?.BaseUnit)
+ {
+ _working.Unit = type.BaseUnit;
+ }
+ }
+
+ // Upstream candidates: same energy type, not self, and not a descendant (would create a cycle).
+ private IEnumerable AvailableUpstream()
+ {
+ var descendants = Descendants(_working.Id);
+ return _meters.Where(m => m.EnergyTypeId == _working.EnergyTypeId && m.Id != _working.Id && !descendants.Contains(m.Id));
+ }
+
+ private HashSet Descendants(int meterId)
+ {
+ var result = new HashSet();
+ if (meterId == 0)
+ {
+ return result;
+ }
+
+ var queue = new Queue();
+ queue.Enqueue(meterId);
+ while (queue.Count > 0)
+ {
+ var current = queue.Dequeue();
+ foreach (var link in _allLinks.Where(l => l.FromMeterId == current))
+ {
+ if (result.Add(link.ToMeterId))
+ {
+ queue.Enqueue(link.ToMeterId);
+ }
+ }
+ }
+
+ return result;
+ }
+
+ private string UpstreamText(IReadOnlyList ids)
+ {
+ var names = ids.Select(idText => int.TryParse(idText, out var id) ? _meters.FirstOrDefault(m => m.Id == id)?.Name ?? idText : idText);
+ return string.Join(", ", names);
+ }
+
+ private async Task RecordSwapInsteadAsync()
+ {
+ var meterId = _working.Id;
+ _open = false;
+ await SwapInsteadRequested.InvokeAsync(meterId);
+ }
+
+ private async Task SaveAsync()
+ {
+ if (string.IsNullOrWhiteSpace(_working.Name) || string.IsNullOrWhiteSpace(_working.Unit) || _working.EnergyTypeId == 0)
+ {
+ Snackbar.Add(S.Meters_RequiredFields, Severity.Warning);
+ return;
+ }
+
+ if (_working.Mode == MeterMode.ConsumableBalance && _working.WantsTank && _working.TankCapacity is not > 0)
+ {
+ Snackbar.Add(S.Meters_TankCapacityRequired, Severity.Warning);
+ return;
+ }
+
+ _saving = true;
+ try
+ {
+ await using var db = await DbFactory.CreateDbContextAsync();
+ // One unit: the meter, its tank, its flow links and its categories are saved together or not at
+ // all — a failure halfway must not leave a meter with half its settings.
+ await using var tx = await db.Database.BeginTransactionAsync();
+ var created = _working.Id == 0;
+ int meterId;
+ if (created)
+ {
+ var meter = new Meter
+ {
+ Name = _working.Name.Trim(),
+ EnergyTypeId = _working.EnergyTypeId,
+ Mode = _working.Mode,
+ Unit = _working.Unit.Trim(),
+ InitialBaseline = _working.Mode == MeterMode.ConsumableBalance ? 0 : _working.InitialBaseline,
+ Meta = MeterMeta.SetRole("{}", _working.Role),
+ Location = Trim(_working.Location),
+ SerialNumber = Trim(_working.SerialNumber),
+ Manufacturer = Trim(_working.Manufacturer),
+ Model = Trim(_working.Model),
+ IsActive = _working.IsActive,
+ };
+ db.Meters.Add(meter);
+ await db.SaveChangesAsync();
+ meterId = meter.Id;
+ await SaveTankAsync(db, meterId);
+ }
+ else
+ {
+ var existing = await db.Meters.FirstAsync(m => m.Id == _working.Id);
+ existing.Name = _working.Name.Trim();
+ existing.EnergyTypeId = _working.EnergyTypeId;
+ existing.Mode = _working.Mode;
+ existing.Unit = _working.Unit.Trim();
+ existing.InitialBaseline = _working.InitialBaseline;
+ existing.Meta = MeterMeta.SetRole(existing.Meta, _working.Role);
+ existing.Location = Trim(_working.Location);
+ existing.SerialNumber = Trim(_working.SerialNumber);
+ existing.Manufacturer = Trim(_working.Manufacturer);
+ existing.Model = Trim(_working.Model);
+ existing.IsActive = _working.IsActive;
+ existing.UpdatedAt = DateTimeOffset.UtcNow;
+ await db.SaveChangesAsync();
+ await SaveTankAsync(db, existing.Id);
+
+ if (_working.RecomputeNeeded)
+ {
+ var normalization = new NormalizationService(db, Engine, Options);
+ await normalization.RecomputeMeterAsync(existing.Id, null);
+ await db.SaveChangesAsync();
+ }
+
+ meterId = existing.Id;
+ }
+
+ await SyncUpstreamAsync(db, meterId, _working.Upstream);
+ await SyncCategoriesAsync(db, meterId, _working.Categories);
+ await tx.CommitAsync();
+
+ _open = false;
+ Snackbar.Add(S.Common_Saved, Severity.Success);
+ await Saved.InvokeAsync((meterId, created));
+ }
+ catch (DbUpdateException)
+ {
+ // Something this dialog relied on changed meanwhile (a category or meter deleted in another
+ // tab). Nothing was saved; show what is there now rather than end the circuit.
+ Snackbar.Add(S.Meters_ChangedMeanwhile, Severity.Warning);
+ if (_working.Id != 0)
+ {
+ await OpenAsync(_working.Id);
+ }
+ else
+ {
+ await LoadListsAsync();
+ }
+ }
+ finally
+ {
+ _saving = false;
+ }
+ }
+
+ ///
+ /// Creates or updates the tank for a consumable meter. A tank is left alone when the meter moves
+ /// to another mode — its calibration is configuration somebody measured, not something to lose
+ /// on a misclick in a mode select.
+ ///
+ private async Task SaveTankAsync(MeterVaultDbContext db, int meterId)
+ {
+ if (_working.Mode != MeterMode.ConsumableBalance || !_working.WantsTank || _working.TankCapacity is not { } capacity)
+ {
+ return;
+ }
+
+ var tank = await db.Tanks.FirstOrDefaultAsync(t => t.MeterId == meterId);
+ if (tank is null)
+ {
+ tank = new Tank { MeterId = meterId };
+ db.Tanks.Add(tank);
+ }
+
+ tank.Capacity = capacity;
+ tank.Unit = TankUnitLabel;
+ tank.Calibration = MeterConfigFactory.SerializeCalibration(
+ _working.VolumePerCm is { } perCm ? new CalibrationCurve(perCm, _working.CalibrationOffset) : null);
+ tank.RateMode = _working.RateMode;
+ tank.FixedRate = _working.RateMode == TankRateMode.Fixed ? _working.FixedRate : null;
+ await db.SaveChangesAsync();
+ }
+
+ /// Reconciles the meter's incoming flow links to the selected upstream meters.
+ private static async Task SyncUpstreamAsync(MeterVaultDbContext db, int meterId, IEnumerable desiredUpstream)
+ {
+ var wanted = desiredUpstream.Where(id => id != meterId).ToList();
+ // Against the database, not the list the dialog loaded: a meter deleted meanwhile is not linked.
+ var desired = (await db.Meters.Where(m => wanted.Contains(m.Id)).Select(m => m.Id).ToListAsync()).ToHashSet();
+ var existing = await db.MeterLinks.Where(l => l.ToMeterId == meterId).ToListAsync();
+
+ foreach (var link in existing.Where(l => !desired.Contains(l.FromMeterId)))
+ {
+ db.MeterLinks.Remove(link);
+ }
+
+ foreach (var fromId in desired.Where(id => existing.All(l => l.FromMeterId != id)))
+ {
+ db.MeterLinks.Add(new MeterLink { FromMeterId = fromId, ToMeterId = meterId });
+ }
+
+ await db.SaveChangesAsync();
+ }
+
+ ///
+ /// Reconciles the meter's own cost-category memberships to the selection. Memberships a category
+ /// holds through an energy type are the category's, not the meter's, and are left alone.
+ ///
+ private static async Task SyncCategoriesAsync(MeterVaultDbContext db, int meterId, IEnumerable desiredCategories)
+ {
+ // Against the database, not the list the dialog loaded: a category deleted meanwhile is not re-added
+ // under a dangling id.
+ var wanted = desiredCategories.ToList();
+ var desired = (await db.CostCategories.Where(c => wanted.Contains(c.Id)).Select(c => c.Id).ToListAsync()).ToHashSet();
+ var existing = await db.CostCategoryMembers.Where(m => m.MeterId == meterId).ToListAsync();
+
+ foreach (var member in existing.Where(m => !desired.Contains(m.CategoryId)))
+ {
+ db.CostCategoryMembers.Remove(member);
+ }
+
+ foreach (var categoryId in desired.Where(id => existing.All(m => m.CategoryId != id)))
+ {
+ db.CostCategoryMembers.Add(new CostCategoryMember { CategoryId = categoryId, MeterId = meterId });
+ }
+
+ await db.SaveChangesAsync();
+ }
+
+ private static string? Trim(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
+
+ private sealed class EditModel
+ {
+ public int Id { get; set; }
+ public string Name { get; set; } = "";
+ public short EnergyTypeId { get; set; }
+ public MeterMode Mode { get; set; } = MeterMode.CumulativeCounter;
+ public MeterMode OriginalMode { get; set; } = MeterMode.CumulativeCounter;
+ public string Unit { get; set; } = "";
+ public double InitialBaseline { get; set; }
+ public double OriginalBaseline { get; set; }
+ public string Role { get; set; } = "";
+ public string? Location { get; set; }
+ public string? SerialNumber { get; set; }
+ public string? Manufacturer { get; set; }
+ public string? Model { get; set; }
+ public bool IsActive { get; set; } = true;
+ public bool OriginalIsActive { get; set; } = true;
+ public IReadOnlyCollection Upstream { get; set; } = new HashSet();
+ public IReadOnlyCollection Categories { get; set; } = new HashSet();
+
+ public bool HasTank { get; set; }
+ public double? TankCapacity { get; set; }
+ public string TankUnit { get; set; } = "";
+ public double? VolumePerCm { get; set; }
+ public double CalibrationOffset { get; set; }
+ public TankRateMode RateMode { get; set; } = TankRateMode.Empirical;
+ public double? FixedRate { get; set; }
+ public string OriginalTank { get; set; } = "";
+
+ /// A tank is kept once it exists, and created as soon as any of its fields is filled in.
+ public bool WantsTank => HasTank || TankCapacity is not null || VolumePerCm is not null || FixedRate is not null;
+
+ ///
+ /// The tank fields that change this meter's derived consumption: the calibration turns levels
+ /// into volume. The burner rate only feeds the Consumables panel's rate figure.
+ ///
+ public string TankSignature => FormattableString.Invariant($"{VolumePerCm}|{CalibrationOffset}");
+
+ public bool RecomputeNeeded =>
+ Mode != OriginalMode
+ || Math.Abs(InitialBaseline - OriginalBaseline) > 1e-9
+ || (Mode == MeterMode.ConsumableBalance && WantsTank && TankSignature != OriginalTank);
+ }
+}
diff --git a/src/App/Components/Shared/MeterEventDialog.razor b/src/App/Components/Shared/MeterEventDialog.razor
new file mode 100644
index 0000000..86be1e2
--- /dev/null
+++ b/src/App/Components/Shared/MeterEventDialog.razor
@@ -0,0 +1,395 @@
+@using System.Globalization
+@using Microsoft.Extensions.DependencyInjection
+@using MeterVault.Infrastructure.Ingestion
+@inject IServiceScopeFactory Scopes
+@inject ISnackbar Snackbar
+@inject Microsoft.Extensions.Options.IOptions Options
+@inject ILogger Logger
+
+@* Records a meter lifecycle event: a swap or reset on a register, a level or delivery on a tank, or a
+ note on anything. Everything it books is explained before saving — the old register's tail, where
+ later readings count from, what a dipstick level means in litres — because each of these is the
+ kind of entry whose mistake only shows up weeks later as a spike in a chart. *@
+
+
+ @Loc.F(S.MeterEvent_Title, _type.Display(), MeterName)
+
+
+ @MeterEventText.Intro(_type)
+
+
+
+
+ @S.Common_Now
+
+ @Loc.F(S.MeterDetail_LocalTimeIn, _when.Zone.Id)
+
+ @if (IsBoundary)
+ {
+
+
+
+ @if (_context is { } c)
+ {
+
+
+ @(c.Previous is { } previous
+ ? Loc.F(S.MeterEvent_LastReadingBefore, Register(previous.Value), Unit, Stamp(previous.Time))
+ : Loc.F(S.MeterEvent_NoReadingBefore, Register(c.InitialBaseline), Unit))
+
+ @if (c.Tail(Parse(_prevText)) is { } tail)
+ {
+
+ @Loc.F(S.MeterEvent_TailBooked, Signed(tail), Unit)
+
+ }
+ @if (Parse(_newText) is { } start)
+ {
+ @Loc.F(S.MeterEvent_CountsFrom, Register(start), Unit)
+ }
+
+
+ @if (c.ReadingsAfter > 0 && c.Next is { } next)
+ {
+
+ @Loc.F(S.MeterEvent_ReadingsAfterWarning,
+ c.ReadingsAfter >= MeterEventService.ReadingsAfterCap ? $"{MeterEventService.ReadingsAfterCap}+" : c.ReadingsAfter.ToString(CultureInfo.CurrentCulture),
+ Register(next.Value), Unit, Stamp(next.Time))
+
+ }
+ @if (c.LiveSources > 0)
+ {
+ @Loc.F(S.MeterEvent_LiveSourcesWarning, c.LiveSources)
+ }
+ }
+ }
+ else if (_type is MeterEventType.TankLevel or MeterEventType.Delivery)
+ {
+
+
+ @if (_type == MeterEventType.TankLevel)
+ {
+
+ cm
+ @TankUnit
+
+ }
+
+
+ @if (_type == MeterEventType.TankLevel && _context is { } c)
+ {
+ @if (c.Calibration is null)
+ {
+ @S.MeterEvent_NoCalibrationHint
+ }
+
+ @if (Parse(_amountText) is { } level && _centimetres)
+ {
+ @Loc.F(S.MeterEvent_LevelVolume, Format.Number(c.ToVolume(level, true), 0), TankUnit)
+ }
+
+ @(c.LastLevel is { } last
+ ? Loc.F(S.MeterEvent_LastLevel, $"{Format.Number(last.Amount, 1)} {last.Unit ?? TankUnit}", Format.Number(last.Volume, 0), TankUnit, Stamp(last.Time))
+ : S.MeterEvent_FirstLevel)
+
+ @if (c.DeliveredSinceLastLevel > 0)
+ {
+ @Loc.F(S.MeterEvent_DeliveredSince, Format.Number(c.DeliveredSinceLastLevel, 0), TankUnit)
+ }
+ @if (Parse(_amountText) is { } entered && c.UsedSinceLastLevel(c.ToVolume(entered, _centimetres)) is { } used)
+ {
+
+ @(used < 0
+ ? Loc.F(S.MeterEvent_LevelRose, Format.Number(-used, 0), TankUnit)
+ : Loc.F(S.MeterEvent_UsedSince, Format.Number(used, 0), TankUnit))
+
+ }
+
+ }
+ }
+
+
+
+ @if (_when.IsSkipped)
+ {
+ @Loc.F(S.MeterDetail_SkippedTime, _when.Zone.Id)
+ }
+ else if (_when.Utc is { } entered && entered > DateTimeOffset.UtcNow.AddMinutes(1))
+ {
+ @S.MeterDetail_FutureTime
+ }
+ @if (VisibleProblem is { } problem)
+ {
+ @MeterEventText.Problem(problem)
+ }
+
+
+ @S.Common_Cancel
+
+ @(_saving ? S.MeterDetail_Saving : S.Common_Save)
+
+
+
+
+@code {
+ ///
+ /// A decimal keyboard, so a phone offers the separator. Hoisted out of the markup because
+ /// decimal is a C# keyword and Razor would read the escape in an attribute as a transition.
+ ///
+ private const InputMode DecimalKeyboard = InputMode.@decimal;
+
+ private readonly DialogOptions _options = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
+
+ private bool _open;
+ private bool _saving;
+ private MeterEventType _type = MeterEventType.Note;
+ private LocalTimeEntry _when = new(TimeZoneInfo.Utc);
+ private MeterEventContext? _context;
+ private DateTimeOffset? _contextFor;
+ private int _contextVersion;
+ private string? _prevText;
+ private string? _newText;
+ private string? _amountText;
+ private string? _notes;
+ private bool _centimetres;
+
+ [Parameter, EditorRequired]
+ public int MeterId { get; set; }
+
+ [Parameter]
+ public string MeterName { get; set; } = string.Empty;
+
+ /// Raised after an event was stored and the meter recomputed.
+ [Parameter]
+ public EventCallback Saved { get; set; }
+
+ /// Raised when the dialog is closed without saving.
+ [Parameter]
+ public EventCallback Cancelled { get; set; }
+
+ protected override void OnInitialized() => _when = new LocalTimeEntry(LocalTimeEntry.Resolve(Options.Value.TimeZone));
+
+ /// Opens the dialog for , at or now.
+ public async Task OpenAsync(MeterEventType type, DateTimeOffset? at = null)
+ {
+ _type = type;
+ _prevText = null;
+ _newText = null;
+ _amountText = null;
+ _notes = null;
+ _context = null;
+ _contextFor = null;
+ if (at is { } instant)
+ {
+ _when.Set(instant);
+ }
+ else
+ {
+ _when.SetNow();
+ }
+
+ _open = true;
+ StateHasChanged();
+
+ await LoadContextAsync();
+ if (_context is { } context)
+ {
+ if (IsBoundary)
+ {
+ // Like the reading dialog: a register's final value usually differs from the last
+ // reading only in its last digits, so correcting a prefill beats typing it out. With no
+ // reading yet, the register starts from the meter's baseline — the same place the
+ // normalizer measures the tail from.
+ _prevText = EntryText(context.RegisterBefore);
+ _newText = "0";
+ }
+ else if (type == MeterEventType.TankLevel)
+ {
+ _centimetres = context.Calibration is not null
+ && (context.LastLevel is null || string.Equals(context.LastLevel.Unit, "cm", StringComparison.OrdinalIgnoreCase));
+ }
+ }
+
+ StateHasChanged();
+ }
+
+ private bool IsBoundary => MeterEventRules.IsRegisterBoundary(_type);
+
+ private string Unit => _context?.Unit ?? string.Empty;
+
+ private string TankUnit => _context?.TankUnit ?? string.Empty;
+
+ private MeterEventDraft? Draft => _when.Utc is { } utc
+ ? new MeterEventDraft(_type, utc)
+ {
+ PrevValue = IsBoundary ? Parse(_prevText) : null,
+ NewValue = IsBoundary ? Parse(_newText) : null,
+ Amount = IsBoundary ? null : Parse(_amountText),
+ Unit = _type == MeterEventType.TankLevel && _centimetres ? "cm" : null,
+ Notes = _notes,
+ }
+ : null;
+
+ /// The service's verdict on the current input, from the context for the time shown.
+ private MeterEventProblem Problem =>
+ _context is { } context && Draft is { } draft && _contextFor == draft.Time
+ ? MeterEventService.Validate(context, draft)
+ : MeterEventProblem.None;
+
+ ///
+ /// Problems worth an alert. An empty required field is already obvious from the disabled Save
+ /// button, and shouting about it before the user has typed anything is just noise.
+ ///
+ private MeterEventProblem? VisibleProblem =>
+ Problem is MeterEventProblem.None or MeterEventProblem.AmountRequired or MeterEventProblem.NoteRequired ? null : Problem;
+
+ private bool RequiredFilled => _type switch
+ {
+ MeterEventType.MeterSwap or MeterEventType.CounterReset => !string.IsNullOrWhiteSpace(_newText),
+ MeterEventType.TankLevel or MeterEventType.Delivery => !string.IsNullOrWhiteSpace(_amountText),
+ _ => !string.IsNullOrWhiteSpace(_notes),
+ };
+
+ private bool CanSave =>
+ !_saving
+ && _context is not null
+ && _when.Utc is { } utc && _contextFor == utc
+ && RequiredFilled
+ && !IsUnparseable(_prevText) && !IsUnparseable(_newText) && !IsUnparseable(_amountText)
+ && Problem == MeterEventProblem.None;
+
+ private async Task OnDateChangedAsync(DateTime? date)
+ {
+ _when.Date = date;
+ await LoadContextAsync();
+ }
+
+ private async Task OnTimeChangedAsync(TimeSpan? time)
+ {
+ _when.TimeOfDay = time;
+ await LoadContextAsync();
+ }
+
+ private async Task SetNowAsync()
+ {
+ _when.SetNow();
+ await LoadContextAsync();
+ }
+
+ ///
+ /// Re-reads the surroundings of the chosen time. Versioned, because the pickers can change faster
+ /// than the queries return, and a late answer for an earlier time must not overwrite a newer one.
+ ///
+ private async Task LoadContextAsync()
+ {
+ if (_when.Utc is not { } utc)
+ {
+ return;
+ }
+
+ var version = ++_contextVersion;
+ await using var scope = Scopes.CreateAsyncScope();
+ var service = scope.ServiceProvider.GetRequiredService();
+ var context = await service.GetContextAsync(MeterId, utc);
+ if (version == _contextVersion)
+ {
+ _context = context;
+ _contextFor = utc;
+ }
+ }
+
+ private async Task SaveAsync()
+ {
+ if (!CanSave || Draft is not { } draft)
+ {
+ return;
+ }
+
+ _saving = true;
+ try
+ {
+ // A scope per save: the service holds a scoped DbContext, and a circuit far outlives the
+ // single unit of work a save should share one with.
+ await using var scope = Scopes.CreateAsyncScope();
+ var service = scope.ServiceProvider.GetRequiredService();
+ MeterEventResult result;
+ try
+ {
+ result = await service.RecordAsync(MeterId, draft);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ // The service's transaction has already rolled back; an unhandled exception here would
+ // take the whole circuit down for a problem the user can simply retry or correct.
+ Logger.LogError(ex, "Recording a {Type} event on meter {MeterId} failed", _type, MeterId);
+ Snackbar.Add(S.MeterEvent_ActionFailed, Severity.Error);
+ await LoadContextAsync();
+ return;
+ }
+
+ if (!result.Succeeded)
+ {
+ Snackbar.Add(MeterEventText.Problem(result.Problem), Severity.Error);
+ await LoadContextAsync();
+ return;
+ }
+
+ Snackbar.Add(MeterEventText.Saved(_type), Severity.Success);
+ _open = false;
+ await Saved.InvokeAsync(_type);
+ }
+ finally
+ {
+ _saving = false;
+ }
+ }
+
+ private async Task CancelAsync()
+ {
+ _open = false;
+ await Cancelled.InvokeAsync();
+ }
+
+ private async Task OnVisibleChangedAsync(bool visible)
+ {
+ if (!visible && _open)
+ {
+ await CancelAsync();
+ }
+ }
+
+ private string Stamp(DateTimeOffset instant) => _when.Local(instant).ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture);
+
+ private static double? Parse(string? text) =>
+ !string.IsNullOrWhiteSpace(text) && ReadingEntry.TryParse(text, out var value) ? value : null;
+
+ private static bool IsUnparseable(string? text) => !string.IsNullOrWhiteSpace(text) && !ReadingEntry.TryParse(text, out _);
+
+ ///
+ /// A register value as editable text, to nine decimals: enough to round-trip what a sensor stored
+ /// (a prefill rounded to three would fail its own "not below the last reading" check), while
+ /// trimming binary noise such as 861.1234000000001.
+ ///
+ private static string EntryText(double value) => value.ToString("0.#########", CultureInfo.CurrentCulture);
+
+ /// A register value for display, at the same precision the checks use.
+ private static string Register(double value) => value.ToString("#,##0.#########", CultureInfo.CurrentCulture);
+
+ private static string Signed(double value) => $"{(value >= 0 ? "+" : "−")}{Format.Number(Math.Abs(value), 3)}";
+}
diff --git a/src/App/Components/Shared/MeterSearchDialog.razor b/src/App/Components/Shared/MeterSearchDialog.razor
new file mode 100644
index 0000000..62b346e
--- /dev/null
+++ b/src/App/Components/Shared/MeterSearchDialog.razor
@@ -0,0 +1,149 @@
+@using Microsoft.AspNetCore.Components.Web
+@using Microsoft.EntityFrameworkCore
+@inject IDbContextFactory DbFactory
+@inject NavigationManager Nav
+
+@* Reachable from every page through the app bar: type part of a name, serial number or location and
+ either open the meter or go straight to entering its reading. Two taps from anywhere to the
+ keypad, which is what standing in a basement with a phone calls for. *@
+
+
+
+
+ @if (_meters is null)
+ {
+
+ }
+ else if (_meters.Count == 0)
+ {
+ @S.MeterSearch_NoMeters
+ }
+ else
+ {
+ var matches = Matches().ToList();
+ @if (matches.Count == 0)
+ {
+ @Loc.F(S.Meters_NoSearchMatch, _search)
+ }
+
+ @foreach (var meter in matches.Take(MaxShown))
+ {
+ var hit = meter;
+
+ @* A focusable control of its own, beside (not around) the quick-entry button, so every
+ result is reachable and announced from the keyboard, not only the first. *@
+ Go(MeterLinks.Detail(hit.Id)))"
+ @onkeydown="@(e => OnResultKey(e, hit))">
+ @hit.Name
+ @Describe(hit)
+
+ @if (MeterLinks.QuickEntry(hit.Id, hit.Mode) is { } entry)
+ {
+
+
+
+ }
+
+ }
+
+ @if (matches.Count > MaxShown)
+ {
+ @Loc.F(S.MeterSearch_More, MaxShown, matches.Count)
+ }
+ }
+
+
+
+@code {
+ private const int MaxShown = 30;
+
+ private List? _meters;
+ private string? _search;
+
+ [CascadingParameter]
+ private IMudDialogInstance? Dialog { get; set; }
+
+ protected override async Task OnInitializedAsync()
+ {
+ await using var db = await DbFactory.CreateDbContextAsync();
+ var meters = await db.Meters.AsNoTracking()
+ .Select(m => new MeterHit(m.Id, m.Name, m.EnergyType != null ? m.EnergyType.DisplayName : "—", m.Mode, m.IsActive, m.SerialNumber, m.Location))
+ .ToListAsync();
+ _meters = meters
+ .OrderBy(m => !m.IsActive)
+ .ThenBy(m => m.EnergyType, StringComparer.CurrentCultureIgnoreCase)
+ .ThenBy(m => m.Name, StringComparer.CurrentCultureIgnoreCase)
+ .ToList();
+ }
+
+ private IEnumerable Matches()
+ {
+ if (_meters is null)
+ {
+ return [];
+ }
+
+ if (string.IsNullOrWhiteSpace(_search))
+ {
+ return _meters;
+ }
+
+ var term = _search.Trim();
+ return _meters.Where(m => Has(m.Name) || Has(m.SerialNumber) || Has(m.Location) || Has(m.EnergyType));
+
+ bool Has(string? value) => value?.Contains(term, StringComparison.CurrentCultureIgnoreCase) == true;
+ }
+
+ private static string Describe(MeterHit meter)
+ {
+ var parts = new List { meter.EnergyType, meter.Mode.Display() };
+ if (!string.IsNullOrWhiteSpace(meter.SerialNumber))
+ {
+ parts.Add(Loc.F(S.MeterDetail_SerialValue, meter.SerialNumber));
+ }
+
+ if (!string.IsNullOrWhiteSpace(meter.Location))
+ {
+ parts.Add(meter.Location);
+ }
+
+ if (!meter.IsActive)
+ {
+ parts.Add(S.MeterDetail_Retired);
+ }
+
+ return string.Join(" · ", parts);
+ }
+
+ /// Enter opens the first match, so a typed name plus Enter is the whole interaction on a keyboard.
+ private void OnKeyDown(KeyboardEventArgs e)
+ {
+ if (e.Key == "Enter" && Matches().FirstOrDefault() is { } first)
+ {
+ Go(MeterLinks.Detail(first.Id));
+ }
+ }
+
+ private void OnResultKey(KeyboardEventArgs e, MeterHit hit)
+ {
+ if (e.Key is "Enter" or " ")
+ {
+ Go(MeterLinks.Detail(hit.Id));
+ }
+ }
+
+ private void Go(string href)
+ {
+ Dialog?.Close();
+ Nav.NavigateTo(href);
+ }
+
+ private sealed record MeterHit(int Id, string Name, string EnergyType, MeterMode Mode, bool IsActive, string? SerialNumber, string? Location);
+}
diff --git a/src/App/DraftStore.cs b/src/App/DraftStore.cs
new file mode 100644
index 0000000..6f855d4
--- /dev/null
+++ b/src/App/DraftStore.cs
@@ -0,0 +1,35 @@
+using System.Diagnostics.CodeAnalysis;
+
+namespace MeterVault.App;
+
+///
+/// Unsaved dialog input that has to survive a detour to another page of the same circuit.
+///
+///
+/// A meter's source dialog sends the user off to create the connector the source needs, and the meter
+/// page is disposed on the way. Keeping the draft here, keyed by what it belongs to, lets the dialog come
+/// back with everything that was typed instead of empty. Scoped: it lives as long as the circuit and is
+/// never shared between users.
+///
+public sealed class DraftStore
+{
+ private readonly Dictionary _drafts = new(StringComparer.Ordinal);
+
+ public void Save(string key, object draft) => _drafts[key] = draft;
+
+ /// Hands back a draft once; it is gone afterwards.
+ public bool TryTake(string key, [NotNullWhen(true)] out T? draft)
+ where T : class
+ {
+ if (_drafts.Remove(key, out var stored) && stored is T typed)
+ {
+ draft = typed;
+ return true;
+ }
+
+ draft = null;
+ return false;
+ }
+
+ public void Discard(string key) => _drafts.Remove(key);
+}
diff --git a/src/App/LocalTimeEntry.cs b/src/App/LocalTimeEntry.cs
new file mode 100644
index 0000000..02bed36
--- /dev/null
+++ b/src/App/LocalTimeEntry.cs
@@ -0,0 +1,77 @@
+namespace MeterVault.App;
+
+///
+/// A date and time of day as typed into a pair of pickers, read in the instance timezone. Values
+/// are stored UTC (SDD §10) but entered as wall-clock time, so a reading taken at 18:00 reads back
+/// as 18:00 rather than as its UTC instant.
+///
+///
+/// Shared by every dialog that timestamps something by hand — a reading, a swap, a delivery — so
+/// they agree on the two awkward hours of the year: a spring-forward time names no instant at all
+/// and is refused, and an ambiguous autumn time resolves to standard time,
+/// 's default. Those two candidate instants are an hour apart on one night
+/// a year, well inside the precision of a timestamp somebody typed.
+///
+public sealed class LocalTimeEntry(TimeZoneInfo zone)
+{
+ public TimeZoneInfo Zone { get; } = zone;
+
+ /// Bound to the date picker; only the date part is used.
+ public DateTime? Date { get; set; }
+
+ /// Bound to the time picker; minute precision.
+ public TimeSpan? TimeOfDay { get; set; }
+
+ /// The wall-clock moment the pickers describe, or null until a date is chosen.
+ public DateTime? WallClock => Date is { } date ? date.Date + (TimeOfDay ?? TimeSpan.Zero) : null;
+
+ /// True when the wall-clock time falls in a spring-forward gap and so names no instant.
+ public bool IsSkipped =>
+ WallClock is { } wall && Zone.IsInvalidTime(DateTime.SpecifyKind(wall, DateTimeKind.Unspecified));
+
+ /// The UTC instant entered, or null while incomplete or skipped.
+ public DateTimeOffset? Utc
+ {
+ get
+ {
+ if (WallClock is not { } wall || IsSkipped)
+ {
+ return null;
+ }
+
+ var unspecified = DateTime.SpecifyKind(wall, DateTimeKind.Unspecified);
+ return new DateTimeOffset(TimeZoneInfo.ConvertTimeToUtc(unspecified, Zone), TimeSpan.Zero);
+ }
+ }
+
+ /// Resolves the configured timezone id, falling back to UTC for an unknown one.
+ public static TimeZoneInfo Resolve(string? id)
+ {
+ if (string.IsNullOrWhiteSpace(id))
+ {
+ return TimeZoneInfo.Utc;
+ }
+
+ try
+ {
+ return TimeZoneInfo.FindSystemTimeZoneById(id);
+ }
+ catch (Exception ex) when (ex is TimeZoneNotFoundException or InvalidTimeZoneException)
+ {
+ return TimeZoneInfo.Utc;
+ }
+ }
+
+ /// Sets the pickers to in the instance timezone, dropping seconds.
+ public void Set(DateTimeOffset instant)
+ {
+ var local = TimeZoneInfo.ConvertTime(instant, Zone);
+ Date = local.Date;
+ TimeOfDay = new TimeSpan(local.Hour, local.Minute, 0);
+ }
+
+ public void SetNow() => Set(DateTimeOffset.UtcNow);
+
+ /// An instant as the instance's wall clock, for display.
+ public DateTimeOffset Local(DateTimeOffset instant) => TimeZoneInfo.ConvertTime(instant, Zone);
+}
diff --git a/src/App/Localization/DisplayNames.cs b/src/App/Localization/DisplayNames.cs
index e894853..0037ed3 100644
--- a/src/App/Localization/DisplayNames.cs
+++ b/src/App/Localization/DisplayNames.cs
@@ -84,7 +84,7 @@ public static class DisplayNames
return string.Empty;
}
- var names = new List(3);
+ var names = new List(4);
if (value.HasFlag(ReadingFlags.CounterReset))
{
names.Add(Strings.Enum_ReadingFlags_CounterReset);
@@ -100,6 +100,11 @@ public static class DisplayNames
names.Add(Strings.Enum_ReadingFlags_Anomaly);
}
+ if (value.HasFlag(ReadingFlags.MonthLabel))
+ {
+ names.Add(Strings.Enum_ReadingFlags_MonthLabel);
+ }
+
return names.Count > 0 ? string.Join(", ", names) : value.ToString();
}
diff --git a/src/App/Localization/MeterEventText.cs b/src/App/Localization/MeterEventText.cs
new file mode 100644
index 0000000..c8caad8
--- /dev/null
+++ b/src/App/Localization/MeterEventText.cs
@@ -0,0 +1,58 @@
+using MeterVault.Core.Domain;
+using MeterVault.Infrastructure.Ingestion;
+
+namespace MeterVault.App.Localization;
+
+/// How meter-event outcomes are worded — one place, shared by the event dialog and the meter page.
+public static class MeterEventText
+{
+ public static string Problem(MeterEventProblem problem) => problem switch
+ {
+ MeterEventProblem.None => string.Empty,
+ MeterEventProblem.UnknownMeter => Strings.MeterDetail_MeterGone,
+ MeterEventProblem.NotRecordableForMode => Strings.MeterEvent_ProblemNotForMode,
+ MeterEventProblem.AmountRequired => Strings.MeterEvent_ProblemAmountRequired,
+ MeterEventProblem.AmountOutOfRange => Strings.MeterEvent_ProblemAmountOutOfRange,
+ MeterEventProblem.NoteRequired => Strings.MeterEvent_ProblemNoteRequired,
+ MeterEventProblem.LevelNeedsCalibration => Strings.MeterEvent_ProblemNeedsCalibration,
+ MeterEventProblem.LevelAtSameTime => Strings.MeterEvent_ProblemLevelAtSameTime,
+ MeterEventProblem.LaterBoundaryDependsOnIt => Strings.MeterEvent_ProblemLaterBoundary,
+ MeterEventProblem.OldRegisterBelowPreviousReading => Strings.MeterEvent_ProblemBelowPrevious,
+ MeterEventProblem.ReadingAtSameTime => Strings.MeterEvent_ProblemReadingAtSameTime,
+ MeterEventProblem.BoundaryAlreadyRecorded => Strings.MeterEvent_ProblemAlreadyRecorded,
+ MeterEventProblem.StartReadingRejected => Strings.MeterEvent_ProblemStartRejected,
+ MeterEventProblem.NotFound => Strings.MeterEvent_ProblemNotFound,
+ MeterEventProblem.Imported => Strings.MeterEvent_ProblemImported,
+ MeterEventProblem.NotManual => Strings.MeterEvent_ProblemNotManual,
+ _ => problem.ToString(),
+ };
+
+ public static string Saved(MeterEventType type) => type switch
+ {
+ MeterEventType.MeterSwap => Strings.MeterEvent_SavedSwap,
+ MeterEventType.CounterReset => Strings.MeterEvent_SavedReset,
+ MeterEventType.TankLevel => Strings.MeterEvent_SavedTankLevel,
+ MeterEventType.Delivery => Strings.MeterEvent_SavedDelivery,
+ _ => Strings.MeterEvent_SavedNote,
+ };
+
+ public static string Intro(MeterEventType type) => type switch
+ {
+ MeterEventType.MeterSwap => Strings.MeterEvent_IntroSwap,
+ MeterEventType.CounterReset => Strings.MeterEvent_IntroReset,
+ MeterEventType.TankLevel => Strings.MeterEvent_IntroTankLevel,
+ MeterEventType.Delivery => Strings.MeterEvent_IntroDelivery,
+ _ => Strings.MeterEvent_IntroNote,
+ };
+
+ /// The Material icon for an event type, so menus and the events list read at a glance.
+ public static string Icon(MeterEventType type) => type switch
+ {
+ MeterEventType.MeterSwap => MudBlazor.Icons.Material.Filled.SwapHoriz,
+ MeterEventType.CounterReset => MudBlazor.Icons.Material.Filled.RestartAlt,
+ MeterEventType.TankLevel => MudBlazor.Icons.Material.Filled.Straighten,
+ MeterEventType.Delivery => MudBlazor.Icons.Material.Filled.LocalShipping,
+ MeterEventType.Correction => MudBlazor.Icons.Material.Filled.Build,
+ _ => MudBlazor.Icons.Material.Filled.StickyNote2,
+ };
+}
diff --git a/src/App/Localization/Strings.de.resx b/src/App/Localization/Strings.de.resx
index ce99752..0df7d64 100644
--- a/src/App/Localization/Strings.de.resx
+++ b/src/App/Localization/Strings.de.resx
@@ -240,6 +240,9 @@
Konnektor hinzufügen
+
+ Zurück zu „{0}“
+
Basis-URL (z. B. http://homeassistant.local:8123)
@@ -252,6 +255,9 @@
Konnektor löschen
+
+ Deaktiviert — diese Quellen erfassen nichts
+
{0} bearbeiten
@@ -270,6 +276,9 @@
Zusätzliche Topics (kommagetrennt, optional)
+
+ Konnektor für eine Quelle von „{0}“ einrichten. Nach dem Speichern geht es zurück.
+
Host
@@ -366,6 +375,15 @@
Langlebiges Zugriffstoken (gespeichert – zum Ersetzen neu eingeben)
+
+ Nicht verwendet
+
+
+ Verwendet von
+
+
+ +{0} weitere
+
Name der Benutzernamen-Umgebungsvariablen (optional)
@@ -408,8 +426,11 @@
Keine Zähler für Vorräte gefunden. Legen Sie einen Zähler mit dem Modus
+
+ , oder laden Sie die Referenzdaten über
+
- und einem Tank an oder laden Sie die Referenzdaten über
+ an und richten Sie seinen Tank ein unter
Vorräte
@@ -417,9 +438,15 @@
({0} {1}/Tag)
+
+ Lieferung erfassen
+
Füllstand
+
+ Für {0} ist noch kein Tank eingerichtet — Füllstand, Füllgrad und Prognose brauchen Fassungsvermögen und Peilstab-Kalibrierung des Tanks.
+
Verbrauch ({0})
@@ -438,8 +465,20 @@
Letzter Monat mit Daten
-
- Noch keine Kostendaten – Tabelle importieren oder Tarife anlegen.
+
+ Kosten werden je Kostenkategorie ausgewiesen, und es gibt noch keine:
+
+
+ Dieses Jahr noch keine Kosten. Sie erscheinen, sobald ein Zähler in einer Kostenkategorie Verbrauch bei gültigem Tarif erfasst.
+
+
+ Noch kein Zähler gehört zu einer Kostenkategorie. Kategorien beim Bearbeiten eines Zählers wählen oder je Kategorie zuordnen:
+
+
+ Noch keine Zähler. Kosten beginnen mit einem Zähler — einen anlegen oder vorhandene Daten importieren:
+
+
+ Noch keine Tarife — Verbrauch braucht einen Preis, bevor er Kosten hat:
Dieser Monat
@@ -528,6 +567,9 @@
vorgelagerte Zähler
+
+ Für diese Zähler ist im gewählten Zeitraum noch kein Verbrauch erfasst.
+
Für diese Energieart gibt es noch keine Zähler. Legen Sie welche an unter
@@ -612,6 +654,9 @@
Zählerwechsel
+
+ Monatsendstand
+
Geschätzt
@@ -879,6 +924,9 @@
Quelle
+
+ Geschrieben in
+
Probelauf mit Referenzblatt
@@ -957,6 +1005,9 @@
Dunkler Modus
+
+ Zähler suchen
+
Sprache
@@ -975,6 +1026,9 @@
etwa gleich
+
+ Ersten Zählerstand erfassen
+
Zählerstand erfassen
@@ -984,6 +1038,9 @@
Quelle hinzufügen
+
+ Weiteren Konnektor einrichten
+
Attribut (optional; leer = state)
@@ -1002,12 +1059,18 @@
Komponente
+
+ Live-Quelle verbinden
+
Konnektor
„{0}“ ist deaktiviert; diese Quelle würde also nie Daten erfassen. Zuerst aktivieren.
+
+ „{0}“ ist deaktiviert und kann diese Quelle nicht bedienen —
+
„{0}“ ist ein {1}-Konnektor; eine {2}-Quelle benötigt {3}.
@@ -1021,7 +1084,25 @@
einen anlegen
- Unter dem letzten Zählerstand ({0} {1}) bei einem Zählwerk, das nur vorwärts zählt — der Wert wird abgelehnt. Nach einem Zählerwechsel oder Zählerreset dieses Ereignis zuerst im Tab „Ereignisse“ erfassen.
+ Unter dem letzten Zählerstand ({0} {1}) bei einem Zählwerk, das nur vorwärts zählt — der Wert würde abgelehnt. Wurde der Zähler gewechselt oder das Zählwerk zurückgesetzt, erfassen Sie das zuerst — der eingegebene Wert bleibt erhalten.
+
+
+ {0} vom {1} löschen? Der dabei erfasste Anfangsstand des neuen Zählwerks wird mit entfernt und der Verbrauch neu berechnet.
+
+
+ Ereignis löschen
+
+
+ {0} vom {1} löschen? Der Verbrauch wird neu berechnet.
+
+
+ Diesen manuell erfassten Zählerstand löschen
+
+
+ Zählerstand {0} {1} vom {2} löschen? Der Verbrauch wird ohne ihn neu berechnet.
+
+
+ Zählerstand löschen
Diese {0}-Quelle löschen?
@@ -1029,15 +1110,36 @@
Quelle löschen
+
+ Zähler bearbeiten
+
Quelle bearbeiten
+
+ aktivieren
+
+
+ Energiefluss „{0}“ öffnen
+
Wert eingeben
Entity-ID (z. B. sensor.house_power)
+
+ Ereignis gelöscht — Verbrauch neu berechnet.
+
+
+ Notizen kommentieren die Historie dieses Zählers und ändern keine Werte.
+
+
+ Hier Zählerwechsel oder Zählerreset erfassen — die Historie bleibt an diesem Zähler durchgängig.
+
+
+ Füllstände und Lieferungen bestimmen den Verbrauch dieses Tanks.
+
Flags
@@ -1047,6 +1149,18 @@
Dieser Zeitpunkt liegt in der Zukunft.
+
+ Für diesen Zähler ist noch nichts erfasst — beginnen Sie mit einem ersten Wert.
+
+
+ Zu den Ereignissen
+
+
+ Importiert
+
+
+ Stammt aus einem Import — zum Entfernen den Import zurücknehmen.
+
Art
@@ -1071,6 +1185,9 @@
Ortszeit in {0}.
+
+ Tarife verwalten
+
Diesen Zähler gibt es nicht mehr.
@@ -1093,7 +1210,7 @@
Noch kein normalisierter Verbrauch.
- Keine Ereignisse (Zählerwechsel, Lieferungen, Korrekturen).
+ Noch keine Ereignisse erfasst.
Noch keine Rohdaten.
@@ -1104,6 +1221,9 @@
Diesem Zähler ist keine Quelle zugeordnet. Eine Quelle hinzufügen, um Daten von MQTT/Tasmota oder Home Assistant zu erfassen.
+
+ Für diesen Zähler ist noch kein Tank eingerichtet — Füllstand, Füllgrad und Prognose brauchen Fassungsvermögen und Peilstab-Kalibrierung des Tanks.
+
Keine passenden Tarife.
@@ -1149,11 +1269,14 @@
Qualität
+
+ Zählerstand gelöscht — Verbrauch neu berechnet.
+
Zählerstand ({0})
- Abgelehnt — unter dem vorherigen Zählerstand bei einem Zählwerk, das nur vorwärts zählt. Zuerst einen Zählerreset oder Zählerwechsel erfassen.
+ Abgelehnt — unter dem vorherigen Zählerstand bei einem Zählwerk, das nur vorwärts zählt. Wurde der Zähler gewechselt oder zurückgesetzt, erfassen Sie das zuerst.
Zählerstand zu diesem Zeitpunkt ersetzt durch {0} {1}.
@@ -1170,6 +1293,18 @@
Die letzten {0} (Rohdaten, unveränderlich und revisionssicher). Zeiten in {1}.
+
+ Ereignis erfassen
+
+
+ Zählerreset erfassen
+
+
+ Zählerwechsel erfassen
+
+
+ Füllstand erfassen
+
Details zum Zählwerk
@@ -1179,6 +1314,9 @@
Für diesen Zeitpunkt gibt es bereits einen Zählerstand — beim Speichern wird sein Wert ersetzt.
+
+ Ersetzt den beim Zählerwechsel erfassten Anfangsstand des neuen Zählers — der Verbrauch über den Wechsel bleibt korrekt.
+
stillgelegt
@@ -1191,6 +1329,15 @@
Skalierung
+
+ Dieser Zähler
+
+
+ Seriennr. {0}
+
+
+ Tank einrichten
+
Diese Uhrzeit gab es in {0} nicht — die Uhren wurden vorgestellt. Bitte eine andere Zeit wählen.
@@ -1203,6 +1350,9 @@
Quellentyp
+
+ Unter letztem Stand — Zählerwechsel?
+
Verbrauch ({0})
@@ -1218,6 +1368,9 @@
Tarife ({0})
+
+ Der Verbrauch eines Tanks ergibt sich aus Füllständen und Lieferungen, die als Ereignisse erfasst werden — ein hier eingetragener Zählerstand hätte keine Wirkung.
+
offen
@@ -1243,13 +1396,13 @@
Wertpfad (z. B. ENERGY.Total; leer = einfacher Zahlenwert)
- Ein virtueller Zähler berechnet sich per Formel aus anderen Zählern und speichert keine eigenen Zählerstände — den Zählerstand an dem Zähler eintragen, auf den sich die Formel bezieht.
+ Ein virtueller Zähler hat keine eigenen Zählerstände — tragen Sie die Zählerstände an den Zählern ein, die er aufsummiert.
- Virtueller Zähler — sein Wert wird beim Abruf per Formel aus anderen Zählern berechnet; er hat also keine eigene gespeicherte Zeitreihe.
+ Virtueller Zähler — er hat keine eigenen Zählerstände; sein Wert ist die Summe der verknüpften vorgelagerten Zähler.
-
- Die Werte stehen unter Trends.
+
+ In der Flussansicht ansehen.
ggü. Vormonat
@@ -1257,18 +1410,189 @@
{0} ggü. {1} im Vorjahr
-
- — wird abgelehnt
-
ja
+
+ Das hat nicht geklappt, es wurde nichts geändert. Bitte erneut versuchen; Details stehen im Log.
+
+
+ Zählerstand nach dem Reset ({0})
+
+
+ Der letzte Wert vor dem Neustart. Leer lassen, wenn unbekannt — der Abschnitt seit dem letzten Zählerstand wird dann nicht gezählt.
+
+
+ Zählerstand vor dem Reset ({0})
+
+
+ Spätere Zählerstände zählen ab {0} {1} weiter.
+
+
+ Geliefert ({0})
+
+
+ Seitdem geliefert: {0} {1}
+
+
+ Erster Füllstand für diesen Tank — der Verbrauch wird ab dem nächsten gezählt.
+
+
+ Eine Befüllung des Tanks. Sie erhöht den Bestand; der nächste Füllstand macht aus der Differenz den Verbrauch.
+
+
+ Eine Anmerkung zur Historie dieses Zählers, z. B. eine Reparatur oder ein versetzter Sensor. Sie ändert keine Werte.
+
+
+ Das Zählwerk hat neu begonnen — Reset oder Überlauf —, es ist aber derselbe Zähler. Spätere Zählerstände zählen ab dem Wert nach dem Reset weiter.
+
+
+ Der physische Zähler wurde getauscht. Tragen Sie den Endstand des alten und den Anfangsstand des neuen Zählers ein — die Historie läuft an diesem Zähler ohne Lücke und ohne Ausreißer weiter.
+
+
+ Ein Peilstab- oder Anzeigewert für den Tankinhalt. Der Verbrauch ist die Differenz zum vorherigen Füllstand plus die Lieferungen dazwischen.
+
+
+ Letzter Füllstand: {0} ({1} {2}) am {3}
+
+
+ Letzter Zählerstand davor: {0} {1} am {2}
+
+
+ Füllstand
+
+
+ Der Füllstand ist um {0} {1} stärker gestiegen als die erfassten Lieferungen — fehlt eine Lieferung? Es wird kein Verbrauch gebucht.
+
+
+ = {0} {1} im Tank
+
+
+ {0} Live-Quelle(n) liefern Werte für diesen Zähler. Falls sie noch das alte Zählwerk lesen, passen Sie sie im Tab „Quellen“ an oder deaktivieren Sie sie — sonst wird ihr nächster Wert gegen das neue gerechnet.
+
+
+ Meist 0 oder der Wert aus dem Einbauprotokoll.
+
+
+ Neuer Zähler — Anfangsstand ({0})
+
+
+ Zentimeter brauchen die Peilstab-Kalibrierung des Tanks — einzurichten unter „Zähler bearbeiten“.
+
+
+ Kein früherer Zählerstand — das alte Zählwerk zählt ab dem Anfangs-Zählerstand des Zählers, {0} {1}.
+
+
+ Keine Zahl
+
+
+ Notiz
+
+
+ Notiz (optional)
+
+
+ Aus dem Wechselprotokoll oder einem Foto des alten Zählers. Vorbelegt mit dem letzten Zählerstand — die Differenz wird beim Wechsel als Verbrauch gebucht.
+
+
+ Alter Zähler — Endstand ({0})
+
+
+ Zwischen denselben zwei Zählerständen ist bereits ein Wechsel oder Reset erfasst — löschen Sie ihn zuerst oder wählen Sie eine Zeit nach dem nächsten Zählerstand.
+
+
+ Dieser Wert ist hier nicht möglich — eine Lieferung muss größer als null sein, ein Füllstand darf nicht negativ sein.
+
+
+ Bitte einen Wert eingeben.
+
+
+ Der Endstand des alten Zählwerks liegt unter seinem letzten Stand (letzter Zählerstand oder Anfangs-Zählerstand des Zählers) — bitte beide Werte prüfen.
+
+
+ Dies stammt aus einem Import — zum Entfernen den Import zurücknehmen.
+
+
+ Ein späterer Zählerwechsel oder Zählerreset baut darauf auf — löschen Sie zuerst diesen.
+
+
+ Zu genau dieser Zeit ist bereits ein Füllstand erfasst — löschen Sie ihn zuerst oder wählen Sie eine andere Minute.
+
+
+ Ein Füllstand in Zentimetern braucht die Kalibrierung des Tanks — unter „Zähler bearbeiten“ einrichten oder das Volumen eingeben.
+
+
+ Dieses Ereignis passt nicht zu einem Zähler in diesem Messmodus.
+
+
+ Existiert nicht mehr — die Seite war veraltet.
+
+
+ Nur manuell erfasste Zählerstände lassen sich hier löschen — gemessene sind der Prüfnachweis, importierte werden mit ihrem Import entfernt.
+
+
+ Bitte die Notiz eingeben.
+
+
+ Zu genau dieser Zeit gibt es bereits einen Zählerstand — wählen Sie eine Minute davor oder danach, je nachdem, zu welchem Zähler er gehört.
+
+
+ Der Anfangsstand des neuen Zählwerks konnte nicht gespeichert werden, weil inzwischen ein Zählerstand hinzukam. Es wurde nichts gespeichert — bitte erneut versuchen.
+
+
+ Nach diesem Zeitpunkt sind bereits {0} Zählerstände erfasst — der nächste ist {1} {2} am {3}. Sie zählen dann zum neuen Zählwerk; gehören sie noch zum alten Zähler, wählen Sie eine spätere Zeit.
+
+
+ Lieferung erfasst — Verbrauch neu berechnet.
+
+
+ Notiz gespeichert.
+
+
+ Zählerreset erfasst — Verbrauch neu berechnet.
+
+
+ Zählerwechsel erfasst — Verbrauch neu berechnet.
+
+
+ Füllstand erfasst — Verbrauch neu berechnet.
+
+
+ {0} {1} seit diesem Zählerstand, gebucht zu diesem Zeitpunkt.
+
+
+ {0} — {1}
+
+
+ Verbrauch seit dem letzten Füllstand: {0} {1}
+
+
+ {0} von {1} angezeigt — zum Eingrenzen weitertippen.
+
+
+ Noch keine Zähler.
+
+
+ Name, Seriennummer oder Standort
+
Aktiv
Zähler hinzufügen
+
+ Während der Dialog offen war, hat sich etwas geändert (ein Zähler oder eine Kategorie wurde gelöscht). Nichts wurde gespeichert — bitte prüfen und erneut speichern.
+
+
+ Kostenkategorien
+
+
+ Das Dashboard zeigt Kosten je Kategorie; ein Zähler ohne Kategorie erscheint dort nicht.
+
+
+ Über seine Energieart bereits enthalten in: {0}
+
„{0}“ wirklich löschen? Das kann nicht rückgängig gemacht werden.
@@ -1317,24 +1641,69 @@
nein
+
+ Noch keine Kostenkategorien, daher erscheinen die Kosten dieses Zählers nicht im Dashboard. Anlegen unter
+
+
+ Kein Zähler passt zu „{0}“.
+
PV-Rolle (optional)
+
+ Hauszähler als total_load und Netzzähler als grid_import markieren, um Eigenverbrauch, Autarkie und Ersparnis auf der Solar-Seite freizuschalten.
+
- Modus/Anfangs-Zählerstand geändert — der Verbrauch wird beim Speichern neu berechnet.
+ Verbrauchsrelevante Einstellungen geändert — der Verbrauch wird beim Speichern neu berechnet.
+
+
+ Stattdessen Zählerwechsel erfassen
Name, Energieart und Einheit sind erforderlich.
+
+ Wurde der physische Zähler getauscht? Erfassen Sie stattdessen einen Zählerwechsel: Historie, Quellen, Flussverknüpfungen und Kostenkategorien bleiben lückenlos an diesem Zähler. Stilllegen nur, wenn der Zähler endgültig wegfällt.
+
— keine —
+
+ Suche nach Name, Seriennummer, Standort oder Art
+
Seriennummer (optional)
Quellen
+
+ Fassungsvermögen
+
+
+ Bitte das Fassungsvermögen angeben, um den Tank einzurichten.
+
+
+ Feste Rate ({0}/h)
+
+
+ Das Fassungsvermögen bestimmt den Füllgrad; die Kalibrierung rechnet Peilstab-Zentimeter in Volumen um. Füllstände und Lieferungen werden dann im Tab „Ereignisse“ des Zählers erfasst.
+
+
+ Versatz ({0})
+
+
+ Brennerrate
+
+
+ Tank
+
+
+ {0} pro cm
+
+
+ z. B. 7000 L ÷ 150 cm = 46,67. Leer lassen, wenn Füllstände als Volumen erfasst werden.
+
Dieser Zähler misst einen Teilbereich des Flusses der gewählten Zähler.
@@ -1371,6 +1740,12 @@
Übersicht
+
+ Zähler & Daten
+
+
+ Energie
+
Einstellungen
@@ -1506,8 +1881,11 @@
Keine Erzeugungszähler gefunden. Legen Sie einen Zähler mit dem Modus
+
+ an oder laden Sie die Referenzdaten über
+
- an oder laden Sie die Referenzdaten über
+ unter
Ersparnis
@@ -1519,13 +1897,13 @@
{0} % der Erzeugung
- Markieren Sie einen Zähler als
+ Setzen Sie die PV-Rolle Ihres Hauszählers auf
- und einen als
+ und die Ihres Netzzählers auf
- (in den Zähler-Metadaten), um Eigenverbrauch, Autarkie und Ersparnis freizuschalten.
+ , um Eigenverbrauch, Autarkie und Ersparnis freizuschalten — im Zählereditor unter
Tarif hinzufügen
diff --git a/src/App/Localization/Strings.resx b/src/App/Localization/Strings.resx
index dcfb651..4927a62 100644
--- a/src/App/Localization/Strings.resx
+++ b/src/App/Localization/Strings.resx
@@ -240,6 +240,9 @@
Add connector
+
+ Back to '{0}'
+
Base URL (e.g. http://homeassistant.local:8123)
@@ -252,6 +255,9 @@
Delete connector
+
+ Disabled — these sources are not ingesting
+
Edit {0}
@@ -270,6 +276,9 @@
Extra topics (comma-separated, optional)
+
+ Setting up a connector for a source of '{0}'. Saving it takes you back.
+
Host
@@ -366,6 +375,15 @@
Long-lived access token (stored — type to replace)
+
+ Not used
+
+
+ Used by
+
+
+ +{0} more
+
Username env-var name (optional)
@@ -408,8 +426,11 @@
No consumable meters found. Add a meter with mode
+
+ , or load the reference data from
+
- and a tank, or load the reference data from
+ and set up its tank in
Consumables
@@ -417,9 +438,15 @@
({0} {1}/day)
+
+ Record delivery
+
Tank level
+
+ {0} has no tank set up yet — its level, fill and forecast need the tank's capacity and dipstick calibration.
+
{0} used
@@ -438,8 +465,20 @@
Latest month with data
-
- No cost data yet — import a sheet or add tariffs.
+
+ Costs are reported per cost category, and there is none yet:
+
+
+ No costs this year yet. They appear once a meter in a cost category records consumption with a tariff in effect.
+
+
+ No meter counts toward a cost category yet. Pick categories when editing a meter, or assign them per category:
+
+
+ No meters yet. Costs start with a meter — add one, or import existing data:
+
+
+ No tariffs yet — consumption needs a price before it has a cost:
This month
@@ -528,6 +567,9 @@
upstream meter(s)
+
+ No consumption recorded for these meters in the selected range yet.
+
No meters for this energy type yet. Add meters in
@@ -612,6 +654,9 @@
Meter swap
+
+ Month-end value
+
Estimated
@@ -879,6 +924,9 @@
Source
+
+ Wrote to
+
Dry-run a reference sheet
@@ -957,6 +1005,9 @@
Dark mode
+
+ Find a meter
+
Language
@@ -975,6 +1026,9 @@
about the same
+
+ Add first reading
+
Add reading
@@ -984,6 +1038,9 @@
Add source
+
+ Set up another connector
+
Attribute (optional; blank = state)
@@ -1002,12 +1059,18 @@
Component
+
+ Connect a live source
+
Connector
'{0}' is disabled, so this source would never ingest. Enable it first.
+
+ '{0}' is disabled, so it cannot serve this source —
+
'{0}' is a {1} connector; a {2} source needs {3}.
@@ -1021,7 +1084,25 @@
create one
- Below the last reading ({0} {1}) on a register that only counts up, so it will be rejected. If the meter was swapped or reset, record that on the Events tab first.
+ Below the last reading ({0} {1}) on a register that only counts up, so it would be rejected. If the meter was swapped or its counter reset, record that first — the value you typed is kept.
+
+
+ Delete the {0} of {1}? The new register's start reading recorded with it is removed too, and consumption is recomputed.
+
+
+ Delete event
+
+
+ Delete the {0} of {1}? Consumption is recomputed.
+
+
+ Delete this hand-entered reading
+
+
+ Delete the reading {0} {1} of {2}? Consumption is recomputed without it.
+
+
+ Delete reading
Delete this {0} source?
@@ -1029,15 +1110,36 @@
Delete source
+
+ Edit meter
+
Edit source
+
+ enable it
+
+
+ Open the {0} flow
+
Enter a value
Entity id (e.g. sensor.house_power)
+
+ Event deleted — consumption recomputed.
+
+
+ Notes annotate this meter's history; they change no figures.
+
+
+ Record a meter swap or a counter reset here — the history stays continuous on this meter.
+
+
+ Tank levels and deliveries drive this tank's consumption.
+
Flags
@@ -1047,6 +1149,18 @@
That time is in the future.
+
+ Nothing recorded for this meter yet — start with a first value.
+
+
+ Go to events
+
+
+ Imported
+
+
+ Came from an import — revert that import to remove it.
+
Kind
@@ -1071,6 +1185,9 @@
Local time in {0}.
+
+ Manage tariffs
+
This meter no longer exists.
@@ -1093,7 +1210,7 @@
No normalized consumption yet.
- No events (swaps, deliveries, corrections).
+ No events recorded yet.
No raw readings.
@@ -1104,6 +1221,9 @@
No ingest sources bound to this meter. Add one to pull from MQTT/Tasmota or Home Assistant.
+
+ No tank set up for this meter yet — its level, fill and forecast need the tank's capacity and dipstick calibration.
+
No applicable tariffs.
@@ -1149,11 +1269,14 @@
Quality
+
+ Reading deleted — consumption recomputed.
+
Reading ({0})
- Rejected — below the previous reading on a register that only counts up. Record a counter reset or meter swap first.
+ Rejected — below the previous reading on a register that only counts up. If the meter was swapped or reset, record that first.
Replaced the reading at that time with {0} {1}.
@@ -1170,6 +1293,18 @@
Most recent {0} (raw, immutable audit truth). Times in {1}.
+
+ Record event
+
+
+ Record counter reset
+
+
+ Record meter swap
+
+
+ Record tank level
+
Meter register details
@@ -1179,6 +1314,9 @@
This meter already has a reading at that time — saving replaces its value.
+
+ Replaces the new meter's start value recorded with the swap — consumption across the swap stays correct.
+
retired
@@ -1191,6 +1329,15 @@
Scale
+
+ This meter
+
+
+ S/N {0}
+
+
+ Set up tank
+
That clock time never happened in {0} — the clocks moved forward. Pick another time.
@@ -1203,6 +1350,9 @@
Source type
+
+ Below last reading — swapped or reset?
+
Consumption ({0})
@@ -1218,6 +1368,9 @@
Tariffs ({0})
+
+ A tank's consumption comes from tank levels and deliveries, which are recorded as events — a reading entered here would change nothing.
+
open
@@ -1243,13 +1396,13 @@
Value path (e.g. ENERGY.Total; blank = bare scalar)
- A virtual meter is an expression over other meters, so it stores no readings of its own — enter the reading on the meter the expression refers to.
+ A virtual meter has no readings of its own — enter readings on the meters it adds up.
- Virtual meter — its value is an expression over other meters, evaluated when read, so it has no stored series of its own.
+ Virtual meter — it has no readings of its own; its value is the sum of the upstream meters linked to it.
-
- See Trends for its figures.
+
+ See it in the flow view.
vs last month
@@ -1257,18 +1410,189 @@
{0} vs {1} last year
-
- — will be rejected
-
yes
+
+ That did not go through, and nothing was changed. Try again; the details are in the log.
+
+
+ Register after the reset ({0})
+
+
+ Its last value before it started over. Leave empty if unknown — the stretch since the last reading is then not counted.
+
+
+ Register before the reset ({0})
+
+
+ Later readings count on from {0} {1}.
+
+
+ Delivered ({0})
+
+
+ Delivered since then: {0} {1}
+
+
+ First level for this tank — consumption is counted from the next one.
+
+
+ A refill of the tank. It raises the level; the next tank level turns the difference into consumption.
+
+
+ A remark on this meter's history, e.g. a repair or a moved sensor. It changes no figures.
+
+
+ The register started over — a reset or a rollover — but it is still the same meter. Later readings count on from the value after the reset.
+
+
+ The physical meter was replaced. Enter the old meter's final reading and the new meter's start reading — the history continues on this meter without a gap or a spike.
+
+
+ A dipstick or gauge reading of what is in the tank. Consumption is the difference to the previous level, plus the deliveries in between.
+
+
+ Last level: {0} ({1} {2}) on {3}
+
+
+ Last reading before: {0} {1} on {2}
+
+
+ Tank level
+
+
+ The level rose {0} {1} more than the recorded deliveries — is a delivery missing? It is booked as no consumption.
+
+
+ = {0} {1} in the tank
+
+
+ {0} live source(s) feed this meter. If they still read the old register, update or disable them on the Sources tab — otherwise their next value counts against the new one.
+
+
+ Usually 0, or the value on the installation record.
+
+
+ New meter — start reading ({0})
+
+
+ Centimetres need the tank's dipstick calibration — set it up under Edit meter.
+
+
+ No earlier reading — the old register counts from the meter's initial baseline, {0} {1}.
+
+
+ Not a number
+
+
+ Note
+
+
+ Note (optional)
+
+
+ From the swap record or a photo of the old meter. Prefilled with the last reading — the difference is booked as consumption at the swap.
+
+
+ Old meter — final reading ({0})
+
+
+ A swap or reset is already recorded between the same two readings — delete it first, or pick a time after the next reading.
+
+
+ That value is not possible here — a delivery must be above zero, and a level cannot be negative.
+
+
+ Enter an amount.
+
+
+ The old register's final reading is below where it last stood (its last reading, or the meter's initial baseline) — check both numbers.
+
+
+ This came from an import — revert that import to remove it.
+
+
+ A later meter swap or counter reset was recorded against this — delete that one first.
+
+
+ A tank level is already recorded at exactly this time — delete that one first, or pick another minute.
+
+
+ A level in centimetres needs the tank's calibration — set it up under Edit meter, or enter the volume.
+
+
+ This event does not apply to a meter in this measurement mode.
+
+
+ It no longer exists — the page was out of date.
+
+
+ Only hand-entered readings can be deleted here — measured readings are the audit record, and imported ones go with their import.
+
+
+ Enter the note.
+
+
+ There is already a reading at exactly this time — pick a minute before or after it, depending on which meter it belongs to.
+
+
+ The new register's start reading could not be stored because a reading arrived meanwhile. Nothing was saved — try again.
+
+
+ {0} reading(s) are already recorded after this time — the next is {1} {2} on {3}. They will count as the new register; if they still belong to the old meter, pick a later time.
+
+
+ Delivery recorded — consumption recomputed.
+
+
+ Note saved.
+
+
+ Counter reset recorded — consumption recomputed.
+
+
+ Meter swap recorded — consumption recomputed.
+
+
+ Tank level recorded — consumption recomputed.
+
+
+ {0} {1} since that reading, booked at this time.
+
+
+ {0} — {1}
+
+
+ Used since the last level: {0} {1}
+
+
+ Showing {0} of {1} — keep typing to narrow down.
+
+
+ No meters yet.
+
+
+ Name, serial number or location
+
Active
Add meter
+
+ Something changed while this dialog was open (a meter or category was deleted). Nothing was saved — check and save again.
+
+
+ Cost categories
+
+
+ The dashboard reports cost per category; a meter in none has no cost there.
+
+
+ Already counted through its energy type in: {0}
+
Delete '{0}'? This cannot be undone.
@@ -1317,24 +1641,69 @@
no
+
+ No cost categories yet, so this meter's cost will not show on the dashboard. Create one in
+
+
+ No meter matches “{0}”.
+
PV role (optional)
+
+ Tag the house meter total_load and the grid meter grid_import to unlock self-consumption, autarky and savings on the Solar page.
+
- Mode/baseline changed — consumption will be recomputed on save.
+ Settings that affect consumption changed — it will be recomputed on save.
+
+
+ Record meter swap instead
Name, energy type and unit are required.
+
+ Was the physical meter replaced? Record a meter swap instead: history, sources, flow links and cost categories stay on this meter, without a gap. Retire a meter only when it is gone for good.
+
— none —
+
+ Search by name, serial number, location or type
+
Serial number (optional)
Sources
+
+ Capacity
+
+
+ Enter the tank's capacity to set up its tank.
+
+
+ Fixed rate ({0}/h)
+
+
+ Capacity sets the fill level; the calibration turns dipstick centimetres into volume. Levels and deliveries are then recorded on the meter's Events tab.
+
+
+ Offset ({0})
+
+
+ Burner rate
+
+
+ Tank
+
+
+ {0} per cm
+
+
+ e.g. 7000 L ÷ 150 cm = 46.67. Leave empty if levels are entered as volume.
+
This meter measures a subsection of the selected meter(s)' flow.
@@ -1371,6 +1740,12 @@
Overview
+
+ Meters & data
+
+
+ Energy
+
Settings
@@ -1506,8 +1881,11 @@
No generation meters found. Add a meter with mode
+
+ , or load the reference data from
+
- or load the reference data from
+ in
Savings (Ersparnis)
@@ -1519,13 +1897,13 @@
{0}% of generation
- Tag a meter
+ Set the PV role of your house meter to
- and one
+ and of your grid meter to
- (in meter metadata) to unlock self-consumption, autarky and savings.
+ to unlock self-consumption, autarky and savings — in the meter editor under
Add tariff
diff --git a/src/App/MeterLinks.cs b/src/App/MeterLinks.cs
new file mode 100644
index 0000000..034ebf6
--- /dev/null
+++ b/src/App/MeterLinks.cs
@@ -0,0 +1,148 @@
+using System.Globalization;
+using MeterVault.Core.Domain;
+
+namespace MeterVault.App;
+
+///
+/// Addresses into a meter's page. The meter page is where every per-meter task lives — readings,
+/// swaps, tank levels, sources, settings — so other screens link straight to the tab and the action
+/// rather than to the top of the page and a hunt for the right button.
+///
+///
+/// /meters/{id}?tab=events&action=swap opens the Events tab with the swap dialog up. The
+/// action is consumed once and dropped from the address, so reloading the page does not reopen it.
+///
+public static class MeterLinks
+{
+ public const string TabReadings = "readings";
+ public const string TabConsumption = "consumption";
+ public const string TabEvents = "events";
+ public const string TabTariffs = "tariffs";
+ public const string TabSources = "sources";
+
+ public const string ActionReading = "reading";
+ public const string ActionEdit = "edit";
+ public const string ActionSource = "source";
+
+ /// Query keys that preset the source dialog: the source to edit, its type, its connector.
+ public const string ParamSource = "source";
+ public const string ParamSourceType = "type";
+ public const string ParamConnector = "connector";
+
+ /// Tab keys in the order the meter page renders its panels.
+ public static readonly IReadOnlyList Tabs = [TabReadings, TabConsumption, TabEvents, TabTariffs, TabSources];
+
+ public static string Detail(int meterId, string? tab = null, string? action = null)
+ {
+ var query = new List(2);
+ if (!string.IsNullOrEmpty(tab))
+ {
+ query.Add($"tab={Uri.EscapeDataString(tab)}");
+ }
+
+ if (!string.IsNullOrEmpty(action))
+ {
+ query.Add($"action={Uri.EscapeDataString(action)}");
+ }
+
+ return query.Count == 0 ? $"/meters/{meterId}" : $"/meters/{meterId}?{string.Join('&', query)}";
+ }
+
+ ///
+ /// The Sources tab with the source dialog open: a new source, or for an
+ /// existing one, optionally already set to a source type and connector. This is where the connector
+ /// page returns to once the connector a source was waiting for exists.
+ ///
+ public static string Source(int meterId, int? sourceId = null, SourceType? sourceType = null, int? connectorId = null)
+ {
+ var link = Detail(meterId, TabSources, ActionSource);
+ if (sourceId is { } source)
+ {
+ link += $"&{ParamSource}={source.ToString(CultureInfo.InvariantCulture)}";
+ }
+
+ if (sourceType is { } type)
+ {
+ link += $"&{ParamSourceType}={type}";
+ }
+
+ if (connectorId is { } connector)
+ {
+ link += $"&{ParamConnector}={connector.ToString(CultureInfo.InvariantCulture)}";
+ }
+
+ return link;
+ }
+
+ ///
+ /// The connector page with a new connector of open, for a source that
+ /// has none yet. Saving it leads back to the source dialog with the new connector picked.
+ ///
+ ///
+ /// The way back is the meter's id, not a URL, so the connector page cannot be made to redirect
+ /// anywhere but a meter page.
+ ///
+ public static string NewConnector(int meterId, int? sourceId, SourceType sourceType, EndpointType endpointType) =>
+ $"/admin/connectors?new={endpointType}{ReturnQuery(meterId, sourceId, sourceType)}";
+
+ /// The connector page with an existing connector open — to enable it — and the same way back.
+ public static string EditConnector(int meterId, int? sourceId, SourceType sourceType, int connectorId) =>
+ $"/admin/connectors?edit={connectorId.ToString(CultureInfo.InvariantCulture)}{ReturnQuery(meterId, sourceId, sourceType)}";
+
+ private static string ReturnQuery(int meterId, int? sourceId, SourceType sourceType) =>
+ $"&meter={meterId.ToString(CultureInfo.InvariantCulture)}"
+ + (sourceId is { } source ? $"&{ParamSource}={source.ToString(CultureInfo.InvariantCulture)}" : "")
+ + $"&{ParamSourceType}={sourceType}";
+
+ /// The meter page's Events tab with the dialog for open.
+ public static string Event(int meterId, MeterEventType type) => Detail(meterId, TabEvents, ActionFor(type));
+
+ ///
+ /// The one-tap data entry for a meter: a reading for a register, a tank level for a tank, and
+ /// nothing for a virtual meter, which has nothing to enter.
+ ///
+ public static string? QuickEntry(int meterId, MeterMode mode) => mode switch
+ {
+ MeterMode.Virtual => null,
+ MeterMode.ConsumableBalance => Event(meterId, MeterEventType.TankLevel),
+ _ => Detail(meterId, TabReadings, ActionReading),
+ };
+
+ public static string ActionFor(MeterEventType type) => type switch
+ {
+ MeterEventType.MeterSwap => "swap",
+ MeterEventType.CounterReset => "reset",
+ MeterEventType.Delivery => "delivery",
+ MeterEventType.TankLevel => "tank-level",
+ MeterEventType.Correction => "correction",
+ _ => "note",
+ };
+
+ /// The event an action names, or null if it names something else (or nothing).
+ public static MeterEventType? EventFor(string? action)
+ {
+ foreach (var type in Enum.GetValues())
+ {
+ if (string.Equals(ActionFor(type), action, StringComparison.OrdinalIgnoreCase))
+ {
+ return type;
+ }
+ }
+
+ return null;
+ }
+
+ /// The panel index for a tab key; unknown or missing keys open the first tab.
+ public static int TabIndex(string? tab)
+ {
+ for (var i = 0; i < Tabs.Count; i++)
+ {
+ if (string.Equals(Tabs[i], tab, StringComparison.OrdinalIgnoreCase))
+ {
+ return i;
+ }
+ }
+
+ return 0;
+ }
+}
diff --git a/src/App/NavState.cs b/src/App/NavState.cs
new file mode 100644
index 0000000..273e264
--- /dev/null
+++ b/src/App/NavState.cs
@@ -0,0 +1,13 @@
+namespace MeterVault.App;
+
+///
+/// Per-circuit signal that something the navigation menu lists has changed. The menu lives in the
+/// layout, which Blazor keeps for the whole circuit, so without this a new or renamed energy type
+/// would not appear in it until the browser reloaded.
+///
+public sealed class NavState
+{
+ public event Action? EnergyTypesChanged;
+
+ public void NotifyEnergyTypesChanged() => EnergyTypesChanged?.Invoke();
+}
diff --git a/src/App/Program.cs b/src/App/Program.cs
index f4edfd2..864a7d1 100644
--- a/src/App/Program.cs
+++ b/src/App/Program.cs
@@ -25,6 +25,8 @@ try
builder.Services.Configure(
builder.Configuration.GetSection(MeterVaultOptions.SectionName));
+ // One zone id for .NET and PostgreSQL alike: a Windows id works for the first and not the second.
+ builder.Services.PostConfigure(o => o.TimeZone = InstanceTimeZone.Canonical(o.TimeZone));
var connectionString = builder.Configuration.GetConnectionString("Default")
?? "Host=localhost;Port=5432;Database=metervault;Username=metervault;Password=metervault";
@@ -74,6 +76,8 @@ try
builder.Services.AddLocalization();
builder.Services.AddMudServices();
+ builder.Services.AddScoped();
+ builder.Services.AddScoped();
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
@@ -147,9 +151,14 @@ finally
static async Task MigrateDatabaseAsync(WebApplication app)
{
- var options = app.Configuration
- .GetSection(MeterVaultOptions.SectionName)
- .Get() ?? new MeterVaultOptions();
+ var options = app.Services.GetRequiredService>().Value;
+
+ // Months are divided and bucketed in this zone. An id nobody knows would silently divide in UTC while
+ // every database query that buckets by it fails, so say so where the operator will look.
+ if (!TimeZoneInfo.TryFindSystemTimeZoneById(options.TimeZone, out _))
+ {
+ Log.Error("MeterVault:TimeZone '{TimeZone}' is not a known timezone id (expected an IANA id such as Europe/Berlin)", options.TimeZone);
+ }
if (!options.RunMigrationsAtStartup)
{
@@ -160,6 +169,14 @@ static async Task MigrateDatabaseAsync(WebApplication app)
var db = scope.ServiceProvider.GetRequiredService();
await db.Database.MigrateAsync().ConfigureAwait(false);
await DatabaseSeeder.SeedAsync(db).ConfigureAwait(false);
+
+ var zoneKnownToDatabase = await db.Database
+ .SqlQuery($"SELECT EXISTS (SELECT 1 FROM pg_timezone_names WHERE name = {options.TimeZone}) AS \"Value\"")
+ .SingleAsync().ConfigureAwait(false);
+ if (!zoneKnownToDatabase)
+ {
+ Log.Error("MeterVault:TimeZone '{TimeZone}' is not a timezone PostgreSQL knows; every chart and cost query will fail", options.TimeZone);
+ }
Log.Information("Database migrations applied and defaults seeded");
if (options.SeedReferenceData)
@@ -169,6 +186,16 @@ static async Task MigrateDatabaseAsync(WebApplication app)
await importer.LoadAsync(dir).ConfigureAwait(false);
Log.Information("Reference dataset ensured (SeedReferenceData=true)");
}
+
+ // After the data is in place: stored consumption is derived, so when the engine starts booking
+ // readings differently, existing meters are rebuilt once rather than only as new readings arrive.
+ var rebuilt = await scope.ServiceProvider
+ .GetRequiredService()
+ .RunAsync().ConfigureAwait(false);
+ if (rebuilt > 0)
+ {
+ Log.Information("Recomputed consumption for {Meters} meter(s) after a normalization change", rebuilt);
+ }
}
/// Exposed for WebApplicationFactory-based integration tests.
diff --git a/src/App/ReadingEntry.cs b/src/App/ReadingEntry.cs
index 6965eb4..65e7f52 100644
--- a/src/App/ReadingEntry.cs
+++ b/src/App/ReadingEntry.cs
@@ -46,9 +46,11 @@ public sealed class ReadingEntry
/// Seeds the buffer with a meter's last reading, marked pristine.
public void Prefill(double value)
{
- // "0.###" keeps a register readable (12345,6) without inventing precision the meter
- // never had; invariant then swapped so the buffer only ever contains one separator glyph.
- Text = value.ToString("0.###", CultureInfo.InvariantCulture).Replace('.', Separator);
+ // "0.#########" keeps a register readable (12345,6) without inventing precision the meter
+ // never had, yet round-trips a sensor's four or five decimals — rounded to three, the prefill
+ // would read as lower than the stored reading and be flagged as a decrease before a key is
+ // pressed. Invariant then swapped so the buffer only ever contains one separator glyph.
+ Text = value.ToString("0.#########", CultureInfo.InvariantCulture).Replace('.', Separator);
IsPristine = true;
}
diff --git a/src/Core/Domain/Enums.cs b/src/Core/Domain/Enums.cs
index 6c91f66..9f1e6a1 100644
--- a/src/Core/Domain/Enums.cs
+++ b/src/Core/Domain/Enums.cs
@@ -53,6 +53,13 @@ public enum ReadingFlags
CounterReset = 1,
MeterSwap = 2,
Anomaly = 4,
+
+ ///
+ /// An imported row whose date was a month name ("Mai 2026"), stamped on the 1st: it carries the
+ /// register at the end of that month, not at the instant it is stamped. Set by the importer, which
+ /// is the only place that still knows whether the date cell named a month or a day.
+ ///
+ MonthLabel = 8,
}
/// Discrete meter lifecycle/correction events (SDD meter_event.event_type).
diff --git a/src/Core/Domain/MeterEventRules.cs b/src/Core/Domain/MeterEventRules.cs
new file mode 100644
index 0000000..6c31243
--- /dev/null
+++ b/src/Core/Domain/MeterEventRules.cs
@@ -0,0 +1,44 @@
+namespace MeterVault.Core.Domain;
+
+///
+/// Which lifecycle events a meter can meaningfully record, decided by its measurement mode — the
+/// same dispatch the normalizers use, so the UI never offers an event that would change nothing.
+///
+///
+/// is deliberately never offered: no normalizer reads it, so
+/// recording one would look like a fix while leaving every derived number untouched.
+///
+public static class MeterEventRules
+{
+ private static readonly MeterEventType[] RegisterEvents =
+ [MeterEventType.MeterSwap, MeterEventType.CounterReset, MeterEventType.Note];
+
+ private static readonly MeterEventType[] TankEvents =
+ [MeterEventType.TankLevel, MeterEventType.Delivery, MeterEventType.Note];
+
+ private static readonly MeterEventType[] NoteOnly = [MeterEventType.Note];
+
+ /// The event types worth recording for a meter in , most common first.
+ public static IReadOnlyList RecordableFor(MeterMode mode) => mode switch
+ {
+ MeterMode.CumulativeCounter or MeterMode.GenerationCounter or MeterMode.RuntimeCounter => RegisterEvents,
+ MeterMode.ConsumableBalance => TankEvents,
+ _ => NoteOnly,
+ };
+
+ public static bool CanRecord(MeterMode mode, MeterEventType type) => RecordableFor(mode).Contains(type);
+
+ /// A swap or reset: an instant where a register legitimately starts over.
+ public static bool IsRegisterBoundary(MeterEventType type) =>
+ type is MeterEventType.MeterSwap or MeterEventType.CounterReset;
+
+ ///
+ /// Whether raw readings drive this meter's consumption. A tank is driven by level and delivery
+ /// events, and a virtual meter by other meters, so a reading typed against either changes nothing.
+ ///
+ public static bool TakesReadings(MeterMode mode) => mode is not (MeterMode.ConsumableBalance or MeterMode.Virtual);
+
+ /// Whether the register may only count up, so a lower value needs a swap or reset to explain it.
+ public static bool IsMonotonic(MeterMode mode) =>
+ mode is MeterMode.CumulativeCounter or MeterMode.GenerationCounter or MeterMode.RuntimeCounter;
+}
diff --git a/src/Core/Domain/SourceRouting.cs b/src/Core/Domain/SourceRouting.cs
new file mode 100644
index 0000000..8cf29f8
--- /dev/null
+++ b/src/Core/Domain/SourceRouting.cs
@@ -0,0 +1,30 @@
+namespace MeterVault.Core.Domain;
+
+///
+/// Which connector a live source is served by. Routing is endpoint-scoped: a source without a
+/// connector of the right kind has no connection details and silently never ingests.
+///
+public static class SourceRouting
+{
+ ///
+ /// The connector kind a source type needs, or null if it needs none (manual/import/virtual).
+ /// Tasmota has no endpoint kind of its own — it is served by an MQTT broker connector.
+ ///
+ public static EndpointType? RequiredEndpoint(SourceType sourceType) => sourceType switch
+ {
+ SourceType.HomeAssistant => EndpointType.HomeAssistant,
+ SourceType.Mqtt or SourceType.Tasmota => EndpointType.MqttBroker,
+ _ => null,
+ };
+
+ /// Whether a connector of can serve a source of .
+ public static bool Serves(EndpointType endpointType, SourceType sourceType) =>
+ RequiredEndpoint(sourceType) == endpointType;
+
+ /// The source type a new source on a connector of this kind starts as.
+ public static SourceType DefaultSourceFor(EndpointType endpointType) => endpointType switch
+ {
+ EndpointType.HomeAssistant => SourceType.HomeAssistant,
+ _ => SourceType.Mqtt,
+ };
+}
diff --git a/src/Core/Normalization/GapAttribution.cs b/src/Core/Normalization/GapAttribution.cs
index b300bf2..53de177 100644
--- a/src/Core/Normalization/GapAttribution.cs
+++ b/src/Core/Normalization/GapAttribution.cs
@@ -1,80 +1,154 @@
+using MeterVault.Core.Domain;
+
namespace MeterVault.Core.Normalization;
///
-/// Spreads a register delta that spans several calendar months across the months it actually covers.
+/// Attributes a register delta to the calendar months it actually accrued in.
///
///
-/// A counter delta is booked at the reading that closes it, which is right when readings arrive at
-/// the reporting cadence: a monthly series books December's usage against the 1 January reading, and
-/// that is what the reference spreadsheet does. It stops being right when a meter goes unread for a
-/// long stretch — 78 days of PV generation arriving as a single July row makes June look idle and
-/// July look extraordinary, when nothing unusual happened.
-///
-/// Splitting is therefore deliberately conservative: an interval is divided only when it contains
-/// two or more complete calendar months. A normal monthly series contains exactly one, so it
-/// is left completely untouched and the golden-fixture reconciliation stands (SDD §13); a series that
-/// skipped a month or more contains two or more, which is precisely where lumping misleads.
-///
-/// Counting whole months contained, rather than boundaries crossed, is what makes this stable against
-/// readings that do not land on midnight: a monthly reading arriving at 06:00 on the 1st still
-/// contains one whole month, where a boundary count would tip over and hand the new month a sliver.
-///
-/// The division is by elapsed time, so it assumes a flat rate across the gap. That is a guess — the
-/// meter recorded a total, not a shape — so every row it produces is marked
-/// . The sum is exact: the final segment absorbs any
-/// rounding remainder, so a split never creates or destroys energy.
-///
-/// Boundaries are UTC. The dashboard buckets in the instance timezone (SDD §10), so a split point
-/// can sit an hour or two from the displayed month edge — immaterial for apportioning a multi-month
-/// gap, and the alternative would be threading a timezone through the otherwise timezone-free engine.
+///
+/// A reading is an instant, and the consumption between two readings happened over the time between
+/// them. Booking the whole delta at the closing reading puts it in the wrong month whenever the
+/// interval crosses a month boundary: a reading on 1 August and the next on 16 September would show
+/// six weeks of use in September and none in August. So an interval that crosses one or more local
+/// month boundaries is divided at those boundaries, in proportion to elapsed time, and every share is
+/// stamped inside the month it belongs to. The meter recorded a total, not a shape, so a divided
+/// interval's rows are marked ; the parts always sum to the
+/// original, so nothing is created or lost.
+///
+///
+/// Imported monthly tables are the exception that keeps the golden fixtures reconciling (SDD §13). A
+/// row labelled "Mai 2026" carries the register at the end of May and May's consumption — the
+/// sheet is filled after the month closes — but the importer stamps it on the 1st so that it files
+/// under its month, and flags it .
+/// reads such a month label as the end of its month. Two consecutive
+/// labels then span exactly their closing month and book unchanged, while a live reading that follows
+/// the last imported row counts from the end of that row's month instead of claiming it a second time.
+/// A day-dated row ("01.08.2026") is an instant like any other reading, even though it is stamped at
+/// the same midnight — which is why label status is recorded at import rather than read off the clock.
+///
+///
+/// Months are the instance's local months (SDD §10), because that is how every chart buckets them: a
+/// UTC split would let a reading taken shortly after local midnight on the 1st file a whole month's
+/// use under the new month.
+///
///
public static class GapAttribution
{
///
- /// True when an interval contains two or more complete calendar months, and so would misattribute
- /// a long gap to its closing month.
+ /// True for a row from an imported monthly table (). Such a
+ /// timestamp names a month, not the moment of reading. A hand correction of the row keeps that
+ /// meaning; a live source writing to the same instant clears the flag (see the ingestion upsert).
///
- public static bool ShouldSplit(DateTimeOffset start, DateTimeOffset end) =>
- end > start && WholeMonthsInside(start, end) >= 2;
+ public static bool IsMonthLabel(Reading reading)
+ {
+ ArgumentNullException.ThrowIfNull(reading);
+
+ return reading.Flags.HasFlag(ReadingFlags.MonthLabel);
+ }
///
- /// Divides across the calendar months between the two instants,
- /// proportionally to the time spent in each.
+ /// The instant a reading describes: its timestamp, or for a month label
+ /// the local midnight that ends the labelled month.
///
- ///
- /// Each segment is stamped at its end, which keeps the existing convention that a
- /// consumption row records the period ending at its timestamp — the same reason an unsplit delta
- /// sits on its closing reading, and the reason the reference sheet's January row carries
- /// December's usage. So the share covering May is stamped 1 June and buckets as June, exactly as
- /// a May-to-June monthly reading pair already would. The last segment therefore keeps the closing
- /// reading's own timestamp, and nothing shifts relative to how unsplit intervals are labelled.
- ///
- public static IReadOnlyList Split(DateTimeOffset start, DateTimeOffset end, double amount)
+ public static DateTimeOffset EffectiveTime(Reading reading, TimeZoneInfo zone)
{
- if (end <= start)
+ ArgumentNullException.ThrowIfNull(reading);
+ ArgumentNullException.ThrowIfNull(zone);
+
+ return IsMonthLabel(reading)
+ ? LocalMidnight(LabelledMonth(reading).AddMonths(1), zone)
+ : reading.Time;
+ }
+
+ ///
+ /// Where a reading's own consumption row is stamped: its timestamp, unless it is a month label whose
+ /// timestamp falls outside the labelled local month. In a zone behind UTC, 00:00 UTC on the 1st is
+ /// still the previous local month, so such a label is stamped at the start of its month instead.
+ ///
+ public static DateTimeOffset StampTime(Reading reading, TimeZoneInfo zone)
+ {
+ ArgumentNullException.ThrowIfNull(reading);
+ ArgumentNullException.ThrowIfNull(zone);
+
+ if (!IsMonthLabel(reading))
{
- return [new GapSegment(end, amount)];
+ return reading.Time;
}
- var total = end - start;
+ var monthStart = LabelMonthStart(reading, zone);
+ return reading.Time >= monthStart && reading.Time < EffectiveTime(reading, zone) ? reading.Time : monthStart;
+ }
+
+ /// The local midnight that starts the month a label names.
+ public static DateTimeOffset LabelMonthStart(Reading reading, TimeZoneInfo zone)
+ {
+ ArgumentNullException.ThrowIfNull(reading);
+ ArgumentNullException.ThrowIfNull(zone);
+
+ return LocalMidnight(LabelledMonth(reading), zone);
+ }
+
+ ///
+ /// The instant a local calendar day starts in , in UTC. Readers turn the dates
+ /// of a requested range into instants with this, so a range covers exactly the local months its rows
+ /// are bucketed and stamped in.
+ ///
+ public static DateTimeOffset LocalMidnight(DateOnly date, TimeZoneInfo zone)
+ {
+ ArgumentNullException.ThrowIfNull(zone);
+
+ return LocalMidnight(date.ToDateTime(TimeOnly.MinValue), zone);
+ }
+
+ ///
+ /// Divides , accrued over [from, to), across the local calendar
+ /// months it touches, in proportion to the time spent in each.
+ ///
+ ///
+ /// Where the closing reading's own row goes (). The share for the month it
+ /// falls in keeps it, so an interval inside a single month produces exactly the row it always did. A
+ /// share in any other month is stamped at the last second of that month — inside it, and strictly
+ /// between the two readings.
+ ///
+ public static IReadOnlyList Attribute(
+ DateTimeOffset from, DateTimeOffset to, DateTimeOffset closingStamp, double amount, TimeZoneInfo zone)
+ {
+ ArgumentNullException.ThrowIfNull(zone);
+
+ if (to <= from)
+ {
+ return [new GapSegment(closingStamp.ToUniversalTime(), amount)];
+ }
+
+ var total = to - from;
var segments = new List();
- var cursor = start;
+ var cursor = from;
var assigned = 0d;
- while (cursor < end)
+ while (cursor < to)
{
- var nextBoundary = NextMonthStart(cursor);
- var segmentEnd = nextBoundary < end ? nextBoundary : end;
-
- if (segmentEnd >= end)
+ var monthStart = MonthStartContaining(cursor, zone);
+ var monthEnd = NextMonthStart(monthStart, zone);
+ var segmentEnd = monthEnd < to ? monthEnd : to;
+ var last = segmentEnd >= to;
+ if (segmentEnd <= cursor)
{
- // Final segment takes the remainder, so the parts always sum to the original.
- segments.Add(new GapSegment(end, amount - assigned));
- break;
+ // Zone data that contradicts itself around a transition must never stall the walk: book
+ // the rest here rather than loop without advancing.
+ segmentEnd = to;
+ last = true;
}
- var share = amount * ((segmentEnd - cursor) / total);
- segments.Add(new GapSegment(segmentEnd, share));
+ // The final share takes the remainder, so rounding never creates or destroys energy.
+ var share = last ? amount - assigned : amount * ((segmentEnd - cursor) / total);
+ var stamp = closingStamp >= monthStart && closingStamp < monthEnd
+ ? closingStamp
+ : InsideSegment(cursor, segmentEnd, monthEnd);
+
+ // UTC, like every stored instant (SDD §10): the boundaries are computed as local wall-clock
+ // times, and PostgreSQL's timestamptz accepts only zero offsets from Npgsql.
+ segments.Add(new GapSegment(stamp.ToUniversalTime(), share));
assigned += share;
cursor = segmentEnd;
}
@@ -82,29 +156,61 @@ public static class GapAttribution
return segments;
}
- private static int WholeMonthsInside(DateTimeOffset start, DateTimeOffset end)
+ /// The month a label names — its UTC month, which is what the importer wrote.
+ private static DateTime LabelledMonth(Reading reading)
{
- // A month counts only if it lies entirely within the interval, so a partial month at either
- // edge never tips the decision.
- var cursor = MonthStart(start) == start.ToUniversalTime() ? MonthStart(start) : NextMonthStart(start);
- var whole = 0;
- while (cursor.AddMonths(1) <= end)
+ var utc = reading.Time.UtcDateTime;
+ return new DateTime(utc.Year, utc.Month, 1);
+ }
+
+ /// The last second of the month, or the segment's midpoint when the segment is shorter than that.
+ private static DateTimeOffset InsideSegment(DateTimeOffset start, DateTimeOffset end, DateTimeOffset monthEnd)
+ {
+ var lastSecond = (end < monthEnd ? end : monthEnd).AddSeconds(-1);
+ return lastSecond > start ? lastSecond : start + ((end - start) / 2);
+ }
+
+ private static DateTimeOffset MonthStartContaining(DateTimeOffset instant, TimeZoneInfo zone)
+ {
+ var local = TimeZoneInfo.ConvertTime(instant, zone);
+ return LocalMidnight(new DateTime(local.Year, local.Month, 1), zone);
+ }
+
+ private static DateTimeOffset NextMonthStart(DateTimeOffset monthStart, TimeZoneInfo zone)
+ {
+ var local = TimeZoneInfo.ConvertTime(monthStart, zone);
+ return LocalMidnight(new DateTime(local.Year, local.Month, 1).AddMonths(1), zone);
+ }
+
+ ///
+ /// Midnight at the start of a local date as an instant. A zone whose clocks jump forward at midnight
+ /// has no such moment on that day; the first moment that does exist is the month's real start. A zone
+ /// whose clocks fall back at midnight has it twice; the first of the two is.
+ ///
+ private static DateTimeOffset LocalMidnight(DateTime date, TimeZoneInfo zone)
+ {
+ var day = DateTime.SpecifyKind(date.Date, DateTimeKind.Unspecified);
+ var wall = day;
+ while (zone.IsInvalidTime(wall))
{
- whole++;
- cursor = cursor.AddMonths(1);
+ wall = wall.AddMinutes(30);
}
- return whole;
- }
+ // The larger offset is the earlier UTC instant, i.e. the first occurrence of the wall-clock time.
+ var offset = zone.IsAmbiguousTime(wall) ? zone.GetAmbiguousTimeOffsets(wall).Max() : zone.GetUtcOffset(wall);
+ var instant = new DateTimeOffset(wall, offset).ToUniversalTime();
- private static DateTimeOffset MonthStart(DateTimeOffset instant)
- {
- var utc = instant.ToUniversalTime();
- return new DateTimeOffset(utc.Year, utc.Month, 1, 0, 0, 0, TimeSpan.Zero);
- }
+ // Some zone data reports, for a midnight right at a transition, the offset from after the change
+ // — naming an instant that is still the evening before (America/Asuncion, Europe/Volgograd). The
+ // day starts at the first instant whose local date is that day. Bounded: offsets move by hours.
+ for (var step = 0; step < 4 * 26 && TimeZoneInfo.ConvertTime(instant, zone).DateTime < day; step++)
+ {
+ instant = instant.AddMinutes(15);
+ }
- private static DateTimeOffset NextMonthStart(DateTimeOffset instant) => MonthStart(instant).AddMonths(1);
+ return instant;
+ }
}
-/// One month's share of a spread gap: the instant it closes and the amount attributed.
+/// One month's share of an interval: the instant it is stamped at and the amount attributed.
public sealed record GapSegment(DateTimeOffset Time, double Amount);
diff --git a/src/Core/Normalization/NormalizationContext.cs b/src/Core/Normalization/NormalizationContext.cs
index 8638d2b..b54b31b 100644
--- a/src/Core/Normalization/NormalizationContext.cs
+++ b/src/Core/Normalization/NormalizationContext.cs
@@ -16,6 +16,12 @@ public sealed class NormalizationContext
public IReadOnlyList Events { get; init; } = [];
+ ///
+ /// The instance timezone. Consumption is attributed to local calendar months — the months every
+ /// chart buckets by (SDD §10) — so an interval is divided at local, not UTC, month boundaries.
+ ///
+ public TimeZoneInfo TimeZone { get; init; } = TimeZoneInfo.Utc;
+
/// Referenced meters' consumption series, keyed by meter id (virtual meters only).
public IReadOnlyDictionary> ReferencedSeries { get; init; }
= new Dictionary>();
diff --git a/src/Core/Normalization/NormalizationEngine.cs b/src/Core/Normalization/NormalizationEngine.cs
index d9e70e3..7194264 100644
--- a/src/Core/Normalization/NormalizationEngine.cs
+++ b/src/Core/Normalization/NormalizationEngine.cs
@@ -36,6 +36,33 @@ public sealed class NormalizationEngine : INormalizationEngine
throw new NotSupportedException($"No normalizer registered for mode {context.Meter.Mode}.");
}
- return [.. normalizer.Normalize(context)];
+ return Coalesce(normalizer.Normalize(context));
+ }
+
+ ///
+ /// One row per (time, kind), in time order. A stored row is keyed by meter, time and kind, so two
+ /// rows on the same instant would make the whole recompute fail — and with it every ingest for the
+ /// meter. Rows can meet when a share stamped at a month's last second lands on a reading taken at
+ /// exactly that second, or at a local midnight a label is stamped at. Their amounts add up: the
+ /// total is what the readings say, only its placement is shared.
+ ///
+ private static List Coalesce(IEnumerable rows)
+ {
+ var byKey = new Dictionary<(DateTimeOffset Time, ConsumptionKind Kind), Consumption>();
+ foreach (var row in rows)
+ {
+ if (byKey.TryGetValue((row.Time, row.Kind), out var existing))
+ {
+ existing.Amount += row.Amount;
+ existing.Quality = ReadingQuality.Estimated;
+ existing.ImportBatchId ??= row.ImportBatchId;
+ }
+ else
+ {
+ byKey[(row.Time, row.Kind)] = row;
+ }
+ }
+
+ return [.. byKey.Values.OrderBy(r => r.Time).ThenBy(r => r.Kind)];
}
}
diff --git a/src/Core/Normalization/Normalizers/CounterNormalizerBase.cs b/src/Core/Normalization/Normalizers/CounterNormalizerBase.cs
index caf8cc4..b1028f8 100644
--- a/src/Core/Normalization/Normalizers/CounterNormalizerBase.cs
+++ b/src/Core/Normalization/Normalizers/CounterNormalizerBase.cs
@@ -10,14 +10,21 @@ namespace MeterVault.Core.Normalization.Normalizers;
/// spreadsheet's month-one full register, e.g. Haus 411, Auto 3755);
/// - meter swap → an explicit Amount override if given (how the water swap …861→2
/// reconciles to 12), otherwise (oldFinal − prev) + (curr − newInitial);
-/// - counter reset → baseline restarts at NewValue (default 0);
+/// - counter reset → baseline restarts at NewValue (default 0); a PrevValue, when
+/// given, is the register's last value before the reset and books the stretch since the previous
+/// reading, exactly like a swap — without it that stretch is simply not counted;
/// - unexplained decrease → 0 with an anomaly flagged (never a silent negative), rebaselined
/// to the current value;
-/// - a plain increase spanning two or more whole calendar months → apportioned across them
-/// and marked estimated (), so an unread stretch does not land wholly
-/// in its closing month. A monthly cadence never triggers this.
+/// - a plain increase over an interval that crosses a local month boundary → divided across
+/// those months by elapsed time and marked estimated (), so a reading
+/// on 16 September does not file August's use under September. Consecutive imported month rows
+/// span exactly one month and are never divided.
///
///
+///
+/// "Ascending" is the order of : an imported month row describes the
+/// register at the end of its month, so it comes after live readings taken during that month.
+///
public abstract class CounterNormalizerBase : IMeterNormalizer
{
public abstract MeterMode Mode { get; }
@@ -28,35 +35,32 @@ public abstract class CounterNormalizerBase : IMeterNormalizer
{
ArgumentNullException.ThrowIfNull(context);
- var readings = context.Readings.OrderBy(r => r.Time).ToList();
- if (readings.Count == 0)
+ var zone = context.TimeZone;
+ var timeline = ReadingTimeline.Build(context.Readings, zone);
+ if (timeline.Readings.Count == 0)
{
yield break;
}
- var swaps = context.Events
- .Where(e => e.EventType is MeterEventType.MeterSwap or MeterEventType.CounterReset)
- .OrderBy(e => e.Time)
- .ToList();
+ var swaps = RegisterBoundary.Of(context.Events);
double previous = context.Meter.InitialBaseline;
- DateTimeOffset? previousTime = null;
+ DateTimeOffset? previousEffective = null;
- foreach (var reading in readings)
+ foreach (var (reading, effective) in timeline.Readings)
{
var quality = reading.Quality == ReadingQuality.Measured ? ReadingQuality.Measured : reading.Quality;
- var swap = FindEvent(swaps, previousTime, reading.Time);
+ var swap = RegisterBoundary.Find(swaps, previousEffective, effective, timeline.BoundaryTime);
double amount;
var plainIncrease = false;
if (swap is { EventType: MeterEventType.MeterSwap })
{
- amount = swap.Amount
- ?? ((swap.PrevValue ?? previous) - previous) + (reading.Value - (swap.NewValue ?? 0));
+ amount = swap.Amount ?? RegisterBoundary.Advance(swap, previous, reading.Value);
}
else if (swap is { EventType: MeterEventType.CounterReset })
{
- amount = reading.Value - (swap.NewValue ?? 0);
+ amount = RegisterBoundary.Advance(swap, previous, reading.Value);
}
else if (reading.Value >= previous)
{
@@ -70,57 +74,32 @@ public abstract class CounterNormalizerBase : IMeterNormalizer
quality = ReadingQuality.Estimated;
}
- // Only a plain increase over an unread stretch is worth apportioning (SDD §7.1). A swap
- // or reset amount is an explicit correction booked at its event; a rejected decrease
- // contributes nothing; the first reading has no interval behind it; and fanning a zero
- // out across three months just adds rows that say nothing.
- var gapStart = plainIncrease && Math.Abs(amount) > 1e-9 ? previousTime : null;
+ // Only a plain increase is attributed across months (SDD §7.1). A swap or reset amount is an
+ // explicit correction booked at its event; a rejected decrease contributes nothing; the
+ // first reading has no interval behind it; and fanning a zero out across months just adds
+ // rows that say nothing.
+ var stamp = GapAttribution.StampTime(reading, zone);
+ var segments = plainIncrease && Math.Abs(amount) > 1e-9 && previousEffective is { } from
+ ? GapAttribution.Attribute(from, effective, stamp, amount, zone)
+ : [new GapSegment(stamp.ToUniversalTime(), amount)];
- if (gapStart is { } start && GapAttribution.ShouldSplit(start, reading.Time))
- {
- foreach (var segment in GapAttribution.Split(start, reading.Time, amount))
- {
- yield return new Consumption
- {
- MeterId = context.Meter.MeterId,
- Time = segment.Time,
- Amount = segment.Amount,
- Kind = Kind,
- // The total is measured; only its distribution across the gap is inferred.
- Quality = ReadingQuality.Estimated,
- ImportBatchId = reading.ImportBatchId,
- };
- }
- }
- else
+ foreach (var segment in segments)
{
yield return new Consumption
{
MeterId = context.Meter.MeterId,
- Time = reading.Time,
- Amount = amount,
+ Time = segment.Time,
+ Amount = segment.Amount,
Kind = Kind,
- Quality = quality,
+ // A divided interval's total is measured; only its distribution across the months is
+ // inferred.
+ Quality = segments.Count > 1 ? ReadingQuality.Estimated : quality,
ImportBatchId = reading.ImportBatchId,
};
}
previous = reading.Value;
- previousTime = reading.Time;
+ previousEffective = effective;
}
}
-
- private static MeterEvent? FindEvent(List events, DateTimeOffset? afterExclusive, DateTimeOffset upToInclusive)
- {
- foreach (var e in events)
- {
- var afterOk = afterExclusive is null || e.Time > afterExclusive.Value;
- if (afterOk && e.Time <= upToInclusive)
- {
- return e;
- }
- }
-
- return null;
- }
}
diff --git a/src/Core/Normalization/Normalizers/DirectDeltaNormalizer.cs b/src/Core/Normalization/Normalizers/DirectDeltaNormalizer.cs
index 8256d19..c463603 100644
--- a/src/Core/Normalization/Normalizers/DirectDeltaNormalizer.cs
+++ b/src/Core/Normalization/Normalizers/DirectDeltaNormalizer.cs
@@ -4,7 +4,8 @@ namespace MeterVault.Core.Normalization.Normalizers;
///
/// The source already reports increments (SDD §5.2 direct_delta): each reading value is the
-/// consumption for its interval, used verbatim.
+/// consumption for its interval, used verbatim. An imported month row ("August 2026" = 20) is that
+/// month's consumption and is stamped inside it ().
///
public sealed class DirectDeltaNormalizer : IMeterNormalizer
{
@@ -19,7 +20,7 @@ public sealed class DirectDeltaNormalizer : IMeterNormalizer
yield return new Consumption
{
MeterId = context.Meter.MeterId,
- Time = reading.Time,
+ Time = GapAttribution.StampTime(reading, context.TimeZone).ToUniversalTime(),
Amount = reading.Value,
Kind = ConsumptionKind.Consumption,
Quality = reading.Quality,
diff --git a/src/Core/Normalization/Normalizers/RegisterBoundary.cs b/src/Core/Normalization/Normalizers/RegisterBoundary.cs
new file mode 100644
index 0000000..77ee4e5
--- /dev/null
+++ b/src/Core/Normalization/Normalizers/RegisterBoundary.cs
@@ -0,0 +1,62 @@
+using MeterVault.Core.Domain;
+
+namespace MeterVault.Core.Normalization.Normalizers;
+
+///
+/// The register math shared by every normalizer that reads a monotonic register: a
+/// or is a point
+/// where the register legitimately starts over, and the interval that contains it is booked as the
+/// old register's tail plus the new register's advance rather than as a raw difference.
+///
+internal static class RegisterBoundary
+{
+ /// The swap/reset events of a meter, oldest first — the only events that move a register.
+ public static List Of(IEnumerable events) =>
+ events
+ .Where(e => e.EventType is MeterEventType.MeterSwap or MeterEventType.CounterReset)
+ .OrderBy(e => e.Time)
+ .ToList();
+
+ ///
+ /// The first boundary inside the reading interval (afterExclusive, upToInclusive]. A reading
+ /// taken at exactly the boundary's instant belongs to the new register, which is what lets a swap
+ /// recorded together with the new meter's start reading book cleanly at that instant.
+ ///
+ public static MeterEvent? Find(List boundaries, DateTimeOffset? afterExclusive, DateTimeOffset upToInclusive) =>
+ Find(boundaries, afterExclusive, upToInclusive, e => e.Time);
+
+ ///
+ /// As , placing each boundary on the
+ /// timeline at — for a series walked in effective time rather than stamps.
+ ///
+ public static MeterEvent? Find(
+ List boundaries, DateTimeOffset? afterExclusive, DateTimeOffset upToInclusive, Func timeOf)
+ {
+ foreach (var e in boundaries)
+ {
+ var time = timeOf(e);
+ var afterOk = afterExclusive is null || time > afterExclusive.Value;
+ if (afterOk && time <= upToInclusive)
+ {
+ return e;
+ }
+ }
+
+ return null;
+ }
+
+ ///
+ /// How far the register moved across a boundary: the old register from the previous reading up to
+ /// its final value (, defaulting to the previous reading, i.e. no
+ /// tail), plus the new register from its start value (, default 0)
+ /// up to the current reading.
+ ///
+ ///
+ /// The start value never counts above the current reading. A swap detected in a monthly table records
+ /// the new register at the end of that month as its "start"; when a live reading earlier in the month
+ /// is the first on the new register, it is already below that value, and the advance is zero, not
+ /// negative.
+ ///
+ public static double Advance(MeterEvent boundary, double previous, double current) =>
+ ((boundary.PrevValue ?? previous) - previous) + (current - Math.Min(boundary.NewValue ?? 0, current));
+}
diff --git a/src/Core/Normalization/Normalizers/RuntimeCounterNormalizer.cs b/src/Core/Normalization/Normalizers/RuntimeCounterNormalizer.cs
index 14238c5..4564486 100644
--- a/src/Core/Normalization/Normalizers/RuntimeCounterNormalizer.cs
+++ b/src/Core/Normalization/Normalizers/RuntimeCounterNormalizer.cs
@@ -9,6 +9,15 @@ namespace MeterVault.Core.Normalization.Normalizers;
/// the tank level-Δ (); a runtime meter's Δhours feeds
/// the empirical L/h analytic and is not double-counted as litres.
///
+///
+/// An hour counter is a register like any other, so a replaced burner or a reset counter is a
+/// / handled with the
+/// same register math as : the interval containing the boundary
+/// books the old counter's tail plus the new counter's advance. Without one, a decrease still books
+/// nothing rather than a negative runtime. Readings are walked in order and
+/// a month row's hours are stamped inside the month it names, like any other register; the hours are
+/// not divided across months.
+///
public sealed class RuntimeCounterNormalizer : IMeterNormalizer
{
public MeterMode Mode => MeterMode.RuntimeCounter;
@@ -18,19 +27,36 @@ public sealed class RuntimeCounterNormalizer : IMeterNormalizer
ArgumentNullException.ThrowIfNull(context);
var rate = context.Meter.Tank is { RateMode: TankRateMode.Fixed, FixedRate: { } r } ? r : 1d;
- var readings = context.Readings.OrderBy(x => x.Time).ToList();
+ var timeline = ReadingTimeline.Build(context.Readings, context.TimeZone);
+ var boundaries = RegisterBoundary.Of(context.Events);
double previous = context.Meter.InitialBaseline;
- foreach (var reading in readings)
+ DateTimeOffset? previousTime = null;
+ foreach (var (reading, effective) in timeline.Readings)
{
- var deltaHours = reading.Value >= previous ? reading.Value - previous : 0d;
+ var boundary = RegisterBoundary.Find(boundaries, previousTime, effective, timeline.BoundaryTime);
+
+ double amount;
+ if (boundary is { EventType: MeterEventType.MeterSwap, Amount: { } explicitAmount })
+ {
+ amount = explicitAmount;
+ }
+ else
+ {
+ var deltaHours = boundary is not null
+ ? RegisterBoundary.Advance(boundary, previous, reading.Value)
+ : reading.Value - previous;
+ amount = Math.Max(0d, deltaHours) * rate;
+ }
+
previous = reading.Value;
+ previousTime = effective;
yield return new Consumption
{
MeterId = context.Meter.MeterId,
- Time = reading.Time,
- Amount = deltaHours * rate,
+ Time = GapAttribution.StampTime(reading, context.TimeZone).ToUniversalTime(),
+ Amount = amount,
Kind = ConsumptionKind.Consumption,
Quality = reading.Quality,
ImportBatchId = reading.ImportBatchId,
diff --git a/src/Core/Normalization/ReadingTimeline.cs b/src/Core/Normalization/ReadingTimeline.cs
new file mode 100644
index 0000000..c51a9be
--- /dev/null
+++ b/src/Core/Normalization/ReadingTimeline.cs
@@ -0,0 +1,71 @@
+using MeterVault.Core.Domain;
+
+namespace MeterVault.Core.Normalization;
+
+///
+/// A meter's readings in the order they describe the register, and where its swaps and resets sit on
+/// that order. Shared by every normalizer that reads a register, and by the checks that must agree with
+/// them (the decrease guard, the event dialog), so none of them orders readings differently.
+///
+///
+///
+/// The order is effective time (): an imported month row
+/// describes the register at the end of its month, so it comes after live readings taken during that
+/// month even though it is stamped on the 1st. Walking stamps instead would take the end-of-month value
+/// first, see the live readings as a drop, and count the month twice.
+///
+///
+/// A swap or reset the importer detected in a monthly table is stored at that row's stamp, but the row
+/// only says the register started over at some point during its month. It is placed at the start of
+/// the labelled local month, so it applies to the month's first reading: the month row itself when the
+/// table stands alone, or the first live reading of that month when there are any — which is already
+/// on the new register.
+///
+///
+public sealed class ReadingTimeline
+{
+ private readonly Dictionary _labelMonthStarts;
+
+ private ReadingTimeline(IReadOnlyList readings, Dictionary labelMonthStarts)
+ {
+ Readings = readings;
+ _labelMonthStarts = labelMonthStarts;
+ }
+
+ /// The readings, oldest first by effective time, then by stamp.
+ public IReadOnlyList Readings { get; }
+
+ public static ReadingTimeline Build(IEnumerable readings, TimeZoneInfo zone)
+ {
+ ArgumentNullException.ThrowIfNull(readings);
+ ArgumentNullException.ThrowIfNull(zone);
+
+ var ordered = readings
+ .Select(r => new TimelineReading(r, GapAttribution.EffectiveTime(r, zone)))
+ .OrderBy(r => r.Effective)
+ .ThenBy(r => r.Reading.Time)
+ .ToList();
+
+ var labelMonthStarts = new Dictionary();
+ foreach (var entry in ordered.Where(r => GapAttribution.IsMonthLabel(r.Reading)))
+ {
+ labelMonthStarts.TryAdd(entry.Reading.Time, GapAttribution.LabelMonthStart(entry.Reading, zone));
+ }
+
+ return new ReadingTimeline(ordered, labelMonthStarts);
+ }
+
+ ///
+ /// Where a swap or reset sits on the timeline: its own time, or — when it is stamped exactly at a
+ /// month row — just after the start of that row's local month.
+ ///
+ public DateTimeOffset BoundaryTime(MeterEvent boundary)
+ {
+ ArgumentNullException.ThrowIfNull(boundary);
+
+ return _labelMonthStarts.TryGetValue(boundary.Time, out var monthStart) ? monthStart.AddTicks(1) : boundary.Time;
+ }
+}
+
+/// A reading and the instant it describes.
+public readonly record struct TimelineReading(Reading Reading, DateTimeOffset Effective);
diff --git a/src/Infrastructure/Costing/CostService.cs b/src/Infrastructure/Costing/CostService.cs
index 97d28b9..8ac988a 100644
--- a/src/Infrastructure/Costing/CostService.cs
+++ b/src/Infrastructure/Costing/CostService.cs
@@ -1,8 +1,10 @@
using Dapper;
using MeterVault.Core.Costing;
using MeterVault.Core.Domain;
+using MeterVault.Infrastructure.Options;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Options;
namespace MeterVault.Infrastructure.Costing;
@@ -13,10 +15,18 @@ namespace MeterVault.Infrastructure.Costing;
/// Categories roll up their member meters' costs plus meterless manual costs. Uses a DbContext
/// factory (short-lived context per operation) so it is safe from a Blazor circuit.
///
-public sealed class CostService(IDbContextFactory contextFactory)
+public sealed class CostService(
+ IDbContextFactory contextFactory, IOptions? options = null)
{
private readonly IDbContextFactory _contextFactory = contextFactory;
+ ///
+ /// The instance timezone months and days are bucketed in (SDD §10) — the zone normalization divides
+ /// intervals at, so a share stamped at a month's last second is read back under that month.
+ ///
+ private readonly string _timeZone = (options?.Value ?? new MeterVaultOptions()).TimeZone;
+ private readonly TimeZoneInfo _zone = InstanceTimeZone.Resolve((options?.Value ?? new MeterVaultOptions()).TimeZone);
+
public async Task> GetMeterCostsAsync(
int meterId, DateTimeOffset from, DateTimeOffset to, CostBucket bucket = CostBucket.Month,
CancellationToken cancellationToken = default)
@@ -31,7 +41,7 @@ public sealed class CostService(IDbContextFactory contextFa
}
var tariffs = await db.Tariffs.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false);
- var series = await QueryConsumptionAsync(db, meterId, from, to, bucket, cancellationToken).ConfigureAwait(false);
+ var series = await QueryConsumptionAsync(db, meterId, from, to, bucket, _timeZone, cancellationToken).ConfigureAwait(false);
var results = new List();
foreach (var period in series.Keys.OrderBy(k => k))
@@ -85,8 +95,9 @@ public sealed class CostService(IDbContextFactory contextFa
}
}
- var fromDate = DateOnly.FromDateTime(from.Date);
- var toDate = DateOnly.FromDateTime(to.Date);
+ // Manual costs are dated in local months; the instants passed in are local midnights.
+ var fromDate = DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(from, _zone).Date);
+ var toDate = DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(to, _zone).Date);
var manualCosts = await db.ManualCosts.AsNoTracking()
.Where(c => c.CategoryId == categoryId && c.PeriodStart >= fromDate && c.PeriodStart < toDate)
.ToListAsync(cancellationToken).ConfigureAwait(false);
@@ -100,7 +111,7 @@ public sealed class CostService(IDbContextFactory contextFa
}
private static async Task> QueryConsumptionAsync(
- MeterVaultDbContext db, int meterId, DateTimeOffset from, DateTimeOffset to, CostBucket bucket,
+ MeterVaultDbContext db, int meterId, DateTimeOffset from, DateTimeOffset to, CostBucket bucket, string tz,
CancellationToken cancellationToken)
{
var interval = bucket switch
@@ -111,13 +122,13 @@ public sealed class CostService(IDbContextFactory contextFa
};
var sql =
- $"SELECT (time_bucket(INTERVAL '{interval}', \"time\", 'Europe/Berlin') AT TIME ZONE 'Europe/Berlin')::date AS period, " +
+ $"SELECT (time_bucket(INTERVAL '{interval}', \"time\", @tz) AT TIME ZONE @tz)::date AS period, " +
"kind, sum(amount) AS amount " +
"FROM consumption WHERE meter_id = @meterId AND \"time\" >= @from AND \"time\" < @to " +
"GROUP BY period, kind";
var connection = db.Database.GetDbConnection();
- var command = new CommandDefinition(sql, new { meterId, from, to }, cancellationToken: cancellationToken);
+ var command = new CommandDefinition(sql, new { meterId, from, to, tz }, cancellationToken: cancellationToken);
var rows = await connection.QueryAsync(command).ConfigureAwait(false);
var result = new Dictionary();
diff --git a/src/Infrastructure/Dashboard/ConsumableModels.cs b/src/Infrastructure/Dashboard/ConsumableModels.cs
index fd1c037..d61084d 100644
--- a/src/Infrastructure/Dashboard/ConsumableModels.cs
+++ b/src/Infrastructure/Dashboard/ConsumableModels.cs
@@ -5,6 +5,9 @@ namespace MeterVault.Infrastructure.Dashboard;
/// One recorded delivery into a consumable store.
public sealed record DeliveryRow(DateTimeOffset Time, double Amount, string? Unit);
+/// A consumable-balance meter with no tank yet, so it has no level, fill or forecast to show.
+public sealed record UnconfiguredConsumable(int MeterId, string Name);
+
/// One month of consumable draw.
public sealed record ConsumableMonth(DateOnly Period, double Consumption);
diff --git a/src/Infrastructure/Dashboard/ConsumableService.cs b/src/Infrastructure/Dashboard/ConsumableService.cs
index 54055b5..e0d8aac 100644
--- a/src/Infrastructure/Dashboard/ConsumableService.cs
+++ b/src/Infrastructure/Dashboard/ConsumableService.cs
@@ -2,8 +2,10 @@ using Dapper;
using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Costing;
using MeterVault.Infrastructure.Normalization;
+using MeterVault.Infrastructure.Options;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Options;
namespace MeterVault.Infrastructure.Dashboard;
@@ -15,11 +17,19 @@ namespace MeterVault.Infrastructure.Dashboard;
/// runtime hours of same-energy-type meters. DbContext
/// factory keeps it Blazor-circuit safe.
///
-public sealed class ConsumableService(IDbContextFactory contextFactory, CostService costService)
+public sealed class ConsumableService(
+ IDbContextFactory contextFactory, CostService costService, IOptions? options = null)
{
private readonly IDbContextFactory _contextFactory = contextFactory;
private readonly CostService _costService = costService;
+ ///
+ /// The instance timezone months and days are bucketed in (SDD §10) — the zone normalization divides
+ /// intervals at, so a share stamped at a month's last second is read back under that month.
+ ///
+ private readonly string _timeZone = (options?.Value ?? new MeterVaultOptions()).TimeZone;
+ private readonly TimeZoneInfo _zone = InstanceTimeZone.Resolve((options?.Value ?? new MeterVaultOptions()).TimeZone);
+
public async Task> GetConsumablesAsync(
DateOnly from, DateOnly to, CancellationToken cancellationToken = default)
{
@@ -44,12 +54,28 @@ public sealed class ConsumableService(IDbContextFactory con
return summaries;
}
+ ///
+ /// Consumable-balance meters that have no tank row. has to skip
+ /// them — capacity and calibration come from the tank — so without this they would simply be
+ /// missing from the panel, with nothing saying why.
+ ///
+ public async Task> GetUnconfiguredAsync(CancellationToken cancellationToken = default)
+ {
+ await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
+
+ return await db.Meters.AsNoTracking()
+ .Where(m => m.Mode == MeterMode.ConsumableBalance && !db.Tanks.Any(t => t.MeterId == m.Id))
+ .OrderBy(m => m.Name)
+ .Select(m => new UnconfiguredConsumable(m.Id, m.Name))
+ .ToListAsync(cancellationToken).ConfigureAwait(false);
+ }
+
private async Task BuildAsync(
MeterVaultDbContext db, Meter meter, Tank tank, DateOnly from, DateOnly to, CancellationToken cancellationToken)
{
var calibration = MeterConfigFactory.FromMeter(meter, tank).Tank?.Calibration;
- var fromUtc = ToUtc(from);
- var toUtc = ToUtc(to);
+ var fromUtc = InstanceTimeZone.StartOf(from, _zone);
+ var toUtc = InstanceTimeZone.StartOf(to, _zone);
var events = await db.MeterEvents.AsNoTracking()
.Where(e => e.MeterId == meter.Id && (e.EventType == MeterEventType.TankLevel || e.EventType == MeterEventType.Delivery))
@@ -103,7 +129,7 @@ public sealed class ConsumableService(IDbContextFactory con
var costInRange = (await _costService.GetMeterCostsAsync(meter.Id, fromUtc, toUtc, CostBucket.Month, cancellationToken).ConfigureAwait(false))
.Sum(c => c.Cost);
- var months = await MonthlyConsumptionAsync(db, meter.Id, fromUtc, toUtc, cancellationToken).ConfigureAwait(false);
+ var months = await MonthlyConsumptionAsync(db, meter.Id, fromUtc, toUtc, _timeZone, cancellationToken).ConfigureAwait(false);
return new ConsumableSummary(
meter.Id, meter.Name, tank.Unit, tank.Capacity, currentLevel, physicalLevel, physicalUnit, levelAsOf,
@@ -152,16 +178,16 @@ public sealed class ConsumableService(IDbContextFactory con
.SumAsync(c => (double?)c.Amount, cancellationToken).ConfigureAwait(false) ?? 0;
private static async Task> MonthlyConsumptionAsync(
- MeterVaultDbContext db, int meterId, DateTimeOffset from, DateTimeOffset to, CancellationToken cancellationToken)
+ MeterVaultDbContext db, int meterId, DateTimeOffset from, DateTimeOffset to, string tz, CancellationToken cancellationToken)
{
const string sql =
- "SELECT (time_bucket(INTERVAL '1 month', \"time\", 'Europe/Berlin') AT TIME ZONE 'Europe/Berlin')::date AS period, " +
+ "SELECT (time_bucket(INTERVAL '1 month', \"time\", @tz) AT TIME ZONE @tz)::date AS period, " +
"sum(amount) AS amount " +
"FROM consumption WHERE meter_id = @meterId AND \"time\" >= @from AND \"time\" < @to " +
"GROUP BY period ORDER BY period";
var connection = db.Database.GetDbConnection();
- var command = new CommandDefinition(sql, new { meterId, from, to }, cancellationToken: cancellationToken);
+ var command = new CommandDefinition(sql, new { meterId, from, to, tz }, cancellationToken: cancellationToken);
var rows = await connection.QueryAsync<(DateOnly Period, double Amount)>(command).ConfigureAwait(false);
return rows.Select(r => new ConsumableMonth(r.Period, r.Amount)).ToList();
}
@@ -173,5 +199,4 @@ public sealed class ConsumableService(IDbContextFactory con
return isCentimetres && calibration is not null ? calibration.ToVolume(value) : value;
}
- private static DateTimeOffset ToUtc(DateOnly date) => new(date.Year, date.Month, date.Day, 0, 0, 0, TimeSpan.Zero);
}
diff --git a/src/Infrastructure/Dashboard/DashboardModels.cs b/src/Infrastructure/Dashboard/DashboardModels.cs
index 32e93df..a711a5a 100644
--- a/src/Infrastructure/Dashboard/DashboardModels.cs
+++ b/src/Infrastructure/Dashboard/DashboardModels.cs
@@ -17,6 +17,35 @@ public sealed record DashboardSummary(DateOnly AsOf, CostKpi Month, CostKpi Year
/// One slice of the cost breakdown / "what costs most" view.
public sealed record CategorySlice(string Name, string? ColorHex, double Cost);
+/// The first thing missing before the dashboard can show a cost, in the order they are set up.
+public enum CostSetupGap
+{
+ None,
+ NoMeters,
+ NoCategories,
+ NoMembers,
+ NoTariffs,
+}
+
+///
+/// What exists of the chain a cost figure needs: a meter, a category that counts it, and a price.
+///
+///
+/// Manual costs stand in for the meter side only on an instance without meters. Once there are meters,
+/// their chain is diagnosed on its own: the diagnosis only runs when this year shows no cost, so any
+/// manual cost there is from another year and explains nothing about the meters.
+///
+public sealed record CostSetup(bool HasMeters, bool HasCategories, bool HasMembers, bool HasTariffs, bool HasManualCosts)
+{
+ public CostSetupGap FirstGap =>
+ !HasMeters && !HasManualCosts ? CostSetupGap.NoMeters
+ : !HasCategories ? CostSetupGap.NoCategories
+ : !HasMeters ? CostSetupGap.None
+ : !HasMembers ? CostSetupGap.NoMembers
+ : !HasTariffs ? CostSetupGap.NoTariffs
+ : CostSetupGap.None;
+}
+
/// A point on a monthly cost/consumption trend.
public sealed record TrendPoint(DateOnly Period, double Cost);
diff --git a/src/Infrastructure/Dashboard/DashboardService.cs b/src/Infrastructure/Dashboard/DashboardService.cs
index 4603956..60966b7 100644
--- a/src/Infrastructure/Dashboard/DashboardService.cs
+++ b/src/Infrastructure/Dashboard/DashboardService.cs
@@ -1,6 +1,8 @@
using MeterVault.Infrastructure.Costing;
+using MeterVault.Infrastructure.Options;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Options;
namespace MeterVault.Infrastructure.Dashboard;
@@ -10,11 +12,18 @@ namespace MeterVault.Infrastructure.Dashboard;
/// only aggregated cost — never the raw hypertable. Uses a DbContext factory (short-lived context
/// per operation) so it is safe from a Blazor circuit.
///
-public sealed class DashboardService(IDbContextFactory contextFactory, CostService costService)
+public sealed class DashboardService(
+ IDbContextFactory contextFactory, CostService costService, IOptions? options = null)
{
private readonly IDbContextFactory _contextFactory = contextFactory;
private readonly CostService _costService = costService;
+ ///
+ /// The instance timezone: periods are local months, so their bounds are local midnights — the same
+ /// months consumption is divided and bucketed in.
+ ///
+ private readonly TimeZoneInfo _zone = InstanceTimeZone.Resolve((options?.Value ?? new MeterVaultOptions()).TimeZone);
+
public async Task GetSummaryAsync(DateOnly asOf, CancellationToken cancellationToken = default)
{
await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
@@ -36,6 +45,24 @@ public sealed class DashboardService(IDbContextFactory cont
return new DashboardSummary(asOf, month, year, latest);
}
+ ///
+ /// Which part of the cost setup exists, so an empty dashboard can name the step that is missing
+ /// instead of listing every admin page. Existence checks only.
+ ///
+ public async Task GetCostSetupAsync(CancellationToken cancellationToken = default)
+ {
+ await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
+ return new CostSetup(
+ HasMeters: await db.Meters.AnyAsync(cancellationToken).ConfigureAwait(false),
+ HasCategories: await db.CostCategories.AnyAsync(cancellationToken).ConfigureAwait(false),
+ // A membership counts only if it reaches a meter: an energy type with no meters costs nothing.
+ HasMembers: await db.CostCategoryMembers
+ .AnyAsync(m => m.MeterId != null || db.Meters.Any(x => x.EnergyTypeId == m.EnergyTypeId), cancellationToken)
+ .ConfigureAwait(false),
+ HasTariffs: await db.Tariffs.AnyAsync(cancellationToken).ConfigureAwait(false),
+ HasManualCosts: await db.ManualCosts.AnyAsync(cancellationToken).ConfigureAwait(false));
+ }
+
public async Task> GetCategoryBreakdownAsync(
DateOnly from, DateOnly to, CancellationToken cancellationToken = default)
{
@@ -124,12 +151,13 @@ public sealed class DashboardService(IDbContextFactory cont
return 0;
}
- var monthStart = new DateOnly(latest.Value.Year, latest.Value.Month, 1);
+ var local = TimeZoneInfo.ConvertTime(latest.Value, _zone);
+ var monthStart = new DateOnly(local.Year, local.Month, 1);
return await TotalCostAsync(db, monthStart, monthStart.AddMonths(1), cancellationToken).ConfigureAwait(false);
}
private static async Task> ActiveMeterIdsAsync(MeterVaultDbContext db, CancellationToken cancellationToken) =>
await db.Meters.AsNoTracking().Select(m => m.Id).ToListAsync(cancellationToken).ConfigureAwait(false);
- private static DateTimeOffset ToUtc(DateOnly date) => new(date.Year, date.Month, date.Day, 0, 0, 0, TimeSpan.Zero);
+ private DateTimeOffset ToUtc(DateOnly date) => InstanceTimeZone.StartOf(date, _zone);
}
diff --git a/src/Infrastructure/Dashboard/FlowService.cs b/src/Infrastructure/Dashboard/FlowService.cs
index 7c395e4..9ee83db 100644
--- a/src/Infrastructure/Dashboard/FlowService.cs
+++ b/src/Infrastructure/Dashboard/FlowService.cs
@@ -1,6 +1,8 @@
using MeterVault.Core.Domain;
+using MeterVault.Infrastructure.Options;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Options;
namespace MeterVault.Infrastructure.Dashboard;
@@ -12,12 +14,16 @@ namespace MeterVault.Infrastructure.Dashboard;
/// hardcoded per energy type — it works for electricity, water, gas, … alike. DbContext factory
/// keeps it Blazor-circuit safe.
///
-public sealed class FlowService(IDbContextFactory contextFactory)
+public sealed class FlowService(
+ IDbContextFactory contextFactory, IOptions? options = null)
{
private const double Epsilon = 0.01;
private readonly IDbContextFactory _contextFactory = contextFactory;
+ /// The instance timezone a requested date range starts and ends in.
+ private readonly TimeZoneInfo _zone = InstanceTimeZone.Resolve((options?.Value ?? new MeterVaultOptions()).TimeZone);
+
public async Task GetFlowAsync(short energyTypeId, DateOnly from, DateOnly to, CancellationToken cancellationToken = default)
{
await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
@@ -30,8 +36,8 @@ public sealed class FlowService(IDbContextFactory contextFa
}
var meterIds = meters.Select(m => m.Id).ToHashSet();
- var fromUtc = ToUtc(from);
- var toUtc = ToUtc(to);
+ var fromUtc = InstanceTimeZone.StartOf(from, _zone);
+ var toUtc = InstanceTimeZone.StartOf(to, _zone);
// A meter's flow value is its throughput: consumption OR generation output — so a generation
// meter (solar) can act as a source feeding downstream meters (grid + solar → house). A meter
@@ -156,5 +162,4 @@ public sealed class FlowService(IDbContextFactory contextFa
private static string NodeId(int meterId) => $"m{meterId}";
- private static DateTimeOffset ToUtc(DateOnly date) => new(date.Year, date.Month, date.Day, 0, 0, 0, TimeSpan.Zero);
}
diff --git a/src/Infrastructure/Dashboard/MeterDetailModels.cs b/src/Infrastructure/Dashboard/MeterDetailModels.cs
index 1506839..7833ff5 100644
--- a/src/Infrastructure/Dashboard/MeterDetailModels.cs
+++ b/src/Infrastructure/Dashboard/MeterDetailModels.cs
@@ -55,8 +55,8 @@ public sealed record MeterPeriodView(
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);
+/// A meter lifecycle/correction event row. marks one only its batch can remove.
+public sealed record EventRow(int Id, DateTimeOffset Time, MeterEventType Type, double? Amount, double? PrevValue, double? NewValue, string? Unit, string? Notes, int? ImportBatchId);
/// A tariff applicable to the meter (own / energy-type / global scope), for the timeline.
public sealed record TariffRow(TariffScope Scope, int? ScopeId, TariffComponent Component, double Value, string Unit, DateOnly ValidFrom, DateOnly? ValidTo);
@@ -91,4 +91,6 @@ public sealed record MeterDetailView(
IReadOnlyList RecentConsumption,
IReadOnlyList Events,
IReadOnlyList Tariffs,
- int SourceCount);
+ int SourceCount,
+ short EnergyTypeId,
+ bool HasTank);
diff --git a/src/Infrastructure/Dashboard/MeterDetailService.cs b/src/Infrastructure/Dashboard/MeterDetailService.cs
index 7d092d6..e279218 100644
--- a/src/Infrastructure/Dashboard/MeterDetailService.cs
+++ b/src/Infrastructure/Dashboard/MeterDetailService.cs
@@ -59,8 +59,8 @@ public sealed class MeterDetailService(IDbContextFactory co
var events = await db.MeterEvents.AsNoTracking()
.Where(e => e.MeterId == meterId)
- .OrderByDescending(e => e.Time)
- .Select(e => new EventRow(e.Time, e.EventType, e.Amount, e.PrevValue, e.NewValue, e.Unit, e.Notes))
+ .OrderByDescending(e => e.Time).ThenByDescending(e => e.Id)
+ .Select(e => new EventRow(e.Id, e.Time, e.EventType, e.Amount, e.PrevValue, e.NewValue, e.Unit, e.Notes, e.ImportBatchId))
.ToListAsync(cancellationToken).ConfigureAwait(false);
var energyTypeId = meter.EnergyTypeId;
@@ -72,12 +72,14 @@ public sealed class MeterDetailService(IDbContextFactory co
.Select(t => new TariffRow(t.ScopeType, t.ScopeId, t.Component, t.Value, t.Unit, t.ValidFrom, t.ValidTo))
.ToListAsync(cancellationToken).ConfigureAwait(false);
+ var hasTank = await db.Tanks.AsNoTracking().AnyAsync(t => t.MeterId == meterId, cancellationToken).ConfigureAwait(false);
+
return new MeterDetailView(
meter.Id, meter.Name, meter.EnergyType?.DisplayName ?? "—", meter.Mode, meter.Unit,
meter.Location, meter.SerialNumber, meter.Manufacturer, meter.Model, meter.InitialBaseline, meter.IsActive,
readingCount, consumptionCount,
first?.Time, last?.Time, first?.Value, last?.Value,
totalConsumption, totalGeneration,
- recentReadings, recentConsumption, events, tariffs, meter.Sources.Count);
+ recentReadings, recentConsumption, events, tariffs, meter.Sources.Count, meter.EnergyTypeId, hasTank);
}
}
diff --git a/src/Infrastructure/Dashboard/SolarService.cs b/src/Infrastructure/Dashboard/SolarService.cs
index 6a284c3..bfefd52 100644
--- a/src/Infrastructure/Dashboard/SolarService.cs
+++ b/src/Infrastructure/Dashboard/SolarService.cs
@@ -1,8 +1,10 @@
using Dapper;
using MeterVault.Core.Costing;
using MeterVault.Core.Domain;
+using MeterVault.Infrastructure.Options;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Options;
namespace MeterVault.Infrastructure.Dashboard;
@@ -11,12 +13,20 @@ namespace MeterVault.Infrastructure.Dashboard;
/// meter; self-consumption / autarky / savings are derived
/// from the meters tagged and
/// — so nothing is hardcoded by meter name. Reads only the aggregated consumption hypertable
-/// (monthly, Europe/Berlin) via Dapper; safe from a Blazor circuit via a DbContext factory.
+/// (monthly, in the instance timezone) via Dapper; safe from a Blazor circuit via a DbContext factory.
///
-public sealed class SolarService(IDbContextFactory contextFactory)
+public sealed class SolarService(
+ IDbContextFactory contextFactory, IOptions? options = null)
{
private readonly IDbContextFactory _contextFactory = contextFactory;
+ ///
+ /// The instance timezone months and days are bucketed in (SDD §10) — the zone normalization divides
+ /// intervals at, so a share stamped at a month's last second is read back under that month.
+ ///
+ private readonly string _timeZone = (options?.Value ?? new MeterVaultOptions()).TimeZone;
+ private readonly TimeZoneInfo _zone = InstanceTimeZone.Resolve((options?.Value ?? new MeterVaultOptions()).TimeZone);
+
public async Task GetSummaryAsync(DateOnly from, DateOnly to, CancellationToken cancellationToken = default)
{
await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
@@ -26,22 +36,22 @@ public sealed class SolarService(IDbContextFactory contextF
var loadMeter = meters.FirstOrDefault(m => MeterMeta.Role(m.Meta) == MeterRoles.TotalLoad);
var gridMeter = meters.FirstOrDefault(m => MeterMeta.Role(m.Meta) == MeterRoles.GridImport);
- var fromUtc = ToUtc(from);
- var toUtc = ToUtc(to);
+ var fromUtc = InstanceTimeZone.StartOf(from, _zone);
+ var toUtc = InstanceTimeZone.StartOf(to, _zone);
// Monthly generation per generation meter.
var genByMeter = new Dictionary>();
foreach (var meter in generationMeters)
{
- genByMeter[meter.Id] = await MonthlyAsync(db, meter.Id, fromUtc, toUtc, cancellationToken).ConfigureAwait(false);
+ genByMeter[meter.Id] = await MonthlyAsync(db, meter.Id, fromUtc, toUtc, _timeZone, cancellationToken).ConfigureAwait(false);
}
var loadByMonth = loadMeter is null
? null
- : await MonthlyAsync(db, loadMeter.Id, fromUtc, toUtc, cancellationToken).ConfigureAwait(false);
+ : await MonthlyAsync(db, loadMeter.Id, fromUtc, toUtc, _timeZone, cancellationToken).ConfigureAwait(false);
var gridByMonth = gridMeter is null
? null
- : await MonthlyAsync(db, gridMeter.Id, fromUtc, toUtc, cancellationToken).ConfigureAwait(false);
+ : await MonthlyAsync(db, gridMeter.Id, fromUtc, toUtc, _timeZone, cancellationToken).ConfigureAwait(false);
var tariffs = gridMeter is null
? []
@@ -98,19 +108,18 @@ public sealed class SolarService(IDbContextFactory contextF
}
private static async Task> MonthlyAsync(
- MeterVaultDbContext db, int meterId, DateTimeOffset from, DateTimeOffset to, CancellationToken cancellationToken)
+ MeterVaultDbContext db, int meterId, DateTimeOffset from, DateTimeOffset to, string tz, CancellationToken cancellationToken)
{
const string sql =
- "SELECT (time_bucket(INTERVAL '1 month', \"time\", 'Europe/Berlin') AT TIME ZONE 'Europe/Berlin')::date AS period, " +
+ "SELECT (time_bucket(INTERVAL '1 month', \"time\", @tz) AT TIME ZONE @tz)::date AS period, " +
"sum(amount) AS amount " +
"FROM consumption WHERE meter_id = @meterId AND \"time\" >= @from AND \"time\" < @to " +
"GROUP BY period";
var connection = db.Database.GetDbConnection();
- var command = new CommandDefinition(sql, new { meterId, from, to }, cancellationToken: cancellationToken);
+ var command = new CommandDefinition(sql, new { meterId, from, to, tz }, cancellationToken: cancellationToken);
var rows = await connection.QueryAsync<(DateOnly Period, double Amount)>(command).ConfigureAwait(false);
return rows.ToDictionary(r => r.Period, r => r.Amount);
}
- private static DateTimeOffset ToUtc(DateOnly date) => new(date.Year, date.Month, date.Day, 0, 0, 0, TimeSpan.Zero);
}
diff --git a/src/Infrastructure/DependencyInjection.cs b/src/Infrastructure/DependencyInjection.cs
index 145d6a2..f97fad6 100644
--- a/src/Infrastructure/DependencyInjection.cs
+++ b/src/Infrastructure/DependencyInjection.cs
@@ -28,10 +28,12 @@ public static class DependencyInjection
services.AddSingleton(_ => NormalizationEngine.CreateDefault());
services.AddScoped();
+ services.AddScoped();
services.AddScoped();
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
services.AddScoped();
// HttpClient + HA tester are available even with live ingestion off, so the admin
// "Test connection" works without the background workers running.
diff --git a/src/Infrastructure/Import/CsvImporter.cs b/src/Infrastructure/Import/CsvImporter.cs
index 093503a..319df33 100644
--- a/src/Infrastructure/Import/CsvImporter.cs
+++ b/src/Infrastructure/Import/CsvImporter.cs
@@ -37,7 +37,7 @@ public sealed class CsvImporter
continue;
}
- if (!TryGetDate(row, profile, out var time))
+ if (!TryGetDate(row, profile, out var time, out var monthLabel))
{
staged.SkippedRows++;
staged.Warnings.Add(ImportWarnings.UnparseableDate(r + 1));
@@ -46,14 +46,14 @@ public sealed class CsvImporter
foreach (var column in profile.Columns)
{
- StageColumn(column, row, time, r, staged, previousByMeter, profile.DetectCumulativeSwaps);
+ StageColumn(column, row, time, monthLabel, r, staged, previousByMeter, profile.DetectCumulativeSwaps);
}
}
return staged;
}
- private static void StageColumn(ColumnMapping column, IReadOnlyList row, DateTimeOffset time,
+ private static void StageColumn(ColumnMapping column, IReadOnlyList row, DateTimeOffset time, bool monthLabel,
int rowIndex, StagedImport staged, Dictionary previousByMeter, bool detectSwaps)
{
if (column.Role == MappingRole.Ignore || column.Index >= row.Count)
@@ -70,7 +70,7 @@ public sealed class CsvImporter
switch (column.Role)
{
case MappingRole.Reading:
- StageReading(column, row, cell, time, rowIndex, staged, previousByMeter, detectSwaps);
+ StageReading(column, row, cell, time, monthLabel, rowIndex, staged, previousByMeter, detectSwaps);
break;
case MappingRole.Delivery:
@@ -126,7 +126,7 @@ public sealed class CsvImporter
}
private static void StageReading(ColumnMapping column, IReadOnlyList row, string cell,
- DateTimeOffset time, int rowIndex, StagedImport staged, Dictionary previousByMeter, bool detectSwaps)
+ DateTimeOffset time, bool monthLabel, int rowIndex, StagedImport staged, Dictionary previousByMeter, bool detectSwaps)
{
var split = ValueCell.Split(cell);
if (split is null)
@@ -167,6 +167,7 @@ public sealed class CsvImporter
Time = time,
Value = value,
Quality = ReadingQuality.Imported,
+ Flags = monthLabel ? ReadingFlags.MonthLabel : ReadingFlags.None,
});
previousByMeter[meterId] = value;
}
@@ -181,9 +182,16 @@ public sealed class CsvImporter
return GermanNumber.TryParse(row[source], out var value) ? value : null;
}
- private static bool TryGetDate(IReadOnlyList row, MappingProfile profile, out DateTimeOffset time)
+ ///
+ /// Whether the cell named a month ("Mai 2026") stamped on its 1st, rather than a day. Normalization
+ /// reads such a row as the register at the end of that month ();
+ /// a day-dated "01.08.2026" at the same midnight is an instant. Months anchored to their last day
+ /// are stamped where they describe, so they are not labels.
+ ///
+ private static bool TryGetDate(IReadOnlyList row, MappingProfile profile, out DateTimeOffset time, out bool monthLabel)
{
time = default;
+ monthLabel = false;
if (profile.DateColumn >= row.Count)
{
return false;
@@ -191,12 +199,21 @@ public sealed class CsvImporter
var raw = row[profile.DateColumn];
DateOnly date;
- var parsed = profile.DateKind switch
+ bool parsed;
+ switch (profile.DateKind)
{
- DateKind.MonthName => TryParseMonthAnchored(raw, profile.AnchorMonthsToEnd, out date),
- DateKind.DayDotMonthYear => GermanDate.TryParseDay(raw, out date),
- _ => ImportDate.TryResolve(raw, profile.AnchorMonthsToEnd, out date),
- };
+ case DateKind.MonthName:
+ parsed = TryParseMonthAnchored(raw, profile.AnchorMonthsToEnd, out date);
+ monthLabel = parsed && !profile.AnchorMonthsToEnd;
+ break;
+ case DateKind.DayDotMonthYear:
+ parsed = GermanDate.TryParseDay(raw, out date);
+ break;
+ default:
+ parsed = ImportDate.TryResolve(raw, profile.AnchorMonthsToEnd, out date);
+ monthLabel = parsed && !profile.AnchorMonthsToEnd && !GermanDate.TryParseDay(raw, out _);
+ break;
+ }
if (!parsed)
{
diff --git a/src/Infrastructure/Ingestion/IngestionService.cs b/src/Infrastructure/Ingestion/IngestionService.cs
index dc73e81..9063ad5 100644
--- a/src/Infrastructure/Ingestion/IngestionService.cs
+++ b/src/Infrastructure/Ingestion/IngestionService.cs
@@ -56,7 +56,7 @@ public sealed class IngestionService(
return IngestionOutcome.RejectedDecrease;
}
- var outcome = await UpsertAsync(meter, utc, value, source.Id, quality: null, cancellationToken).ConfigureAwait(false);
+ var outcome = await UpsertAsync(meter, utc, value, source.Id, quality: null, ReadingFlags.None, cancellationToken).ConfigureAwait(false);
await UpdateSourceStatusAsync(source, utc, value, "ok", cancellationToken).ConfigureAwait(false);
await RenormalizeAsync(meter.Id, outcome, cancellationToken).ConfigureAwait(false);
return outcome;
@@ -73,9 +73,14 @@ public sealed class IngestionService(
/// row's quality alone — a source re-reporting a timestamp must not silently relabel a reading
/// somebody entered by hand or that came from an import.
///
+ ///
+ /// Annotations to add to the row, e.g. on the new register's
+ /// start value written together with a swap. Added to an existing row's flags, never cleared.
+ ///
public async Task IngestByMeterAsync(
int meterId, DateTimeOffset time, double value, bool renormalize = true,
- ReadingQuality? quality = null, CancellationToken cancellationToken = default)
+ ReadingQuality? quality = null, ReadingFlags flags = ReadingFlags.None,
+ CancellationToken cancellationToken = default)
{
var meter = await _db.Meters
.FirstOrDefaultAsync(m => m.Id == meterId, cancellationToken).ConfigureAwait(false);
@@ -91,7 +96,7 @@ public sealed class IngestionService(
return IngestionOutcome.RejectedDecrease;
}
- var outcome = await UpsertAsync(meter, utc, value, sourceId: null, quality, cancellationToken).ConfigureAwait(false);
+ var outcome = await UpsertAsync(meter, utc, value, sourceId: null, quality, flags, cancellationToken).ConfigureAwait(false);
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
if (renormalize)
{
@@ -167,7 +172,7 @@ public sealed class IngestionService(
private async Task UpsertAsync(
Meter meter, DateTimeOffset utc, double value, int? sourceId, ReadingQuality? quality,
- CancellationToken cancellationToken)
+ ReadingFlags flags, CancellationToken cancellationToken)
{
var existing = _db.Readings.Local.FirstOrDefault(r => r.MeterId == meter.Id && r.Time == utc)
?? await _db.Readings.FirstOrDefaultAsync(r => r.MeterId == meter.Id && r.Time == utc, cancellationToken).ConfigureAwait(false);
@@ -181,43 +186,49 @@ public sealed class IngestionService(
Value = value,
SourceId = sourceId,
Quality = quality ?? ReadingQuality.Measured,
+ Flags = flags,
});
return IngestionOutcome.Written;
}
existing.Value = value;
existing.SourceId = sourceId ?? existing.SourceId;
+ existing.Flags |= flags;
if (quality is { } stamp)
{
existing.Quality = stamp;
}
+ // An imported month row stamped at this instant describes the end of its month. Correcting it by
+ // hand or by re-import keeps that meaning; a live value, or the start reading of a swap, is what
+ // the register showed at this very instant, so the row stops being a month row.
+ if (existing.Flags.HasFlag(ReadingFlags.MonthLabel)
+ && (quality is not (ReadingQuality.Manual or ReadingQuality.Imported)
+ || (flags & (ReadingFlags.MeterSwap | ReadingFlags.CounterReset)) != 0))
+ {
+ existing.Flags &= ~ReadingFlags.MonthLabel;
+ existing.Quality = quality ?? ReadingQuality.Measured;
+ }
+
return IngestionOutcome.Updated;
}
private async Task IsSpuriousDecreaseAsync(
int meterId, DateTimeOffset time, double value, CancellationToken cancellationToken)
{
- var previous = await _db.Readings
- .Where(r => r.MeterId == meterId && r.Time < time)
- .OrderByDescending(r => r.Time)
- .Select(r => new { r.Value, r.Time })
- .FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
+ // The previous reading on the normalizer's timeline, not by stamp: a month row stamped on the 1st
+ // describes the end of its month and must not reject a lower reading taken during that month.
+ var neighbours = await RegisterNeighbours.FindAsync(_db, meterId, time, _normalization.TimeZone, cancellationToken)
+ .ConfigureAwait(false);
- if (previous is null || value >= previous.Value)
+ if (neighbours.Previous is not { } previous || value >= previous.Reading.Value)
{
return false;
}
// Only a reset/swap in the window (previousReading, thisReading] explains the decrease —
// an old historical reset must not permanently disable the guard.
- var explained = await _db.MeterEvents.AnyAsync(
- e => e.MeterId == meterId
- && (e.EventType == MeterEventType.CounterReset || e.EventType == MeterEventType.MeterSwap)
- && e.Time > previous.Time && e.Time <= time,
- cancellationToken).ConfigureAwait(false);
-
- return !explained;
+ return !neighbours.BoundaryAfterPreviousUpTo(time);
}
private async Task UpdateSourceStatusAsync(
diff --git a/src/Infrastructure/Ingestion/MeterEventService.cs b/src/Infrastructure/Ingestion/MeterEventService.cs
new file mode 100644
index 0000000..fbc3ed4
--- /dev/null
+++ b/src/Infrastructure/Ingestion/MeterEventService.cs
@@ -0,0 +1,491 @@
+using MeterVault.Core.Domain;
+using MeterVault.Core.Normalization;
+using MeterVault.Infrastructure.Normalization;
+using MeterVault.Infrastructure.Persistence;
+using Microsoft.EntityFrameworkCore;
+
+namespace MeterVault.Infrastructure.Ingestion;
+
+/// What the user entered for a meter event, before it is checked and stored.
+public sealed record MeterEventDraft(MeterEventType Type, DateTimeOffset Time)
+{
+ /// Delivered volume, or the tank level in .
+ public double? Amount { get; init; }
+
+ /// Swap/reset: the old register's final value. Null books no tail.
+ public double? PrevValue { get; init; }
+
+ /// Swap/reset: the new register's start value. Null means 0.
+ public double? NewValue { get; init; }
+
+ /// Tank level only: cm for a dipstick reading, otherwise the tank's volume unit.
+ public string? Unit { get; init; }
+
+ public string? Notes { get; init; }
+}
+
+/// Why an event could not be recorded or removed. means it was.
+public enum MeterEventProblem
+{
+ None,
+ UnknownMeter,
+ NotRecordableForMode,
+ AmountRequired,
+ AmountOutOfRange,
+ NoteRequired,
+ LevelNeedsCalibration,
+ LevelAtSameTime,
+ OldRegisterBelowPreviousReading,
+ ReadingAtSameTime,
+ BoundaryAlreadyRecorded,
+ StartReadingRejected,
+ NotFound,
+ Imported,
+ NotManual,
+ LaterBoundaryDependsOnIt,
+}
+
+/// A reading's instant and value.
+public sealed record ReadingPoint(DateTimeOffset Time, double Value);
+
+/// A recorded tank level, with its volume after calibration.
+public sealed record TankLevelPoint(DateTimeOffset Time, double Amount, string? Unit, double Volume);
+
+///
+/// Everything around a candidate event time that decides whether the event is valid and what it will
+/// book — read once so the dialog can explain the outcome before saving, and the save can re-check it.
+///
+public sealed record MeterEventContext(
+ int MeterId,
+ MeterMode Mode,
+ string Unit,
+ double InitialBaseline,
+ ReadingPoint? Previous,
+ bool ReadingAtTime,
+ ReadingPoint? Next,
+ int ReadingsAfter,
+ bool BoundaryInWindow,
+ int LiveSources,
+ string TankUnit,
+ CalibrationCurve? Calibration,
+ TankLevelPoint? LastLevel,
+ double DeliveredSinceLastLevel)
+{
+ ///
+ /// The register value the old register's tail is measured from: the last reading before the
+ /// event, or — with none — the meter's initial baseline, exactly where the normalizer starts.
+ ///
+ public double RegisterBefore => Previous?.Value ?? InitialBaseline;
+
+ /// How much of the old register a swap/reset books: its final value less .
+ public double? Tail(double? finalValue) => finalValue is { } final ? final - RegisterBefore : null;
+
+ public double ToVolume(double amount, bool centimetres) =>
+ centimetres && Calibration is { } curve ? curve.ToVolume(amount) : amount;
+
+ /// Draw since the last level: that level plus deliveries since, less the new volume.
+ public double? UsedSinceLastLevel(double volume) =>
+ LastLevel is { } last ? last.Volume + DeliveredSinceLastLevel - volume : null;
+}
+
+/// An event recording outcome.
+public sealed record MeterEventResult(MeterEventProblem Problem, int? EventId = null)
+{
+ public bool Succeeded => Problem == MeterEventProblem.None;
+}
+
+///
+/// Records and removes meter lifecycle events from the UI — swaps, resets, tank levels, deliveries and
+/// notes — and the manual corrections that go with them, always recomputing the meter in the same
+/// transaction so no derived number is ever left describing the old history.
+///
+///
+///
+/// A swap or reset is stored the way the golden water fixture expresses one: the event at instant T
+/// carrying the old register's final value and the new register's start value, plus a manual reading
+/// of that start value at exactly T. The normalizer's boundary window is (previousReading, reading],
+/// so the reading at T books the old register's tail there and every later reading counts from the
+/// new register's start. It also means the meter's latest reading is the new register, so the
+/// next manual entry is prefilled and checked against the right number.
+///
+///
+/// The obvious alternative — writing the old final value as the reading at T — books the old register
+/// twice and then rejects every new-register reading, which is why the service, not the page, owns this.
+///
+///
+public sealed class MeterEventService(
+ MeterVaultDbContext db, IngestionService ingestion, NormalizationService normalization)
+{
+ /// Enough to warn about a backdated event; counting further is just a slower query.
+ public const int ReadingsAfterCap = 1000;
+
+ private const double Tolerance = 1e-9;
+
+ private readonly MeterVaultDbContext _db = db;
+ private readonly IngestionService _ingestion = ingestion;
+ private readonly NormalizationService _normalization = normalization;
+
+ /// Reads the surroundings of for a meter, or null if the meter is gone.
+ public async Task GetContextAsync(
+ int meterId, DateTimeOffset time, CancellationToken cancellationToken = default)
+ {
+ var meter = await _db.Meters.AsNoTracking()
+ .FirstOrDefaultAsync(m => m.Id == meterId, cancellationToken).ConfigureAwait(false);
+ if (meter is null)
+ {
+ return null;
+ }
+
+ var utc = time.ToUniversalTime();
+ var readings = _db.Readings.AsNoTracking().Where(r => r.MeterId == meterId);
+
+ // Neighbours as the normalizer orders them, so the tail the dialog shows is the tail that is booked
+ // — a month row stamped on the 1st describes the end of its month.
+ var neighbours = await RegisterNeighbours.FindAsync(_db, meterId, utc, _normalization.TimeZone, cancellationToken)
+ .ConfigureAwait(false);
+ var previous = neighbours.Previous is { } p ? new ReadingPoint(p.Reading.Time, p.Reading.Value) : null;
+ var next = neighbours.Next is { } n ? new ReadingPoint(n.Reading.Time, n.Reading.Value) : null;
+ var readingAtTime = await readings.AnyAsync(r => r.Time == utc, cancellationToken).ConfigureAwait(false);
+ var readingsAfter = next is null
+ ? 0
+ : await readings.Where(r => r.Time > utc).Take(ReadingsAfterCap).CountAsync(cancellationToken).ConfigureAwait(false);
+
+ // A second swap/reset between the same two readings would never be applied: the normalizer
+ // takes the first boundary in an interval. Scoped to the surrounding readings, so a genuine
+ // earlier swap — which has its own start reading after it — does not block a later one.
+ var boundaryInWindow = neighbours.BoundaryAfterPreviousUpTo(neighbours.Next?.Effective);
+
+ var liveSources = await _db.MeterSources.AsNoTracking()
+ .CountAsync(s => s.MeterId == meterId && s.IsEnabled
+ && (s.SourceType == SourceType.HomeAssistant || s.SourceType == SourceType.Mqtt || s.SourceType == SourceType.Tasmota),
+ cancellationToken).ConfigureAwait(false);
+
+ var tank = await _db.Tanks.AsNoTracking()
+ .FirstOrDefaultAsync(t => t.MeterId == meterId, cancellationToken).ConfigureAwait(false);
+ var calibration = MeterConfigFactory.ParseCalibration(tank?.Calibration);
+
+ TankLevelPoint? lastLevel = null;
+ double delivered = 0;
+ if (meter.Mode == MeterMode.ConsumableBalance)
+ {
+ var level = await _db.MeterEvents.AsNoTracking()
+ .Where(e => e.MeterId == meterId && e.EventType == MeterEventType.TankLevel && e.Time <= utc)
+ .OrderByDescending(e => e.Time)
+ .FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
+ if (level is not null)
+ {
+ var centimetres = string.Equals(level.Unit, "cm", StringComparison.OrdinalIgnoreCase);
+ var amount = level.Amount ?? 0;
+ lastLevel = new TankLevelPoint(level.Time, amount, level.Unit,
+ centimetres && calibration is not null ? calibration.ToVolume(amount) : amount);
+ var since = level.Time;
+ delivered = await _db.MeterEvents.AsNoTracking()
+ .Where(e => e.MeterId == meterId && e.EventType == MeterEventType.Delivery && e.Time > since && e.Time <= utc)
+ .SumAsync(e => e.Amount ?? 0, cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ return new MeterEventContext(
+ meter.Id, meter.Mode, meter.Unit, meter.InitialBaseline, previous, readingAtTime, next, readingsAfter, boundaryInWindow,
+ liveSources, tank?.Unit ?? meter.Unit, calibration, lastLevel, delivered);
+ }
+
+ ///
+ /// Checks a draft against its context without touching the database — the same verdict the save
+ /// reaches, so the dialog can show it while the user is still typing.
+ ///
+ public static MeterEventProblem Validate(MeterEventContext context, MeterEventDraft draft)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+ ArgumentNullException.ThrowIfNull(draft);
+
+ if (!MeterEventRules.CanRecord(context.Mode, draft.Type))
+ {
+ return MeterEventProblem.NotRecordableForMode;
+ }
+
+ switch (draft.Type)
+ {
+ case MeterEventType.MeterSwap:
+ case MeterEventType.CounterReset:
+ if (!IsFinite(draft.PrevValue) || !IsFinite(draft.NewValue))
+ {
+ return MeterEventProblem.AmountOutOfRange;
+ }
+
+ if (context.ReadingAtTime)
+ {
+ return MeterEventProblem.ReadingAtSameTime;
+ }
+
+ if (context.BoundaryInWindow)
+ {
+ return MeterEventProblem.BoundaryAlreadyRecorded;
+ }
+
+ // Below the last reading the old register would book negative consumption — that is a
+ // typo in one of the two numbers, never a real meter.
+ return context.Tail(draft.PrevValue) is < -Tolerance
+ ? MeterEventProblem.OldRegisterBelowPreviousReading
+ : MeterEventProblem.None;
+
+ case MeterEventType.Delivery:
+ return draft.Amount switch
+ {
+ null => MeterEventProblem.AmountRequired,
+ { } amount when !double.IsFinite(amount) || amount <= 0 => MeterEventProblem.AmountOutOfRange,
+ _ => MeterEventProblem.None,
+ };
+
+ case MeterEventType.TankLevel:
+ if (draft.Amount is not { } level)
+ {
+ return MeterEventProblem.AmountRequired;
+ }
+
+ if (!double.IsFinite(level) || level < 0)
+ {
+ return MeterEventProblem.AmountOutOfRange;
+ }
+
+ // Two levels at one instant would each yield a consumption row for the same
+ // (meter, time) — one of them is a mistake, and the recompute cannot store both.
+ if (context.LastLevel is { } last && last.Time == draft.Time.ToUniversalTime())
+ {
+ return MeterEventProblem.LevelAtSameTime;
+ }
+
+ // Without a calibration a centimetre level would be read as litres.
+ return IsCentimetres(draft.Unit) && context.Calibration is null
+ ? MeterEventProblem.LevelNeedsCalibration
+ : MeterEventProblem.None;
+
+ case MeterEventType.Note:
+ return string.IsNullOrWhiteSpace(draft.Notes) ? MeterEventProblem.NoteRequired : MeterEventProblem.None;
+
+ default:
+ return MeterEventProblem.NotRecordableForMode;
+ }
+ }
+
+ /// Records an event (and, for a swap/reset, the new register's start reading) and recomputes the meter.
+ public async Task RecordAsync(
+ int meterId, MeterEventDraft draft, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(draft);
+
+ var utc = draft.Time.ToUniversalTime();
+ var context = await GetContextAsync(meterId, utc, cancellationToken).ConfigureAwait(false);
+ if (context is null)
+ {
+ return new MeterEventResult(MeterEventProblem.UnknownMeter);
+ }
+
+ var problem = Validate(context, draft);
+ if (problem != MeterEventProblem.None)
+ {
+ return new MeterEventResult(problem);
+ }
+
+ var boundary = MeterEventRules.IsRegisterBoundary(draft.Type);
+ var meterEvent = new MeterEvent
+ {
+ MeterId = meterId,
+ Time = utc,
+ EventType = draft.Type,
+ Amount = draft.Type is MeterEventType.Delivery or MeterEventType.TankLevel ? draft.Amount : null,
+ PrevValue = boundary ? draft.PrevValue : null,
+ NewValue = boundary ? draft.NewValue ?? 0 : null,
+ Unit = draft.Type switch
+ {
+ MeterEventType.TankLevel when IsCentimetres(draft.Unit) => "cm",
+ MeterEventType.TankLevel or MeterEventType.Delivery => context.TankUnit,
+ MeterEventType.MeterSwap or MeterEventType.CounterReset => context.Unit,
+ _ => null,
+ },
+ Notes = string.IsNullOrWhiteSpace(draft.Notes) ? null : draft.Notes.Trim(),
+ };
+
+ await using var tx = await BeginOwnTransactionAsync(cancellationToken).ConfigureAwait(false);
+ _db.MeterEvents.Add(meterEvent);
+ // Saved before the start reading: the ingestion guard finds the swap in the database, not in
+ // the change tracker, and without it the new register's lower value is a spurious decrease.
+ await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+
+ if (boundary)
+ {
+ var flag = draft.Type == MeterEventType.MeterSwap ? ReadingFlags.MeterSwap : ReadingFlags.CounterReset;
+ var outcome = await _ingestion.IngestByMeterAsync(
+ meterId, utc, meterEvent.NewValue ?? 0, renormalize: false,
+ quality: ReadingQuality.Manual, flags: flag, cancellationToken: cancellationToken).ConfigureAwait(false);
+ if (outcome != IngestionOutcome.Written)
+ {
+ // Checked just above, so only a reading that arrived in between gets here. Leave
+ // nothing half-recorded: without its start reading the swap would book at whatever
+ // reading comes next.
+ _db.ChangeTracker.Clear();
+ return new MeterEventResult(MeterEventProblem.StartReadingRejected);
+ }
+ }
+
+ if (draft.Type != MeterEventType.Note)
+ {
+ await _normalization.RecomputeMeterAsync(meterId, batchId: null, cancellationToken).ConfigureAwait(false);
+ await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ if (tx is not null)
+ {
+ await tx.CommitAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ return new MeterEventResult(MeterEventProblem.None, meterEvent.Id);
+ }
+
+ ///
+ /// Removes a hand-recorded event and recomputes the meter. A swap/reset also takes its start
+ /// reading with it — but only while that reading is still the untouched value the swap wrote, so a
+ /// real reading later entered at the same instant survives.
+ ///
+ /// Imported events belong to their batch and are removed by reverting it.
+ public async Task DeleteEventAsync(
+ int meterId, int eventId, CancellationToken cancellationToken = default)
+ {
+ var meterEvent = await _db.MeterEvents
+ .FirstOrDefaultAsync(e => e.Id == eventId && e.MeterId == meterId, cancellationToken).ConfigureAwait(false);
+ if (meterEvent is null)
+ {
+ return new MeterEventResult(MeterEventProblem.NotFound);
+ }
+
+ if (meterEvent.ImportBatchId is not null)
+ {
+ return new MeterEventResult(MeterEventProblem.Imported, eventId);
+ }
+
+ Reading? startReading = null;
+ if (MeterEventRules.IsRegisterBoundary(meterEvent.EventType))
+ {
+ var flag = meterEvent.EventType == MeterEventType.MeterSwap ? ReadingFlags.MeterSwap : ReadingFlags.CounterReset;
+ var time = meterEvent.Time;
+ var candidate = await _db.Readings
+ .FirstOrDefaultAsync(r => r.MeterId == meterId && r.Time == time, cancellationToken).ConfigureAwait(false);
+ if (candidate is { Quality: ReadingQuality.Manual }
+ && candidate.Flags.HasFlag(flag)
+ && Math.Abs(candidate.Value - (meterEvent.NewValue ?? 0)) < Tolerance)
+ {
+ startReading = candidate;
+ }
+ }
+
+ // Checked before anything changes, so a refusal leaves even a caller-owned transaction untouched.
+ if (startReading is not null
+ && await BoundaryDependsOnReadingAtAsync(meterId, startReading.Time, cancellationToken).ConfigureAwait(false))
+ {
+ return new MeterEventResult(MeterEventProblem.LaterBoundaryDependsOnIt, eventId);
+ }
+
+ await using var tx = await BeginOwnTransactionAsync(cancellationToken).ConfigureAwait(false);
+ if (startReading is not null)
+ {
+ _db.Readings.Remove(startReading);
+ }
+
+ _db.MeterEvents.Remove(meterEvent);
+ await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+
+ if (meterEvent.EventType != MeterEventType.Note)
+ {
+ await _normalization.RecomputeMeterAsync(meterId, batchId: null, cancellationToken).ConfigureAwait(false);
+ await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ if (tx is not null)
+ {
+ await tx.CommitAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ return new MeterEventResult(MeterEventProblem.None, eventId);
+ }
+
+ ///
+ /// Deletes a reading somebody typed by hand, then recomputes the meter. The escape hatch for a
+ /// mistyped register, which otherwise blocks every later reading as a decrease.
+ ///
+ ///
+ /// Only rows: a measured reading is the audit truth a source
+ /// reported, and an imported one is removed by reverting its batch.
+ ///
+ public async Task DeleteManualReadingAsync(
+ int meterId, DateTimeOffset time, CancellationToken cancellationToken = default)
+ {
+ var utc = time.ToUniversalTime();
+ var reading = await _db.Readings
+ .FirstOrDefaultAsync(r => r.MeterId == meterId && r.Time == utc, cancellationToken).ConfigureAwait(false);
+ if (reading is null)
+ {
+ return new MeterEventResult(MeterEventProblem.NotFound);
+ }
+
+ if (reading.Quality != ReadingQuality.Manual)
+ {
+ return new MeterEventResult(MeterEventProblem.NotManual);
+ }
+
+ if (await BoundaryDependsOnReadingAtAsync(meterId, utc, cancellationToken).ConfigureAwait(false))
+ {
+ return new MeterEventResult(MeterEventProblem.LaterBoundaryDependsOnIt);
+ }
+
+ await using var tx = await BeginOwnTransactionAsync(cancellationToken).ConfigureAwait(false);
+ _db.Readings.Remove(reading);
+ await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ await _normalization.RecomputeMeterAsync(meterId, batchId: null, cancellationToken).ConfigureAwait(false);
+ await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+
+ if (tx is not null)
+ {
+ await tx.CommitAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ return new MeterEventResult(MeterEventProblem.None);
+ }
+
+ ///
+ /// Whether a swap/reset lies in the interval that begins at the reading at ,
+ /// i.e. in (readingTime, next reading]. That boundary's old-register tail was measured against
+ /// this reading; removing it would re-measure the tail against an older one — a negative amount if the
+ /// register had moved on — or merge two boundaries into one interval, where only the first applies.
+ /// The later swap/reset has to go first.
+ ///
+ private async Task BoundaryDependsOnReadingAtAsync(int meterId, DateTimeOffset readingTime, CancellationToken cancellationToken)
+ {
+ var nextReading = await _db.Readings.AsNoTracking()
+ .Where(r => r.MeterId == meterId && r.Time > readingTime)
+ .OrderBy(r => r.Time)
+ .Select(r => (DateTimeOffset?)r.Time)
+ .FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
+
+ return await _db.MeterEvents.AsNoTracking()
+ .Where(e => e.MeterId == meterId
+ && (e.EventType == MeterEventType.MeterSwap || e.EventType == MeterEventType.CounterReset)
+ && e.Time > readingTime)
+ .Where(e => nextReading == null || e.Time <= nextReading)
+ .AnyAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ /// Opens a transaction unless the caller already holds one. Disposing it uncommitted rolls back,
+ /// which is what every early return above relies on; a caller-owned transaction is the caller's
+ /// to commit or roll back.
+ ///
+ private async Task BeginOwnTransactionAsync(
+ CancellationToken cancellationToken) =>
+ _db.Database.CurrentTransaction is null
+ ? await _db.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false)
+ : null;
+
+ private static bool IsCentimetres(string? unit) => string.Equals(unit?.Trim(), "cm", StringComparison.OrdinalIgnoreCase);
+
+ private static bool IsFinite(double? value) => value is null || double.IsFinite(value.Value);
+}
diff --git a/src/Infrastructure/Ingestion/RegisterNeighbours.cs b/src/Infrastructure/Ingestion/RegisterNeighbours.cs
new file mode 100644
index 0000000..41d2719
--- /dev/null
+++ b/src/Infrastructure/Ingestion/RegisterNeighbours.cs
@@ -0,0 +1,83 @@
+using MeterVault.Core.Domain;
+using MeterVault.Core.Normalization;
+using MeterVault.Infrastructure.Persistence;
+using Microsoft.EntityFrameworkCore;
+
+namespace MeterVault.Infrastructure.Ingestion;
+
+///
+/// The readings either side of an instant, in the order the normalizer walks them
+/// (), and the swaps or resets between them.
+///
+///
+/// The decrease guard and the event dialog judge a reading against its neighbours. Picking those by
+/// stamp would disagree with the engine wherever a month row is involved: "August 2026" is stamped on
+/// 1 August but describes 31 August, so a reading taken on 20 August comes before it, not after.
+///
+internal static class RegisterNeighbours
+{
+ ///
+ /// How far a month row's place on the timeline can lie from its stamp. Readings stamped further
+ /// away than this from the instant cannot be its neighbours unless nothing nearer exists.
+ ///
+ private static readonly TimeSpan LabelReach = TimeSpan.FromDays(33);
+
+ public static async Task FindAsync(
+ MeterVaultDbContext db, int meterId, DateTimeOffset utc, TimeZoneInfo zone, CancellationToken cancellationToken)
+ {
+ var readings = db.Readings.AsNoTracking().Where(r => r.MeterId == meterId);
+ var windowStart = utc - LabelReach;
+ var windowEnd = utc + LabelReach;
+
+ var nearby = await readings
+ .Where(r => r.Time >= windowStart && r.Time <= windowEnd && r.Time != utc)
+ .ToListAsync(cancellationToken).ConfigureAwait(false);
+ var before = await readings.Where(r => r.Time < windowStart).OrderByDescending(r => r.Time)
+ .FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
+ var after = await readings.Where(r => r.Time > windowEnd).OrderBy(r => r.Time)
+ .FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
+
+ IEnumerable candidates = nearby;
+ if (before is not null)
+ {
+ candidates = candidates.Append(before);
+ }
+
+ if (after is not null)
+ {
+ candidates = candidates.Append(after);
+ }
+
+ var timeline = ReadingTimeline.Build(candidates, zone);
+
+ // A reading stamped at the instant sorts after a month row whose month ends exactly then, as in the engine.
+ static bool IsBefore(TimelineReading entry, DateTimeOffset instant) =>
+ entry.Effective < instant || (entry.Effective == instant && entry.Reading.Time < instant);
+
+ var previous = timeline.Readings.LastOrDefault(e => IsBefore(e, utc));
+ var next = timeline.Readings.FirstOrDefault(e => !IsBefore(e, utc));
+
+ var lower = previous.Reading is null ? (DateTimeOffset?)null : previous.Effective;
+ var upper = next.Reading is null ? (DateTimeOffset?)null : next.Effective;
+ var events = await db.MeterEvents.AsNoTracking()
+ .Where(e => e.MeterId == meterId
+ && (e.EventType == MeterEventType.MeterSwap || e.EventType == MeterEventType.CounterReset))
+ .Where(e => lower == null || e.Time > lower.Value - LabelReach)
+ .Where(e => upper == null || e.Time <= upper.Value + LabelReach)
+ .ToListAsync(cancellationToken).ConfigureAwait(false);
+
+ return new Neighbours(
+ previous.Reading is null ? null : previous,
+ next.Reading is null ? null : next,
+ [.. events.Select(e => (Event: e, Time: timeline.BoundaryTime(e)))]);
+ }
+
+ /// The neighbours of an instant, and every swap or reset near them placed on the same timeline.
+ public sealed record Neighbours(
+ TimelineReading? Previous, TimelineReading? Next, IReadOnlyList<(MeterEvent Event, DateTimeOffset Time)> Boundaries)
+ {
+ /// Whether a swap or reset sits in (Previous, upToInclusive] — the interval it would explain.
+ public bool BoundaryAfterPreviousUpTo(DateTimeOffset? upToInclusive) =>
+ Boundaries.Any(b => (Previous is not { } p || b.Time > p.Effective) && (upToInclusive is null || b.Time <= upToInclusive));
+ }
+}
diff --git a/src/Infrastructure/Normalization/MeterConfigFactory.cs b/src/Infrastructure/Normalization/MeterConfigFactory.cs
index 15ce6e3..3e7e65e 100644
--- a/src/Infrastructure/Normalization/MeterConfigFactory.cs
+++ b/src/Infrastructure/Normalization/MeterConfigFactory.cs
@@ -31,7 +31,11 @@ public static class MeterConfigFactory
};
}
- private static CalibrationCurve? ParseCalibration(string? json)
+ ///
+ /// Reads a tank's stored calibration ({"volumePerUnit": 46.667, "offset": 0}), or null when
+ /// there is none or it is unreadable — a level is then taken as already being a volume.
+ ///
+ public static CalibrationCurve? ParseCalibration(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
@@ -56,6 +60,12 @@ public static class MeterConfigFactory
}
}
+ /// The stored form of a calibration, in the shape reads back; null clears it.
+ public static string? SerializeCalibration(CalibrationCurve? calibration) =>
+ calibration is null
+ ? null
+ : JsonSerializer.Serialize(new { volumePerUnit = calibration.VolumePerUnit, offset = calibration.Offset });
+
private static VirtualSpec? ParseVirtual(Meter meter)
{
if (meter.Mode != MeterMode.Virtual || string.IsNullOrWhiteSpace(meter.Meta))
diff --git a/src/Infrastructure/Normalization/NormalizationService.cs b/src/Infrastructure/Normalization/NormalizationService.cs
index 2bc90e3..812b7fd 100644
--- a/src/Infrastructure/Normalization/NormalizationService.cs
+++ b/src/Infrastructure/Normalization/NormalizationService.cs
@@ -1,7 +1,9 @@
using MeterVault.Core.Domain;
using MeterVault.Core.Normalization;
+using MeterVault.Infrastructure.Options;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Options;
namespace MeterVault.Infrastructure.Normalization;
@@ -10,10 +12,19 @@ namespace MeterVault.Infrastructure.Normalization;
/// consumption wholesale (consumption is a pure function of readings + events), and replaces the
/// stored rows. Virtual meters are skipped here — they are computed on read (SDD §14.1).
///
-public sealed class NormalizationService(MeterVaultDbContext db, INormalizationEngine engine)
+///
+/// Without options (tests, or a hand-built instance) months are UTC months; the application always
+/// passes the configured instance timezone, so stored consumption files under the months the charts show.
+///
+public sealed class NormalizationService(
+ MeterVaultDbContext db, INormalizationEngine engine, IOptions? options = null)
{
private readonly MeterVaultDbContext _db = db;
private readonly INormalizationEngine _engine = engine;
+ private readonly TimeZoneInfo _zone = InstanceTimeZone.Resolve(options?.Value.TimeZone);
+
+ /// The zone months are divided in: the configured one, or UTC when it is missing or unknown.
+ public TimeZoneInfo TimeZone => _zone;
///
/// Recomputes and replaces the consumption series for one meter from all its current readings
@@ -40,7 +51,7 @@ public sealed class NormalizationService(MeterVaultDbContext db, INormalizationE
.OrderBy(e => e.Time)
.ToListAsync(cancellationToken).ConfigureAwait(false);
- var context = new NormalizationContext { Meter = config, Readings = readings, Events = events };
+ var context = new NormalizationContext { Meter = config, Readings = readings, Events = events, TimeZone = _zone };
var consumption = _engine.Normalize(context);
await _db.Consumption.Where(c => c.MeterId == meterId)
diff --git a/src/Infrastructure/Normalization/NormalizationUpgrade.cs b/src/Infrastructure/Normalization/NormalizationUpgrade.cs
new file mode 100644
index 0000000..24fab14
--- /dev/null
+++ b/src/Infrastructure/Normalization/NormalizationUpgrade.cs
@@ -0,0 +1,345 @@
+using System.Text.Json;
+using MeterVault.Core.Domain;
+using MeterVault.Infrastructure.Import;
+using MeterVault.Infrastructure.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+namespace MeterVault.Infrastructure.Normalization;
+
+///
+/// Rebuilds every meter's stored consumption once after the normalization rules — or the timezone they
+/// divide months in — change.
+///
+///
+///
+/// Consumption is a pure function of readings and events, recomputed per meter whenever that meter
+/// ingests something. A rule change therefore reaches a meter only at its next reading — a meter read
+/// once a month would keep showing the old attribution for weeks. Recording the revision and zone the
+/// stored data was built with, and rebuilding when either differs, applies the change to everything at
+/// the next start instead. Runs as part of the startup migration step.
+///
+///
+/// A meter that fails to rebuild is logged and remembered, never fatal: startup continues, and the next
+/// start retries just those meters. One bad series must not keep the whole application down.
+///
+///
+public sealed class NormalizationUpgrade(
+ MeterVaultDbContext db, NormalizationService normalization, ILogger logger)
+{
+ /// The app_setting key holding the revision stored consumption was computed with.
+ public const string SettingKey = "normalization_revision";
+
+ /// The app_setting key holding the timezone id stored consumption was divided in.
+ public const string ZoneSettingKey = "normalization_zone";
+
+ /// The app_setting key listing meters whose rebuild failed and is retried at the next start.
+ public const string PendingSettingKey = "normalization_pending";
+
+ ///
+ /// Bump whenever the engine books existing readings differently.
+ /// 2: consumption between two readings is divided across the local months it spans; imported month
+ /// rows are flagged as such and read as the end of their month.
+ ///
+ public const int CurrentRevision = 2;
+
+ private const int ProgressEvery = 100;
+
+ ///
+ /// How long one statement of the upgrade may take. Rewriting a long series, or updating rows that sit
+ /// in compressed chunks, outlasts Npgsql's 30-second default on a real dataset — and a timeout there
+ /// would leave stored consumption half-derived until the next start.
+ ///
+ private static readonly TimeSpan StatementTimeout = TimeSpan.FromMinutes(15);
+
+ private readonly MeterVaultDbContext _db = db;
+ private readonly NormalizationService _normalization = normalization;
+ private readonly ILogger _logger = logger;
+
+ ///
+ /// Rebuilds all meters if stored consumption predates or another
+ /// timezone, otherwise only meters left over from a failed rebuild. Returns how many were rebuilt.
+ ///
+ public async Task RunAsync(CancellationToken cancellationToken = default)
+ {
+ var previousTimeout = _db.Database.GetCommandTimeout();
+ _db.Database.SetCommandTimeout(StatementTimeout);
+ try
+ {
+ return await RebuildAsync(cancellationToken).ConfigureAwait(false);
+ }
+ finally
+ {
+ _db.Database.SetCommandTimeout(previousTimeout);
+ }
+ }
+
+ private async Task RebuildAsync(CancellationToken cancellationToken)
+ {
+ var storedRevision = await ReadAsync(SettingKey, cancellationToken).ConfigureAwait(false);
+ var storedZone = await ReadAsync(ZoneSettingKey, cancellationToken).ConfigureAwait(false);
+ var pending = await ReadAsync(PendingSettingKey, cancellationToken).ConfigureAwait(false) ?? [];
+ var zone = _normalization.TimeZone.Id;
+
+ var outdated = storedRevision is not { } revision || revision < CurrentRevision || storedZone != zone;
+ if (!outdated && pending.Length == 0)
+ {
+ return 0;
+ }
+
+ // Month rows must be marked before anything is rebuilt: rebuilt without the marks, every imported
+ // monthly table would shift a month. If marking fails, nothing is rebuilt or recorded, and the
+ // whole upgrade is retried at the next start — the application still starts.
+ if (storedRevision is not >= 2 && !await MarkMonthLabelsAsync(cancellationToken).ConfigureAwait(false))
+ {
+ return 0;
+ }
+
+ List meterIds = outdated
+ ? await _db.Meters.AsNoTracking()
+ .Where(m => m.Mode != MeterMode.Virtual)
+ .OrderBy(m => m.Id)
+ .Select(m => m.Id)
+ .ToListAsync(cancellationToken).ConfigureAwait(false)
+ : [.. pending.Order()];
+
+ _logger.LogInformation(
+ "Rebuilding stored consumption for {Meters} meter(s) (normalization revision {Revision}, timezone {Zone})",
+ meterIds.Count, CurrentRevision, zone);
+
+ var failed = new List();
+ for (var i = 0; i < meterIds.Count; i++)
+ {
+ var meterId = meterIds[i];
+ try
+ {
+ // One transaction per meter: the rebuild deletes then re-inserts, and a meter must never be
+ // left without consumption — but one meter's failure should not undo all the others.
+ await using var tx = await _db.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
+ await _normalization.RecomputeMeterAsync(meterId, batchId: null, cancellationToken).ConfigureAwait(false);
+ await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ await tx.CommitAsync(cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ failed.Add(meterId);
+ _logger.LogError(ex, "Could not rebuild consumption for meter {MeterId}; it is retried at the next start", meterId);
+ }
+ finally
+ {
+ _db.ChangeTracker.Clear();
+ }
+
+ if ((i + 1) % ProgressEvery == 0)
+ {
+ _logger.LogInformation("Rebuilt {Done} of {Total} meter(s)", i + 1, meterIds.Count);
+ }
+ }
+
+ await WriteAsync(SettingKey, CurrentRevision, cancellationToken).ConfigureAwait(false);
+ await WriteAsync(ZoneSettingKey, zone, cancellationToken).ConfigureAwait(false);
+ await WriteAsync(PendingSettingKey, failed.ToArray(), cancellationToken).ConfigureAwait(false);
+ await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+
+ if (failed.Count > 0)
+ {
+ _logger.LogWarning(
+ "Consumption for {Failed} meter(s) could not be rebuilt and keeps its previous attribution until the next start: {MeterIds}",
+ failed.Count, string.Join(", ", failed));
+ }
+ else
+ {
+ _logger.LogInformation("Stored consumption rebuilt to normalization revision {Revision}", CurrentRevision);
+ }
+
+ return meterIds.Count - failed.Count;
+ }
+
+ ///
+ /// Flags the rows of earlier imports that came from monthly tables as ,
+ /// which the importer only records since revision 2. Returns false when that could not be done.
+ ///
+ ///
+ /// What a batch's date column held is known from its stored mapping: a reference profile by name, or
+ /// the wizard's date format. The wizard's default is auto-detection, which does not say per row whether
+ /// "August 2026" or "01.08.2026" was written. Such a batch is read as a monthly table when every one of
+ /// its readings is stamped at midnight on the 1st across at least two months — how those rows were
+ /// attributed before, and what a month-name sheet looks like. That is logged per batch, because a
+ /// day-dated sheet read on the 1st every time looks the same; it is fixed by re-importing it with the
+ /// day format.
+ ///
+ private async Task MarkMonthLabelsAsync(CancellationToken cancellationToken)
+ {
+ try
+ {
+ var batches = await _db.ImportBatches.AsNoTracking()
+ .Select(b => new { b.Id, b.SourceName, b.Mapping })
+ .ToListAsync(cancellationToken).ConfigureAwait(false);
+ var kinds = batches.ToDictionary(b => b.Id, b => DatesOf(b.Mapping));
+ var ids = kinds.Where(k => k.Value == BatchDates.Months).Select(k => k.Key).ToList();
+ var autoIds = kinds.Where(k => k.Value == BatchDates.AutoDetected).Select(k => k.Key).ToArray();
+
+ var label = (int)ReadingFlags.MonthLabel;
+ var imported = (short)ReadingQuality.Imported;
+ if (autoIds.Length > 0)
+ {
+ var monthly = await _db.Database.SqlQuery(
+ $"""
+ SELECT import_batch_id AS "Value" FROM reading
+ WHERE import_batch_id = ANY({autoIds}) AND quality = {imported}
+ GROUP BY import_batch_id
+ HAVING bool_and(date_trunc('month', "time" AT TIME ZONE 'UTC') = "time" AT TIME ZONE 'UTC')
+ AND count(DISTINCT date_trunc('month', "time" AT TIME ZONE 'UTC')) >= 2
+ """).ToListAsync(cancellationToken).ConfigureAwait(false);
+ foreach (var batch in batches.Where(b => monthly.Contains(b.Id)))
+ {
+ _logger.LogWarning(
+ "Import batch {BatchId} ({Source}) had its dates auto-detected and every row on the 1st of a month; it is read as a monthly table. If its dates were days, revert it and import it again with the day format",
+ batch.Id, batch.SourceName);
+ }
+
+ ids.AddRange(monthly);
+ }
+
+ if (ids.Count == 0)
+ {
+ return true;
+ }
+
+ var marked = 0;
+ foreach (var batchId in ids)
+ {
+ marked += await MarkBatchAsync(batchId, label, imported, cancellationToken).ConfigureAwait(false);
+ }
+
+ _logger.LogInformation("Marked {Rows} imported reading(s) from {Batches} monthly table(s) as month rows", marked, ids.Count);
+ return true;
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ _logger.LogError(ex, "Could not mark imported month rows; stored consumption is not rebuilt, and the upgrade is retried at the next start");
+ return false;
+ }
+ }
+
+ ///
+ /// Marks one batch's month rows. Kept to a single batch, and bounded by the meters and the time span
+ /// that batch actually wrote, because reading is a compressed hypertable: without those bounds
+ /// PostgreSQL has to decompress every chunk to answer, which on a long polled series is minutes of
+ /// work for a handful of rows. meter_id is the compression's segment key and time its
+ /// chunk key, so both prune. The decompression cap is lifted for the statement itself, since a monthly
+ /// table overlapping densely polled data still exceeds it.
+ ///
+ private async Task MarkBatchAsync(int batchId, int label, short imported, CancellationToken cancellationToken)
+ {
+ var rows = _db.Readings.AsNoTracking().Where(r => r.ImportBatchId == batchId);
+ var meters = await rows.Select(r => r.MeterId).Distinct().ToArrayAsync(cancellationToken).ConfigureAwait(false);
+ if (meters.Length == 0)
+ {
+ return 0;
+ }
+
+ var from = await rows.MinAsync(r => r.Time, cancellationToken).ConfigureAwait(false);
+ var to = await rows.MaxAsync(r => r.Time, cancellationToken).ConfigureAwait(false);
+
+ await using var tx = await _db.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
+ await _db.Database.ExecuteSqlRawAsync(
+ "SET LOCAL timescaledb.max_tuples_decompressed_per_dml_transaction = 0", cancellationToken).ConfigureAwait(false);
+ var marked = await _db.Database.ExecuteSqlInterpolatedAsync(
+ $"""
+ UPDATE reading SET flags = flags | {label}
+ WHERE import_batch_id = {batchId} AND quality = {imported} AND (flags & {label}) = 0
+ AND meter_id = ANY({meters}) AND "time" >= {from} AND "time" <= {to}
+ AND date_trunc('month', "time" AT TIME ZONE 'UTC') = "time" AT TIME ZONE 'UTC'
+ """,
+ cancellationToken).ConfigureAwait(false);
+ await tx.CommitAsync(cancellationToken).ConfigureAwait(false);
+ return marked;
+ }
+
+ private enum BatchDates
+ {
+ Days,
+ Months,
+ AutoDetected,
+ }
+
+ private static BatchDates DatesOf(string? mapping)
+ {
+ if (string.IsNullOrWhiteSpace(mapping))
+ {
+ return BatchDates.Days;
+ }
+
+ try
+ {
+ using var doc = JsonDocument.Parse(mapping);
+ var root = doc.RootElement;
+ if (root.ValueKind != JsonValueKind.Object)
+ {
+ return BatchDates.Days;
+ }
+
+ if (root.TryGetProperty("profile", out var profileName) && profileName.ValueKind == JsonValueKind.String)
+ {
+ MappingProfile[] reference =
+ [ReferenceProfiles.Electricity(), ReferenceProfiles.Water(), ReferenceProfiles.HeatingOil(), ReferenceProfiles.Costs()];
+ return reference.Any(p =>
+ p.Name == profileName.GetString() && p.DateKind == DateKind.MonthName && !p.AnchorMonthsToEnd)
+ ? BatchDates.Months
+ : BatchDates.Days;
+ }
+
+ if (!root.TryGetProperty("dateKind", out var dateKind) || dateKind.ValueKind != JsonValueKind.String)
+ {
+ return BatchDates.Days;
+ }
+
+ return dateKind.GetString() switch
+ {
+ nameof(DateKind.MonthName) => BatchDates.Months,
+ nameof(DateKind.Auto) => BatchDates.AutoDetected,
+ _ => BatchDates.Days,
+ };
+ }
+ catch (JsonException)
+ {
+ return BatchDates.Days;
+ }
+ }
+
+ private async Task ReadAsync(string key, CancellationToken cancellationToken)
+ {
+ var setting = await _db.AppSettings.AsNoTracking()
+ .FirstOrDefaultAsync(s => s.Key == key, cancellationToken).ConfigureAwait(false);
+ if (setting is null)
+ {
+ return default;
+ }
+
+ try
+ {
+ return JsonSerializer.Deserialize(setting.Value);
+ }
+ catch (JsonException)
+ {
+ // Unreadable counts as absent: the worst outcome is one rebuild too many.
+ return default;
+ }
+ }
+
+ /// Stages a setting as JSON — the column is jsonb.
+ private async Task WriteAsync(string key, T value, CancellationToken cancellationToken)
+ {
+ var json = JsonSerializer.Serialize(value);
+ var setting = await _db.AppSettings.FirstOrDefaultAsync(s => s.Key == key, cancellationToken).ConfigureAwait(false);
+ if (setting is null)
+ {
+ _db.AppSettings.Add(new AppSetting { Key = key, Value = json });
+ }
+ else
+ {
+ setting.Value = json;
+ }
+ }
+}
diff --git a/src/Infrastructure/Options/InstanceTimeZone.cs b/src/Infrastructure/Options/InstanceTimeZone.cs
new file mode 100644
index 0000000..9d093cf
--- /dev/null
+++ b/src/Infrastructure/Options/InstanceTimeZone.cs
@@ -0,0 +1,40 @@
+using MeterVault.Core.Normalization;
+
+namespace MeterVault.Infrastructure.Options;
+
+///
+/// The instance timezone (, SDD §10) as every part of the
+/// application uses it: the normalizer divides months in it, readers bucket in it and turn requested
+/// dates into instants with it. One resolution, so none of them disagrees about where a month starts.
+///
+public static class InstanceTimeZone
+{
+ /// The configured zone, or UTC when is empty or not a known zone.
+ public static TimeZoneInfo Resolve(string? id)
+ {
+ if (string.IsNullOrWhiteSpace(id))
+ {
+ return TimeZoneInfo.Utc;
+ }
+
+ return TimeZoneInfo.TryFindSystemTimeZoneById(id, out var zone) ? zone : TimeZoneInfo.Utc;
+ }
+
+ ///
+ /// The IANA id for a configured zone. .NET also accepts Windows ids ("W. Europe Standard Time"), but
+ /// PostgreSQL buckets by IANA names only, so a Windows id is translated rather than passed to SQL.
+ /// Anything unrecognised is returned unchanged, to be reported at startup.
+ ///
+ public static string Canonical(string id)
+ {
+ if (!TimeZoneInfo.TryFindSystemTimeZoneById(id, out var zone) || zone.HasIanaId)
+ {
+ return id;
+ }
+
+ return TimeZoneInfo.TryConvertWindowsIdToIanaId(zone.Id, out var iana) ? iana : id;
+ }
+
+ /// The instant a local date starts — the lower bound a range of that date begins at.
+ public static DateTimeOffset StartOf(DateOnly date, TimeZoneInfo zone) => GapAttribution.LocalMidnight(date, zone);
+}
diff --git a/src/Infrastructure/Options/MeterVaultOptions.cs b/src/Infrastructure/Options/MeterVaultOptions.cs
index d8d873d..487d8f9 100644
--- a/src/Infrastructure/Options/MeterVaultOptions.cs
+++ b/src/Infrastructure/Options/MeterVaultOptions.cs
@@ -35,10 +35,9 @@ public sealed class MeterVaultOptions
///
/// Allow an update to be triggered from the UI/API. Off by default, and deliberately. The
/// updater builds whatever is on the branch and the LXC runs this app as root, so enabling it
- /// turns a valid API key into arbitrary code execution on the host. It additionally requires at
- /// least one configured API key: an anonymous-API deployment can never reach it, because opening
- /// reads must not open root. Only sensible where the UI is behind an authenticating proxy or on
- /// a network you fully trust.
+ /// turns a valid API key into arbitrary code execution on the host. This flag is the only gate:
+ /// with it on, anything that can reach the app can trigger a rebuild and restart. Only sensible
+ /// where the UI is behind an authenticating proxy or on a network you fully trust.
///
public bool AllowInAppUpdate { get; set; }
@@ -58,7 +57,8 @@ public sealed class MeterVaultOptions
///
/// API keys accepted on the X-Api-Key header for the REST API (SDD §9). Provide via env
- /// (e.g. MeterVault__ApiKeys__0=...). Empty means the API is open (dev only).
+ /// (e.g. MeterVault__ApiKeys__0=...). Empty closes the REST API unless
+ /// opens it explicitly.
///
public IList ApiKeys { get; set; } = [];
diff --git a/tests/Core.Tests/CumulativeCounterNormalizerTests.cs b/tests/Core.Tests/CumulativeCounterNormalizerTests.cs
index a22906b..81288a9 100644
--- a/tests/Core.Tests/CumulativeCounterNormalizerTests.cs
+++ b/tests/Core.Tests/CumulativeCounterNormalizerTests.cs
@@ -72,6 +72,54 @@ public sealed class CumulativeCounterNormalizerTests
Assert.Equal([10d, 50d, 30d, 50d], result.Select(c => c.Amount));
}
+ [Fact]
+ public void Counter_reset_with_a_final_register_books_the_stretch_before_the_reset()
+ {
+ // The register climbed 150 → 170 after the February reading, then reset to 0 and reached 30
+ // by March. Knowing the 170 turns the reset from "lose 20" into the swap formula: 20 + 30.
+ var ctx = new NormalizationContext
+ {
+ Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "kWh", InitialBaseline = 90 },
+ Readings =
+ [
+ Reading(1, Month(2023, 1), 100),
+ Reading(1, Month(2023, 2), 150),
+ Reading(1, Month(2023, 3), 30),
+ Reading(1, Month(2023, 4), 80),
+ ],
+ Events = [Reset(1, Month(2023, 3), newValue: 0, prevValue: 170)],
+ };
+
+ var result = _engine.Normalize(ctx);
+
+ Assert.Equal([10d, 50d, 50d, 50d], result.Select(c => c.Amount));
+ }
+
+ [Fact]
+ public void Swap_recorded_with_the_new_meters_start_reading_at_the_same_instant_books_the_tail_there()
+ {
+ // What the meter page records for "the meter was swapped today": the swap event and a reading of
+ // the new register's start value, both at the swap instant. The old meter's tail (873 − 861)
+ // lands at the swap, and the next reading counts from the new register's start.
+ var swapAt = Month(2023, 3).AddDays(16).AddHours(9);
+ var ctx = new NormalizationContext
+ {
+ Meter = new MeterConfig { MeterId = 10, Mode = MeterMode.CumulativeCounter, Unit = "m3", InitialBaseline = 848 },
+ Readings =
+ [
+ DayReading(10, Month(2023, 3), 861),
+ Reading(10, swapAt, 2),
+ Reading(10, swapAt.AddDays(1), 2.5),
+ ],
+ Events = [Swap(10, swapAt, prevValue: 873, newValue: 2)],
+ };
+
+ var result = _engine.Normalize(ctx);
+
+ Assert.Equal([13d, 12d, 0.5d], result.Select(c => c.Amount));
+ Assert.Equal(swapAt, result[1].Time);
+ }
+
[Fact]
public void Unexplained_decrease_yields_zero_and_marks_quality()
{
diff --git a/tests/Core.Tests/GapAttributionTests.cs b/tests/Core.Tests/GapAttributionTests.cs
index 283ec96..b4122dc 100644
--- a/tests/Core.Tests/GapAttributionTests.cs
+++ b/tests/Core.Tests/GapAttributionTests.cs
@@ -5,125 +5,406 @@ using static MeterVault.Core.Tests.TestData;
namespace MeterVault.Core.Tests;
///
-/// A counter delta is booked at the reading that closes it. That is correct at the reporting cadence
-/// and wrong after a long outage, so a gap containing two or more whole months is apportioned.
-/// The boundary between those two behaviours is what these pin down: a normal monthly series must
-/// come out byte-for-byte unchanged, because it is what reconciles against the reference spreadsheet.
+/// Consumption between two readings belongs to the months it accrued in. What these pin down: an
+/// interval crossing a local month boundary is divided by elapsed time; an imported monthly table
+/// still produces exactly one unchanged row per row, because that is what reconciles against the
+/// reference spreadsheet (SDD §13).
///
public sealed class GapAttributionTests
{
+ private static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
+
private readonly INormalizationEngine _engine = NormalizationEngine.CreateDefault();
+ private static DateTimeOffset BerlinTime(int year, int month, int day, int hour = 0, int minute = 0) =>
+ new(new DateTime(year, month, day, hour, minute, 0), Berlin.GetUtcOffset(new DateTime(year, month, day, hour, minute, 0)));
+
+ private static Reading Manual(DateTimeOffset time, double value) =>
+ new() { MeterId = 1, Time = time, Value = value, Quality = ReadingQuality.Manual };
+
+ private static string LocalMonth(DateTimeOffset instant) => TimeZoneInfo.ConvertTime(instant, Berlin).ToString("yyyy-MM");
+
[Fact]
- public void A_monthly_cadence_is_never_split()
+ public void Readings_from_1_August_to_16_September_are_divided_between_the_two_months()
{
- // One whole month per interval — the reference-data shape. Splitting here would move energy
- // between months and break reconciliation (SDD §13).
- Assert.False(GapAttribution.ShouldSplit(Month(2023, 1), Month(2023, 2)));
- Assert.False(GapAttribution.ShouldSplit(Month(2023, 1), Month(2023, 2).AddDays(-1)));
+ // The reported case: nothing read in between, so September used to carry all 46 days.
+ var august1 = BerlinTime(2026, 8, 1, 9, 0);
+ var september16 = BerlinTime(2026, 9, 16, 18, 0);
+ var ctx = new NormalizationContext
+ {
+ Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "m3" },
+ Readings = [Manual(august1, 700), Manual(september16, 746)],
+ TimeZone = Berlin,
+ };
- // A reading that lands hours late must not tip the rule and hand January a sliver.
- Assert.False(GapAttribution.ShouldSplit(Month(2023, 12), Month(2024, 1).AddHours(6)));
+ var result = _engine.Normalize(ctx).ToList();
+ var interval = result.Skip(1).ToList();
- // Nor should a six-week interval, which still contains only one whole month.
- Assert.False(GapAttribution.ShouldSplit(Month(2023, 1), Month(2023, 2).AddDays(14)));
+ Assert.Equal(2, interval.Count);
+ Assert.Equal(["2026-08", "2026-09"], interval.Select(c => LocalMonth(c.Time)));
+ Assert.Equal(46, interval.Sum(c => c.Amount), 9);
+
+ var total = september16 - august1;
+ var inAugust = BerlinTime(2026, 9, 1) - august1;
+ Assert.Equal(46 * (inAugust / total), interval[0].Amount, 9);
+ Assert.All(interval, c => Assert.Equal(ReadingQuality.Estimated, c.Quality));
+
+ // September's share still sits on the reading that closed the interval.
+ Assert.Equal(september16, interval[1].Time);
}
[Fact]
- public void Sub_month_intervals_are_never_split()
+ public void An_interval_inside_one_month_is_one_row_at_its_reading_with_its_own_quality()
{
- Assert.False(GapAttribution.ShouldSplit(Month(2023, 5), Month(2023, 5).AddHours(1)));
- Assert.False(GapAttribution.ShouldSplit(Month(2023, 5).AddDays(10), Month(2023, 5).AddDays(20)));
+ var ctx = new NormalizationContext
+ {
+ Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "m3" },
+ Readings = [Manual(BerlinTime(2026, 9, 1, 8), 700), Manual(BerlinTime(2026, 9, 16, 18), 710)],
+ TimeZone = Berlin,
+ };
+
+ var result = _engine.Normalize(ctx).ToList();
+
+ Assert.Equal(2, result.Count);
+ Assert.Equal(BerlinTime(2026, 9, 16, 18), result[1].Time);
+ Assert.Equal(ReadingQuality.Manual, result[1].Quality);
}
[Fact]
- public void A_skipped_month_is_split()
+ public void Months_are_local_so_a_reading_just_after_local_midnight_does_not_take_the_month_with_it()
{
- Assert.True(GapAttribution.ShouldSplit(Month(2023, 1), Month(2023, 3)));
- Assert.True(GapAttribution.ShouldSplit(Month(2026, 5), new DateTimeOffset(2026, 7, 18, 15, 33, 0, TimeSpan.Zero)));
+ // 1 September 00:30 in Berlin is still 31 August in UTC. Split at UTC boundaries this interval
+ // would be one row stamped in local September carrying all of August.
+ var ctx = new NormalizationContext
+ {
+ Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "m3" },
+ Readings = [Manual(BerlinTime(2026, 8, 1), 700), Manual(BerlinTime(2026, 9, 1, 0, 30), 731)],
+ TimeZone = Berlin,
+ };
+
+ var interval = _engine.Normalize(ctx).Skip(1).ToList();
+
+ Assert.Equal(["2026-08", "2026-09"], interval.Select(c => LocalMonth(c.Time)));
+ Assert.True(interval[0].Amount > 30.9, $"August got only {interval[0].Amount}");
}
[Fact]
- public void Splitting_preserves_the_total_and_keeps_the_closing_timestamp()
+ public void A_reading_exactly_at_local_midnight_on_the_first_books_wholly_to_the_month_before()
{
- var start = Month(2026, 5);
- var end = new DateTimeOffset(2026, 7, 18, 15, 33, 0, TimeSpan.Zero);
+ var ctx = new NormalizationContext
+ {
+ Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "m3" },
+ Readings = [Manual(BerlinTime(2026, 8, 1), 700), Manual(BerlinTime(2026, 9, 1), 731)],
+ TimeZone = Berlin,
+ };
- var segments = GapAttribution.Split(start, end, 714.5);
+ var result = _engine.Normalize(ctx).ToList();
- // May, June, July.
- Assert.Equal(3, segments.Count);
- Assert.Equal(714.5, segments.Sum(s => s.Amount), 6);
- Assert.Equal(end, segments[^1].Time);
- Assert.Equal(Month(2026, 6), segments[0].Time);
- Assert.Equal(Month(2026, 7), segments[1].Time);
+ Assert.Equal(2, result.Count);
+ Assert.Equal("2026-08", LocalMonth(result[1].Time));
+ Assert.Equal(31, result[1].Amount, 9);
+ Assert.Equal(ReadingQuality.Manual, result[1].Quality);
}
[Fact]
- public void Each_month_gets_a_share_proportional_to_the_time_it_covers()
+ public void An_ordinary_imported_monthly_series_produces_one_unchanged_row_per_reading()
{
- // Exactly two whole months: an even split, to the cent.
- var segments = GapAttribution.Split(Month(2023, 1), Month(2023, 3), 620);
+ // The reference-data shape: rows stamped 00:00 UTC on the 1st. It must not gain rows, move
+ // them, or lose its quality — in UTC or in the instance timezone.
+ foreach (var zone in new[] { TimeZoneInfo.Utc, Berlin })
+ {
+ var ctx = new NormalizationContext
+ {
+ Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "kWh" },
+ Readings =
+ [
+ Reading(1, Month(2022, 9), 0),
+ Reading(1, Month(2022, 10), 411),
+ Reading(1, Month(2022, 11), 1153),
+ Reading(1, Month(2022, 12), 1968),
+ ],
+ TimeZone = zone,
+ };
+
+ var result = _engine.Normalize(ctx).ToList();
+
+ Assert.Equal(4, result.Count);
+ Assert.DoesNotContain(result, c => c.Quality == ReadingQuality.Estimated);
+ Assert.Equal([0, 411, 742, 815], result.Select(c => c.Amount).ToArray());
+ Assert.Equal([Month(2022, 9), Month(2022, 10), Month(2022, 11), Month(2022, 12)], result.Select(c => c.Time));
+ }
+ }
+
+ [Fact]
+ public void A_month_label_is_an_imported_row_the_importer_flagged_as_a_month()
+ {
+ Assert.True(GapAttribution.IsMonthLabel(Reading(1, Month(2026, 5), 1)));
+ Assert.False(GapAttribution.IsMonthLabel(DayReading(1, Month(2026, 5), 1)));
+ // Correcting a typo in an imported month row by hand does not turn it into a reading on the 1st.
+ Assert.True(GapAttribution.IsMonthLabel(new Reading
+ {
+ Time = Month(2026, 5), Quality = ReadingQuality.Manual, Flags = ReadingFlags.MonthLabel,
+ }));
+
+ // "Mai 2026" is the register at the end of May.
+ Assert.Equal(BerlinTime(2026, 6, 1), GapAttribution.EffectiveTime(Reading(1, Month(2026, 5), 1), Berlin));
+ }
+
+ [Fact]
+ public void A_day_dated_import_on_the_first_is_an_instant_not_a_month()
+ {
+ // A meter log imported with "15.07.2026" and "01.08.2026", then read by hand on 16 September. The
+ // row on the 1st is stamped at the same midnight as a month label but means that day.
+ var ctx = new NormalizationContext
+ {
+ Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "m3" },
+ Readings =
+ [
+ DayReading(1, new DateTimeOffset(2026, 7, 15, 0, 0, 0, TimeSpan.Zero), 600),
+ DayReading(1, Month(2026, 8), 700),
+ Manual(BerlinTime(2026, 9, 16, 18), 746),
+ ],
+ TimeZone = Berlin,
+ };
+
+ var byMonth = _engine.Normalize(ctx).Skip(1)
+ .GroupBy(c => LocalMonth(c.Time))
+ .ToDictionary(g => g.Key, g => g.Sum(c => c.Amount));
+
+ // 15 July to 1 August 00:00 UTC ends at 02:00 local on the 1st: two hours of the 100 in August, not 65.
+ Assert.True(byMonth["2026-07"] > 99.4, $"July got only {byMonth["2026-07"]}");
+ // ...and the six weeks after it are shared between August and September.
+ Assert.True(byMonth["2026-08"] is > 30 and < 31, $"August got {byMonth["2026-08"]}");
+ Assert.True(byMonth["2026-09"] is > 15 and < 16, $"September got {byMonth["2026-09"]}");
+ Assert.Equal(146, byMonth.Values.Sum(), 6);
+ }
+
+ [Fact]
+ public void A_monthly_table_imported_after_live_readings_does_not_count_its_month_twice()
+ {
+ // Read by hand on 1 August and 16 September; later the sheet rows "Juli" and "August" are imported.
+ // "August 2026" = 731 is the register at the end of August, so it belongs after the reading taken
+ // on 1 August even though it is stamped at midnight that day.
+ var ctx = new NormalizationContext
+ {
+ Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "m3" },
+ Readings =
+ [
+ Reading(1, Month(2026, 7), 699),
+ Reading(1, Month(2026, 8), 731),
+ Manual(BerlinTime(2026, 8, 1, 9), 700),
+ Manual(BerlinTime(2026, 9, 16, 18), 746),
+ ],
+ TimeZone = Berlin,
+ };
+
+ var result = _engine.Normalize(ctx).ToList();
+ var byMonth = result.GroupBy(c => LocalMonth(c.Time)).ToDictionary(g => g.Key, g => g.Sum(c => c.Amount));
+
+ Assert.Equal(746, result.Sum(c => c.Amount), 6);
+ Assert.Equal(32, byMonth["2026-08"], 6); // 1 on the morning of the 1st, 31 through the month
+ Assert.Equal(15, byMonth["2026-09"], 6);
+ Assert.DoesNotContain(result, c => c.Amount < 0);
+ }
+
+ [Fact]
+ public void Behind_utc_imported_months_stay_in_their_own_local_month()
+ {
+ // In New York, 00:00 UTC on the 1st is still the evening before. The water sheet rows, including
+ // the swap in March, must each land in the month they name: none doubled, none empty.
+ var newYork = TimeZoneInfo.FindSystemTimeZoneById("America/New_York");
+ var ctx = new NormalizationContext
+ {
+ Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "m³" },
+ Readings =
+ [
+ Reading(1, Month(2022, 12), 834),
+ Reading(1, Month(2023, 1), 848),
+ Reading(1, Month(2023, 2), 861),
+ Reading(1, Month(2023, 3), 2),
+ Reading(1, Month(2023, 4), 15),
+ ],
+ Events = [Swap(1, Month(2023, 3), prevValue: 861, newValue: 2, amount: 12)],
+ TimeZone = newYork,
+ };
+
+ var result = _engine.Normalize(ctx).ToList();
+ string NewYorkMonth(DateTimeOffset t) => TimeZoneInfo.ConvertTime(t, newYork).ToString("yyyy-MM");
+
+ Assert.Equal(
+ ["2022-12", "2023-01", "2023-02", "2023-03", "2023-04"],
+ result.Select(c => NewYorkMonth(c.Time)));
+ Assert.Equal([834, 14, 13, 12, 13], result.Select(c => c.Amount).ToArray());
+ }
+
+ [Fact]
+ public void Behind_utc_a_live_reading_next_to_imported_months_never_duplicates_a_stored_row()
+ {
+ // Imported "Juni" and "Juli", and HA polled at exactly local midnight on 1 July, the instant the
+ // July row is stamped at. Two rows on one key used to fail the whole recompute; they now add up.
+ var newYork = TimeZoneInfo.FindSystemTimeZoneById("America/New_York");
+ var julyMidnight = new DateTimeOffset(2026, 7, 1, 4, 0, 0, TimeSpan.Zero); // 00:00 EDT
+ var ctx = new NormalizationContext
+ {
+ Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "kWh" },
+ Readings =
+ [
+ Reading(1, Month(2026, 6), 1000),
+ Reading(1, Month(2026, 7), 1300),
+ new Reading { MeterId = 1, Time = julyMidnight, Value = 1005, Quality = ReadingQuality.Measured },
+ new Reading { MeterId = 1, Time = new DateTimeOffset(2026, 8, 10, 16, 0, 0, TimeSpan.Zero), Value = 1420, Quality = ReadingQuality.Measured },
+ ],
+ TimeZone = newYork,
+ };
+
+ var result = _engine.Normalize(ctx).ToList();
+ var july = result.Where(c => TimeZoneInfo.ConvertTime(c.Time, newYork).ToString("yyyy-MM") == "2026-07").ToList();
+
+ Assert.Equal(result.Count, result.Select(c => (c.Time, c.Kind)).Distinct().Count());
+ Assert.Equal(1420, result.Sum(c => c.Amount), 6);
+ Assert.Equal(300, july.Sum(c => c.Amount), 6);
+ }
+
+ [Fact]
+ public void A_swap_detected_in_a_monthly_table_applies_to_the_first_live_reading_of_that_month()
+ {
+ // HA already read the new water meter on 10 and 25 March; later the sheet is imported and the
+ // importer detects the swap at the "Maerz" row. The old tail must not be measured from 1.5.
+ var ctx = new NormalizationContext
+ {
+ Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "m³" },
+ Readings =
+ [
+ Reading(1, Month(2023, 1), 848),
+ Reading(1, Month(2023, 2), 861),
+ Manual(BerlinTime(2023, 3, 10, 12), 0.5),
+ Manual(BerlinTime(2023, 3, 25, 12), 1.5),
+ Reading(1, Month(2023, 3), 2),
+ Reading(1, Month(2023, 4), 15),
+ ],
+ Events = [Swap(1, Month(2023, 3), prevValue: 861, newValue: 2)],
+ TimeZone = Berlin,
+ };
+
+ var byMonth = _engine.Normalize(ctx).GroupBy(c => LocalMonth(c.Time)).ToDictionary(g => g.Key, g => g.Sum(c => c.Amount));
+
+ Assert.Equal(1.5, byMonth["2023-03"], 6);
+ Assert.Equal(13, byMonth["2023-04"], 6);
+ }
+
+ [Fact]
+ public void A_reset_at_a_month_rows_stamp_applies_where_the_month_begins()
+ {
+ // A reset posted at 00:00 UTC on 1 August, with the August row and two live August readings.
+ var ctx = new NormalizationContext
+ {
+ Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "kWh" },
+ Readings =
+ [
+ Reading(1, Month(2026, 7), 699),
+ Manual(BerlinTime(2026, 8, 5, 12), 5),
+ Manual(BerlinTime(2026, 8, 20, 12), 20),
+ Reading(1, Month(2026, 8), 31),
+ ],
+ Events = [Reset(1, Month(2026, 8), newValue: 0, prevValue: 700)],
+ TimeZone = Berlin,
+ };
+
+ var byMonth = _engine.Normalize(ctx).GroupBy(c => LocalMonth(c.Time)).ToDictionary(g => g.Key, g => g.Sum(c => c.Amount));
+
+ Assert.Equal(32, byMonth["2026-08"], 6);
+ }
+
+ [Fact]
+ public void Burner_hours_follow_the_same_month_rows_as_registers()
+ {
+ // "Juli" = 960 h and "August" = 1000 h imported, plus live readings on 15 August and 16 September.
+ var ctx = new NormalizationContext
+ {
+ Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.RuntimeCounter, Unit = "h" },
+ Readings =
+ [
+ Reading(1, Month(2026, 7), 960),
+ Reading(1, Month(2026, 8), 1000),
+ Manual(BerlinTime(2026, 8, 15, 12), 990),
+ Manual(BerlinTime(2026, 9, 16, 12), 1020),
+ ],
+ TimeZone = Berlin,
+ };
+
+ var result = _engine.Normalize(ctx).ToList();
+
+ Assert.Equal(1020, result.Sum(c => c.Amount), 6);
+ Assert.DoesNotContain(result, c => c.Amount < 0);
+ }
+
+ [Fact]
+ public void Behind_utc_month_rows_of_deltas_and_hours_stay_in_their_own_month()
+ {
+ var newYork = TimeZoneInfo.FindSystemTimeZoneById("America/New_York");
+ string NewYorkMonth(DateTimeOffset t) => TimeZoneInfo.ConvertTime(t, newYork).ToString("yyyy-MM");
+
+ foreach (var mode in new[] { MeterMode.DirectDelta, MeterMode.RuntimeCounter })
+ {
+ var ctx = new NormalizationContext
+ {
+ Meter = new MeterConfig { MeterId = 1, Mode = mode, Unit = "h" },
+ Readings = [Reading(1, Month(2026, 7), 960), Reading(1, Month(2026, 8), 1000)],
+ TimeZone = newYork,
+ };
+
+ var result = _engine.Normalize(ctx).ToList();
+
+ Assert.Equal(["2026-07", "2026-08"], result.Select(c => NewYorkMonth(c.Time)));
+ }
+ }
+
+ [Theory]
+ [InlineData("America/Asuncion", 2023, 8)]
+ [InlineData("America/Asuncion", 2017, 8)]
+ [InlineData("Europe/Volgograd", 2018, 11)]
+ [InlineData("Pacific/Apia", 2011, 11)]
+ [InlineData("Asia/Amman", 2005, 2)]
+ [InlineData("Asia/Damascus", 2011, 2)]
+ public void Months_at_a_transition_the_zone_data_contradicts_still_divide_and_finish(string zoneId, int year, int month)
+ {
+ // Zone data that reports a midnight's offset from after the change used to stall the month walk
+ // forever — an import or the startup rebuild that never returned.
+ if (!TimeZoneInfo.TryFindSystemTimeZoneById(zoneId, out var zone))
+ {
+ return;
+ }
+
+ var from = new DateTimeOffset(year, month, 10, 12, 0, 0, TimeSpan.Zero);
+ var to = from.AddMonths(3);
+ var work = Task.Run(() => GapAttribution.Attribute(from, to, to, 90, zone));
+
+ Assert.True(work.Wait(TimeSpan.FromSeconds(5)), "Attribution did not finish.");
+ Assert.Equal(90, work.Result.Sum(s => s.Amount), 6);
+ Assert.All(work.Result, s => Assert.True(s.Time > from && s.Time <= to, $"{s.Time:O} lies outside the interval"));
+ }
+
+ [Fact]
+ public void Where_clocks_fall_back_at_midnight_the_month_starts_at_the_first_midnight()
+ {
+ // Havana leaves daylight time at 01:00 on 1 November 2026, so 00:00 happens twice. October ends at
+ // the first one; stamping October's share after it would file it under November.
+ var havana = TimeZoneInfo.FindSystemTimeZoneById("America/Havana");
+ var from = new DateTimeOffset(2026, 10, 20, 16, 0, 0, TimeSpan.Zero);
+ var to = new DateTimeOffset(2026, 11, 20, 17, 0, 0, TimeSpan.Zero);
+
+ var segments = GapAttribution.Attribute(from, to, to, 31, havana);
Assert.Equal(2, segments.Count);
- var januaryShare = 31d / 59d; // 2023 is not a leap year: Jan 31 + Feb 28.
- Assert.Equal(620 * januaryShare, segments[0].Amount, 6);
- Assert.Equal(620, segments.Sum(s => s.Amount), 6);
+ Assert.True(segments[0].Time < new DateTimeOffset(2026, 11, 1, 4, 0, 0, TimeSpan.Zero), $"October's share is stamped {segments[0].Time:O}");
}
[Fact]
- public void A_gap_in_a_counter_series_is_spread_and_marked_estimated()
+ public void A_live_reading_after_the_last_imported_row_counts_from_the_end_of_that_rows_month()
{
- var ctx = new NormalizationContext
- {
- Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "kWh" },
- Readings =
- [
- Reading(1, Month(2023, 1), 1000),
- Reading(1, Month(2023, 4), 1900), // three months in one reading
- ],
- };
-
- var result = _engine.Normalize(ctx).ToList();
-
- // Baseline row for the first reading, then Jan/Feb/Mar shares of the 900 gap.
- Assert.Equal(4, result.Count);
- Assert.Equal(1000 + 900, result.Sum(c => c.Amount), 6);
-
- var spread = result.Skip(1).ToList();
- Assert.All(spread, c => Assert.Equal(ReadingQuality.Estimated, c.Quality));
- Assert.Equal(900, spread.Sum(c => c.Amount), 6);
- }
-
- [Fact]
- public void An_ordinary_monthly_series_produces_one_measured_row_per_reading()
- {
- // The regression that matters: this is the reference-data shape, and it must not gain rows
- // or lose its quality markers.
- var ctx = new NormalizationContext
- {
- Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "kWh" },
- Readings =
- [
- Reading(1, Month(2022, 9), 0),
- Reading(1, Month(2022, 10), 411),
- Reading(1, Month(2022, 11), 1153),
- Reading(1, Month(2022, 12), 1968),
- ],
- };
-
- var result = _engine.Normalize(ctx).ToList();
-
- Assert.Equal(4, result.Count);
- Assert.DoesNotContain(result, c => c.Quality == ReadingQuality.Estimated);
- Assert.Equal([0, 411, 742, 815], result.Select(c => c.Amount).ToArray());
- }
-
- [Fact]
- public void The_observed_solar_gap_is_apportioned_across_the_months_it_covers()
- {
- // The case this exists for: Solar 1 read monthly to 1 May 2026, then a single live reading on
- // 18 July. 714.5 kWh of generation arriving as one July row made June look like an outage.
+ // Solar 1 read monthly up to "Mai 2026", then one live reading on 18 July. May's use is already
+ // in the May row; the 714.5 kWh since belongs to June and July — not a third of it back in May.
+ var july18 = new DateTimeOffset(2026, 7, 18, 15, 33, 0, TimeSpan.Zero);
var ctx = new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.GenerationCounter, Unit = "kWh" },
@@ -131,28 +412,59 @@ public sealed class GapAttributionTests
[
Reading(1, Month(2026, 4), 10308),
Reading(1, Month(2026, 5), 10731),
- Reading(1, new DateTimeOffset(2026, 7, 18, 15, 33, 0, TimeSpan.Zero), 11445.5),
+ new Reading { MeterId = 1, Time = july18, Value = 11445.5, Quality = ReadingQuality.Measured },
],
+ TimeZone = Berlin,
};
var result = _engine.Normalize(ctx).ToList();
- var gap = result.Where(c => c.Time > Month(2026, 5)).ToList();
+ var gap = result.Skip(2).ToList();
- Assert.Equal(3, gap.Count);
+ Assert.Equal(["2026-06", "2026-07"], gap.Select(c => LocalMonth(c.Time)));
Assert.Equal(714.5, gap.Sum(c => c.Amount), 6);
-
- // No single month swallows the whole gap any more.
- Assert.All(gap, c => Assert.True(c.Amount < 714.5 * 0.75, $"{c.Time:yyyy-MM-dd} took {c.Amount:0.#}"));
-
- // Generation is preserved end to end: baseline 0 → 11445.5.
+ var june = BerlinTime(2026, 7, 1) - BerlinTime(2026, 6, 1);
+ Assert.Equal(714.5 * (june / (july18 - BerlinTime(2026, 6, 1))), gap[0].Amount, 6);
Assert.Equal(11445.5, result.Sum(c => c.Amount), 6);
}
[Fact]
- public void An_unchanged_register_across_a_long_gap_does_not_fan_out_into_empty_rows()
+ public void A_skipped_month_in_an_imported_table_is_shared_between_the_months_it_covers()
+ {
+ // "Januar" then "April": the 900 accrued over February, March and April.
+ var ctx = new NormalizationContext
+ {
+ Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "kWh" },
+ Readings = [Reading(1, Month(2023, 1), 1000), Reading(1, Month(2023, 4), 1900)],
+ };
+
+ var spread = _engine.Normalize(ctx).Skip(1).ToList();
+
+ Assert.Equal(["2023-02", "2023-03", "2023-04"], spread.Select(c => c.Time.UtcDateTime.ToString("yyyy-MM")));
+ Assert.Equal(900, spread.Sum(c => c.Amount), 6);
+ Assert.Equal(900 * (28d / 89d), spread[0].Amount, 6); // Feb 28 + Mar 31 + Apr 30 days
+ Assert.All(spread, c => Assert.Equal(ReadingQuality.Estimated, c.Quality));
+
+ // April's share keeps the April row's own timestamp.
+ Assert.Equal(Month(2023, 4), spread[^1].Time);
+ }
+
+ [Fact]
+ public void Attribution_preserves_the_total_and_stamps_every_share_inside_its_month()
+ {
+ var from = BerlinTime(2026, 5, 20, 7);
+ var to = BerlinTime(2026, 8, 3, 21);
+
+ var segments = GapAttribution.Attribute(from, to, to, 1000.1, Berlin);
+
+ Assert.Equal(["2026-05", "2026-06", "2026-07", "2026-08"], segments.Select(s => LocalMonth(s.Time)));
+ Assert.Equal(1000.1, segments.Sum(s => s.Amount), 9);
+ Assert.All(segments, s => Assert.True(s.Time > from && s.Time <= to));
+ Assert.Equal(segments.Count, segments.Select(s => s.Time).Distinct().Count());
+ }
+
+ [Fact]
+ public void An_unchanged_register_across_months_does_not_fan_out_into_empty_rows()
{
- // Nothing was used. Three rows of zero say no more than one, and would dilute the
- // measured/estimated ratio on the detail page.
var ctx = new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "kWh" },
@@ -166,10 +478,8 @@ public sealed class GapAttributionTests
}
[Fact]
- public void A_rejected_decrease_across_a_long_gap_stays_a_single_row()
+ public void A_rejected_decrease_across_months_stays_a_single_row()
{
- // The decrease branch already yields 0 and rebaselines; spreading that zero would invent
- // rows for months the meter never reported.
var ctx = new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "kWh" },
@@ -184,10 +494,10 @@ public sealed class GapAttributionTests
}
[Fact]
- public void A_swap_across_a_long_gap_keeps_its_explicit_amount_in_one_row()
+ public void A_swap_across_months_keeps_its_explicit_amount_in_one_row()
{
// Swap amounts are corrections booked at the event (the water …861 → 2 case reconciles to
- // 12). Apportioning one across the gap would silently rewrite a number the operator supplied.
+ // 12). Apportioning one would silently rewrite a number the operator supplied.
var ctx = new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "m³" },
@@ -197,6 +507,7 @@ public sealed class GapAttributionTests
Reading(1, Month(2023, 5), 15),
],
Events = [Swap(1, Month(2023, 3), prevValue: 861, newValue: 2, amount: 12)],
+ TimeZone = Berlin,
};
var result = _engine.Normalize(ctx).ToList();
diff --git a/tests/Core.Tests/MeterEventRulesTests.cs b/tests/Core.Tests/MeterEventRulesTests.cs
new file mode 100644
index 0000000..679cc46
--- /dev/null
+++ b/tests/Core.Tests/MeterEventRulesTests.cs
@@ -0,0 +1,46 @@
+using MeterVault.Core.Domain;
+
+namespace MeterVault.Core.Tests;
+
+///
+/// The event menu on a meter page is driven by these rules, so they pin which events each mode can
+/// actually use — an event the normalizer ignores must never be offered as if it fixed something.
+///
+public sealed class MeterEventRulesTests
+{
+ [Theory]
+ [InlineData(MeterMode.CumulativeCounter)]
+ [InlineData(MeterMode.GenerationCounter)]
+ [InlineData(MeterMode.RuntimeCounter)]
+ public void Registers_offer_swap_and_reset(MeterMode mode)
+ {
+ Assert.Equal([MeterEventType.MeterSwap, MeterEventType.CounterReset, MeterEventType.Note], MeterEventRules.RecordableFor(mode));
+ Assert.True(MeterEventRules.IsMonotonic(mode));
+ Assert.True(MeterEventRules.TakesReadings(mode));
+ }
+
+ [Fact]
+ public void A_tank_offers_level_and_delivery_and_takes_no_readings()
+ {
+ Assert.Equal([MeterEventType.TankLevel, MeterEventType.Delivery, MeterEventType.Note],
+ MeterEventRules.RecordableFor(MeterMode.ConsumableBalance));
+ Assert.False(MeterEventRules.TakesReadings(MeterMode.ConsumableBalance));
+ Assert.False(MeterEventRules.CanRecord(MeterMode.ConsumableBalance, MeterEventType.MeterSwap));
+ }
+
+ [Theory]
+ [InlineData(MeterMode.DirectDelta)]
+ [InlineData(MeterMode.InstantRate)]
+ [InlineData(MeterMode.Virtual)]
+ public void Modes_without_a_register_only_take_notes(MeterMode mode) =>
+ Assert.Equal([MeterEventType.Note], MeterEventRules.RecordableFor(mode));
+
+ [Fact]
+ public void Correction_is_never_offered_because_nothing_reads_it()
+ {
+ foreach (var mode in Enum.GetValues())
+ {
+ Assert.DoesNotContain(MeterEventType.Correction, MeterEventRules.RecordableFor(mode));
+ }
+ }
+}
diff --git a/tests/Core.Tests/RuntimeAndTankTests.cs b/tests/Core.Tests/RuntimeAndTankTests.cs
index e8b58cb..3f56cf1 100644
--- a/tests/Core.Tests/RuntimeAndTankTests.cs
+++ b/tests/Core.Tests/RuntimeAndTankTests.cs
@@ -30,6 +30,37 @@ public sealed class RuntimeAndTankTests
Assert.Equal([0d, 334d], result.Select(c => c.Amount));
}
+ [Fact]
+ public void Runtime_counter_carries_hours_across_a_replaced_counter()
+ {
+ // Burner replaced: the old counter stopped at 8000 h (last read 7952), the new one started at
+ // 0 and reads 40 h a month later. Runtime is 48 + 40 h, not a lost month.
+ var ctx = new NormalizationContext
+ {
+ Meter = new MeterConfig { MeterId = 20, Mode = MeterMode.RuntimeCounter, Unit = "h", InitialBaseline = 7785 },
+ Readings = [Reading(20, Month(2023, 2), 7952), Reading(20, Month(2023, 3), 40), Reading(20, Month(2023, 4), 100)],
+ Events = [Swap(20, Month(2023, 3), prevValue: 8000, newValue: 0)],
+ };
+
+ var result = _engine.Normalize(ctx);
+
+ Assert.Equal([167d, 88d, 60d], result.Select(c => c.Amount));
+ }
+
+ [Fact]
+ public void Runtime_counter_books_nothing_for_an_unexplained_decrease()
+ {
+ var ctx = new NormalizationContext
+ {
+ Meter = new MeterConfig { MeterId = 20, Mode = MeterMode.RuntimeCounter, Unit = "h" },
+ Readings = [Reading(20, Month(2023, 1), 100), Reading(20, Month(2023, 2), 40), Reading(20, Month(2023, 3), 50)],
+ };
+
+ var result = _engine.Normalize(ctx);
+
+ Assert.Equal([100d, 0d, 10d], result.Select(c => c.Amount));
+ }
+
[Fact]
public void Tank_consumption_is_level_delta_between_dipsticks()
{
diff --git a/tests/Core.Tests/SourceRoutingTests.cs b/tests/Core.Tests/SourceRoutingTests.cs
new file mode 100644
index 0000000..ff6e080
--- /dev/null
+++ b/tests/Core.Tests/SourceRoutingTests.cs
@@ -0,0 +1,46 @@
+using MeterVault.Core.Domain;
+
+namespace MeterVault.Core.Tests;
+
+///
+/// Which connector serves which source. The source dialog and the connector page's way back both lean
+/// on it, and a wrong answer produces a source that saves cleanly and never ingests.
+///
+public sealed class SourceRoutingTests
+{
+ [Theory]
+ [InlineData(SourceType.HomeAssistant, EndpointType.HomeAssistant)]
+ [InlineData(SourceType.Mqtt, EndpointType.MqttBroker)]
+ [InlineData(SourceType.Tasmota, EndpointType.MqttBroker)]
+ public void Live_sources_need_a_connector(SourceType source, EndpointType endpoint)
+ {
+ Assert.Equal(endpoint, SourceRouting.RequiredEndpoint(source));
+ Assert.True(SourceRouting.Serves(endpoint, source));
+ }
+
+ [Theory]
+ [InlineData(SourceType.Manual)]
+ [InlineData(SourceType.Import)]
+ [InlineData(SourceType.Virtual)]
+ public void Other_sources_need_none(SourceType source)
+ {
+ Assert.Null(SourceRouting.RequiredEndpoint(source));
+ Assert.All(Enum.GetValues(), endpoint => Assert.False(SourceRouting.Serves(endpoint, source)));
+ }
+
+ [Fact]
+ public void A_connector_never_serves_the_other_kind()
+ {
+ Assert.False(SourceRouting.Serves(EndpointType.HomeAssistant, SourceType.Tasmota));
+ Assert.False(SourceRouting.Serves(EndpointType.MqttBroker, SourceType.HomeAssistant));
+ }
+
+ [Fact]
+ public void Every_connector_kind_defaults_to_a_source_it_serves()
+ {
+ foreach (var endpoint in Enum.GetValues())
+ {
+ Assert.True(SourceRouting.Serves(endpoint, SourceRouting.DefaultSourceFor(endpoint)));
+ }
+ }
+}
diff --git a/tests/Core.Tests/TestData.cs b/tests/Core.Tests/TestData.cs
index beda949..68ced92 100644
--- a/tests/Core.Tests/TestData.cs
+++ b/tests/Core.Tests/TestData.cs
@@ -8,7 +8,21 @@ internal static class TestData
public static DateTimeOffset Month(int year, int month) =>
new(new DateTime(year, month, 1, 0, 0, 0, DateTimeKind.Utc));
+ ///
+ /// An imported reading. At midnight UTC on the 1st it is a row of a monthly table ("Mai 2026"), flagged
+ /// the way the importer flags month names — the shape of the reference data these tests model.
+ ///
public static Reading Reading(int meterId, DateTimeOffset time, double value) => new()
+ {
+ MeterId = meterId,
+ Time = time,
+ Value = value,
+ Quality = ReadingQuality.Imported,
+ Flags = time.UtcDateTime is { Day: 1, TimeOfDay.Ticks: 0 } ? ReadingFlags.MonthLabel : ReadingFlags.None,
+ };
+
+ /// An imported reading from a day-dated row ("01.08.2026"): an instant, whatever its clock time.
+ public static Reading DayReading(int meterId, DateTimeOffset time, double value) => new()
{
MeterId = meterId,
Time = time,
@@ -27,11 +41,12 @@ internal static class TestData
Amount = amount,
};
- public static MeterEvent Reset(int meterId, DateTimeOffset time, double? newValue = 0) => new()
+ public static MeterEvent Reset(int meterId, DateTimeOffset time, double? newValue = 0, double? prevValue = null) => new()
{
MeterId = meterId,
Time = time,
EventType = MeterEventType.CounterReset,
+ PrevValue = prevValue,
NewValue = newValue,
};
diff --git a/tests/Integration.Tests/CostSetupTests.cs b/tests/Integration.Tests/CostSetupTests.cs
new file mode 100644
index 0000000..1a3128a
--- /dev/null
+++ b/tests/Integration.Tests/CostSetupTests.cs
@@ -0,0 +1,33 @@
+using MeterVault.Infrastructure.Dashboard;
+
+namespace MeterVault.Integration.Tests;
+
+///
+/// The dashboard's empty state names the first missing step of the cost setup. Pure, so no Docker.
+///
+public sealed class CostSetupTests
+{
+ [Fact]
+ public void A_fresh_instance_starts_with_a_meter() =>
+ Assert.Equal(CostSetupGap.NoMeters, new CostSetup(false, false, false, false, false).FirstGap);
+
+ [Fact]
+ public void The_steps_are_reported_in_setup_order()
+ {
+ Assert.Equal(CostSetupGap.NoCategories, new CostSetup(true, false, false, false, false).FirstGap);
+ Assert.Equal(CostSetupGap.NoMembers, new CostSetup(true, true, false, false, false).FirstGap);
+ Assert.Equal(CostSetupGap.NoTariffs, new CostSetup(true, true, true, false, false).FirstGap);
+ Assert.Equal(CostSetupGap.None, new CostSetup(true, true, true, true, false).FirstGap);
+ }
+
+ [Fact]
+ public void Manual_costs_stand_in_for_meters_only_where_there_are_none()
+ {
+ Assert.Equal(CostSetupGap.None, new CostSetup(false, true, false, false, true).FirstGap);
+ Assert.Equal(CostSetupGap.NoCategories, new CostSetup(false, false, false, false, true).FirstGap);
+
+ // Old manual costs do not hide what the meters are missing.
+ Assert.Equal(CostSetupGap.NoMembers, new CostSetup(true, true, false, false, true).FirstGap);
+ Assert.Equal(CostSetupGap.NoTariffs, new CostSetup(true, true, true, false, true).FirstGap);
+ }
+}
diff --git a/tests/Integration.Tests/DashboardRenderTests.cs b/tests/Integration.Tests/DashboardRenderTests.cs
index cd7c526..df85505 100644
--- a/tests/Integration.Tests/DashboardRenderTests.cs
+++ b/tests/Integration.Tests/DashboardRenderTests.cs
@@ -1,3 +1,4 @@
+using MeterVault.App;
using MeterVault.Infrastructure.Costing;
using MeterVault.Infrastructure.Dashboard;
using MeterVault.Infrastructure.Import;
@@ -112,6 +113,44 @@ public sealed class DashboardRenderTests(TimescaleFixture fx)
var meterPage = await (await client.GetAsync(new Uri($"/meters/{hausId}", UriKind.Relative)))
.Content.ReadAsStringAsync();
Assert.Contains("Add reading", meterPage, StringComparison.Ordinal);
+ // ...and so is every other per-meter task, from the page header: recording a swap or
+ // reset, and editing the meter itself.
+ Assert.Contains("Record event", meterPage, StringComparison.Ordinal);
+ Assert.Contains("Edit meter", meterPage, StringComparison.Ordinal);
+
+ // A deep link into a tab and an action renders — the action itself only opens once the
+ // page is interactive, which a prerender request never is.
+ (await client.GetAsync(new Uri($"/meters/{hausId}?tab=events&action=swap", UriKind.Relative))).EnsureSuccessStatusCode();
+
+ // The way back from setting up a connector for a source: the connector page names the meter
+ // and links to its source dialog, and lists which meters each connector serves.
+ var connectorsForMeter = System.Net.WebUtility.HtmlDecode(await client.GetStringAsync(new Uri(
+ MeterLinks.NewConnector(hausId, sourceId: null, MeterVault.Core.Domain.SourceType.HomeAssistant, MeterVault.Core.Domain.EndpointType.HomeAssistant),
+ UriKind.Relative)));
+ Assert.Contains("Back to 'Zähler Haus'", connectorsForMeter, StringComparison.Ordinal);
+ Assert.Contains("Used by", connectorsForMeter, StringComparison.Ordinal);
+ (await client.GetAsync(new Uri(MeterLinks.Source(hausId, sourceType: MeterVault.Core.Domain.SourceType.Mqtt, connectorId: 1), UriKind.Relative)))
+ .EnsureSuccessStatusCode();
+
+ // Each import batch links the meters it wrote to.
+ var importPage = await client.GetStringAsync(new Uri("/import", UriKind.Relative));
+ Assert.Contains($"href=\"/meters/{hausId}\"", importPage, StringComparison.Ordinal);
+
+ // Regression: /trends started with its load guard set, so it never left the spinner.
+ var trends = await client.GetStringAsync(new Uri("/trends", UriKind.Relative));
+ Assert.Contains("Total over range", trends, StringComparison.Ordinal);
+
+ // A tank's page leads with the entry that drives it (a tank level), not a reading nothing reads.
+ int tankId;
+ await using (var db = fx.CreateContext())
+ {
+ tankId = await db.Meters.Where(m => m.Mode == MeterVault.Core.Domain.MeterMode.ConsumableBalance).Select(m => m.Id).FirstAsync();
+ }
+
+ var tankPage = await client.GetStringAsync(new Uri($"/meters/{tankId}", UriKind.Relative));
+ Assert.Contains("Record tank level", tankPage, StringComparison.Ordinal);
+ var consumablesPage = await client.GetStringAsync(new Uri("/consumables", UriKind.Relative));
+ Assert.Contains($"/meters/{tankId}?tab=events&action=delivery", consumablesPage, StringComparison.Ordinal);
// The same pages in German (SDD §12, M7). Real data rather than an empty instance, so
// this covers the labels that only exist once rows have rendered — the branch a
diff --git a/tests/Integration.Tests/Import/MonthLabelImportTests.cs b/tests/Integration.Tests/Import/MonthLabelImportTests.cs
new file mode 100644
index 0000000..8d4a806
--- /dev/null
+++ b/tests/Integration.Tests/Import/MonthLabelImportTests.cs
@@ -0,0 +1,50 @@
+using MeterVault.Core.Domain;
+using MeterVault.Infrastructure.Import;
+
+namespace MeterVault.Integration.Tests.Import;
+
+///
+/// The importer is the only place that still sees whether a date cell named a month or a day, so it is
+/// where a row becomes a month label. Both are stamped at midnight on the 1st; only the month means
+/// "the register at the end of that month". Pure, so no Docker.
+///
+public sealed class MonthLabelImportTests
+{
+ [Theory]
+ [InlineData(DateKind.MonthName, "August 2026", true)]
+ [InlineData(DateKind.DayDotMonthYear, "01.08.2026", false)]
+ [InlineData(DateKind.Auto, "August 2026", true)]
+ [InlineData(DateKind.Auto, "01.08.2026", false)]
+ public void A_row_is_a_month_label_only_when_its_date_named_a_month(DateKind kind, string date, bool label)
+ {
+ var reading = Assert.Single(Stage(kind, anchorToEnd: false, $"{date},700").Readings);
+
+ Assert.Equal(new DateTimeOffset(2026, 8, 1, 0, 0, 0, TimeSpan.Zero), reading.Time);
+ Assert.Equal(label, reading.Flags.HasFlag(ReadingFlags.MonthLabel));
+ }
+
+ [Fact]
+ public void A_month_anchored_to_its_last_day_is_stamped_where_it_belongs_and_is_no_label()
+ {
+ var reading = Assert.Single(Stage(DateKind.Auto, anchorToEnd: true, "August 2026,700").Readings);
+
+ Assert.Equal(new DateTimeOffset(2026, 8, 31, 0, 0, 0, TimeSpan.Zero), reading.Time);
+ Assert.False(reading.Flags.HasFlag(ReadingFlags.MonthLabel));
+ }
+
+ private static StagedImport Stage(DateKind kind, bool anchorToEnd, string row)
+ {
+ var profile = new MappingProfile
+ {
+ Name = "test",
+ DateColumn = 0,
+ DateKind = kind,
+ AnchorMonthsToEnd = anchorToEnd,
+ FirstDataRowIndex = 0,
+ Columns = [new ColumnMapping { Index = 1, Role = MappingRole.Reading, MeterId = 1, Unit = "m3" }],
+ };
+
+ using var reader = new StringReader(row);
+ return new CsvImporter().Stage(profile, reader);
+ }
+}
diff --git a/tests/Integration.Tests/Ingestion/MeterEventServiceTests.cs b/tests/Integration.Tests/Ingestion/MeterEventServiceTests.cs
new file mode 100644
index 0000000..426adc4
--- /dev/null
+++ b/tests/Integration.Tests/Ingestion/MeterEventServiceTests.cs
@@ -0,0 +1,427 @@
+using MeterVault.Core.Domain;
+using MeterVault.Core.Normalization;
+using MeterVault.Infrastructure.Ingestion;
+using MeterVault.Infrastructure.Normalization;
+using MeterVault.Infrastructure.Persistence;
+using Microsoft.EntityFrameworkCore;
+
+namespace MeterVault.Integration.Tests.Ingestion;
+
+///
+/// Recording meter events from the meter page: what a swap, reset, tank level, delivery or note
+/// actually persists, that the derived consumption follows in the same step, and that a mistake can
+/// be taken back without leaving the series worse than before.
+///
+[Collection("Timescale")]
+public sealed class MeterEventServiceTests(TimescaleFixture fx)
+{
+ private static readonly DateTimeOffset Yesterday = new(2026, 9, 16, 18, 0, 0, TimeSpan.Zero);
+ private static readonly DateTimeOffset SwapAt = new(2026, 9, 17, 9, 30, 0, TimeSpan.Zero);
+
+ [Fact]
+ public async Task A_swap_recorded_today_books_the_old_tail_and_lets_the_new_register_count_on()
+ {
+ await using var db = fx.CreateContext();
+ var meterId = await CreateMeterAsync(db, MeterMode.CumulativeCounter, "m3");
+ var ingestion = NewIngestion(db);
+ await ingestion.IngestByMeterAsync(meterId, Yesterday.AddDays(-30), 848, quality: ReadingQuality.Manual);
+ await ingestion.IngestByMeterAsync(meterId, Yesterday, 861, quality: ReadingQuality.Manual);
+
+ var result = await NewService(db).RecordAsync(meterId,
+ new MeterEventDraft(MeterEventType.MeterSwap, SwapAt) { PrevValue = 873, NewValue = 2 });
+
+ Assert.True(result.Succeeded, result.Problem.ToString());
+
+ // The event carries both registers; the new register's start is a real, flagged manual reading.
+ var swap = await db.MeterEvents.AsNoTracking().SingleAsync(e => e.MeterId == meterId);
+ Assert.Equal((873d, 2d, "m3"), (swap.PrevValue!.Value, swap.NewValue!.Value, swap.Unit));
+ var start = await db.Readings.AsNoTracking().SingleAsync(r => r.MeterId == meterId && r.Time == SwapAt);
+ Assert.Equal(2d, start.Value, 9);
+ Assert.Equal(ReadingQuality.Manual, start.Quality);
+ Assert.True(start.Flags.HasFlag(ReadingFlags.MeterSwap));
+
+ // The old meter's last 12 m³ land at the swap — not a −859 anomaly, not an 871 spike.
+ var atSwap = await db.Consumption.AsNoTracking().SingleAsync(c => c.MeterId == meterId && c.Time == SwapAt);
+ Assert.Equal(12d, atSwap.Amount, 9);
+
+ // A reading of the new register is accepted and counts from its start value.
+ var next = await ingestion.IngestByMeterAsync(meterId, SwapAt.AddHours(8), 2.4, quality: ReadingQuality.Manual);
+ Assert.Equal(IngestionOutcome.Written, next);
+ var afterSwap = await db.Consumption.AsNoTracking().SingleAsync(c => c.MeterId == meterId && c.Time == SwapAt.AddHours(8));
+ Assert.Equal(0.4, afterSwap.Amount, 9);
+
+ await CleanupAsync(db, meterId);
+ }
+
+ [Fact]
+ public async Task A_new_reading_typed_at_the_swap_instant_replaces_the_start_value_and_still_reconciles()
+ {
+ // The reading dialog's "meter swapped?" hand-off: the user records the swap at the time they
+ // were typing a reading, then saves that reading at the same instant.
+ await using var db = fx.CreateContext();
+ var meterId = await CreateMeterAsync(db, MeterMode.CumulativeCounter, "m3");
+ var ingestion = NewIngestion(db);
+ await ingestion.IngestByMeterAsync(meterId, Yesterday, 861, quality: ReadingQuality.Manual);
+
+ await NewService(db).RecordAsync(meterId, new MeterEventDraft(MeterEventType.MeterSwap, SwapAt) { PrevValue = 873, NewValue = 0 });
+ var outcome = await ingestion.IngestByMeterAsync(meterId, SwapAt, 0.3, quality: ReadingQuality.Manual);
+
+ Assert.Equal(IngestionOutcome.Updated, outcome);
+ var atSwap = await db.Consumption.AsNoTracking().SingleAsync(c => c.MeterId == meterId && c.Time == SwapAt);
+ Assert.Equal(12.3, atSwap.Amount, 9); // 873 − 861, plus 0.3 on the new register
+
+ await CleanupAsync(db, meterId);
+ }
+
+ [Fact]
+ public async Task A_swap_is_refused_when_its_numbers_or_its_instant_cannot_be_right()
+ {
+ await using var db = fx.CreateContext();
+ var meterId = await CreateMeterAsync(db, MeterMode.CumulativeCounter, "m3");
+ var ingestion = NewIngestion(db);
+ await ingestion.IngestByMeterAsync(meterId, Yesterday, 861, quality: ReadingQuality.Manual);
+ var service = NewService(db);
+
+ // The old register cannot end below a reading already taken from it.
+ var below = await service.RecordAsync(meterId, new MeterEventDraft(MeterEventType.MeterSwap, SwapAt) { PrevValue = 850, NewValue = 0 });
+ Assert.Equal(MeterEventProblem.OldRegisterBelowPreviousReading, below.Problem);
+
+ // A reading already sits at that instant: the start reading would silently overwrite it.
+ var sameTime = await service.RecordAsync(meterId, new MeterEventDraft(MeterEventType.MeterSwap, Yesterday) { PrevValue = 861, NewValue = 0 });
+ Assert.Equal(MeterEventProblem.ReadingAtSameTime, sameTime.Problem);
+
+ // A tank event means nothing on a register.
+ var delivery = await service.RecordAsync(meterId, new MeterEventDraft(MeterEventType.Delivery, SwapAt) { Amount = 100 });
+ Assert.Equal(MeterEventProblem.NotRecordableForMode, delivery.Problem);
+
+ Assert.False(await db.MeterEvents.AnyAsync(e => e.MeterId == meterId));
+
+ // A second swap between the same two readings would never be applied.
+ Assert.True((await service.RecordAsync(meterId, new MeterEventDraft(MeterEventType.MeterSwap, SwapAt) { PrevValue = 873, NewValue = 0 })).Succeeded);
+ var context = await service.GetContextAsync(meterId, SwapAt.AddMinutes(-10));
+ Assert.True(context!.BoundaryInWindow);
+ var duplicate = await service.RecordAsync(meterId, new MeterEventDraft(MeterEventType.MeterSwap, SwapAt.AddMinutes(-10)) { PrevValue = 873, NewValue = 0 });
+ Assert.Equal(MeterEventProblem.BoundaryAlreadyRecorded, duplicate.Problem);
+
+ // ...but a genuine later swap, after the new register has been read, is fine.
+ await ingestion.IngestByMeterAsync(meterId, SwapAt.AddDays(10), 5, quality: ReadingQuality.Manual);
+ var later = await service.RecordAsync(meterId, new MeterEventDraft(MeterEventType.MeterSwap, SwapAt.AddDays(20)) { PrevValue = 9, NewValue = 0 });
+ Assert.True(later.Succeeded, later.Problem.ToString());
+
+ await CleanupAsync(db, meterId);
+ }
+
+ [Fact]
+ public async Task A_reset_with_the_last_register_value_keeps_the_stretch_before_it()
+ {
+ await using var db = fx.CreateContext();
+ var meterId = await CreateMeterAsync(db, MeterMode.CumulativeCounter, "kWh");
+ var ingestion = NewIngestion(db);
+ await ingestion.IngestByMeterAsync(meterId, Yesterday, 99_990, quality: ReadingQuality.Manual);
+
+ var result = await NewService(db).RecordAsync(meterId,
+ new MeterEventDraft(MeterEventType.CounterReset, SwapAt) { PrevValue = 99_999, NewValue = 0 });
+
+ Assert.True(result.Succeeded, result.Problem.ToString());
+ var start = await db.Readings.AsNoTracking().SingleAsync(r => r.MeterId == meterId && r.Time == SwapAt);
+ Assert.True(start.Flags.HasFlag(ReadingFlags.CounterReset));
+ var atReset = await db.Consumption.AsNoTracking().SingleAsync(c => c.MeterId == meterId && c.Time == SwapAt);
+ Assert.Equal(9d, atReset.Amount, 9);
+
+ await CleanupAsync(db, meterId);
+ }
+
+ [Fact]
+ public async Task Tank_levels_and_deliveries_drive_the_tank_and_centimetres_need_a_calibration()
+ {
+ await using var db = fx.CreateContext();
+ var meterId = await CreateMeterAsync(db, MeterMode.ConsumableBalance, "L");
+ var service = NewService(db);
+
+ // No tank yet: a dipstick reading in cm cannot be turned into litres.
+ var uncalibrated = await service.RecordAsync(meterId, new MeterEventDraft(MeterEventType.TankLevel, Yesterday) { Amount = 50, Unit = "cm" });
+ Assert.Equal(MeterEventProblem.LevelNeedsCalibration, uncalibrated.Problem);
+
+ db.Tanks.Add(new Tank
+ {
+ MeterId = meterId,
+ Capacity = 7000,
+ Unit = "L",
+ Calibration = MeterConfigFactory.SerializeCalibration(new CalibrationCurve(7000d / 150d)),
+ });
+ await db.SaveChangesAsync();
+
+ Assert.True((await service.RecordAsync(meterId, new MeterEventDraft(MeterEventType.TankLevel, Yesterday.AddDays(-60)) { Amount = 50, Unit = "cm" })).Succeeded);
+ Assert.Equal(MeterEventProblem.AmountOutOfRange,
+ (await service.RecordAsync(meterId, new MeterEventDraft(MeterEventType.Delivery, Yesterday.AddDays(-30)) { Amount = 0 })).Problem);
+ Assert.True((await service.RecordAsync(meterId, new MeterEventDraft(MeterEventType.Delivery, Yesterday.AddDays(-30)) { Amount = 2000 })).Succeeded);
+
+ var context = await service.GetContextAsync(meterId, Yesterday);
+ Assert.Equal(2333.33, context!.LastLevel!.Volume, 1);
+ Assert.Equal(2000d, context.DeliveredSinceLastLevel, 9);
+ Assert.Equal(600d, context.UsedSinceLastLevel(context.ToVolume(80, centimetres: true))!.Value, 1);
+
+ Assert.True((await service.RecordAsync(meterId, new MeterEventDraft(MeterEventType.TankLevel, Yesterday) { Amount = 80, Unit = "cm" })).Succeeded);
+
+ // 2333.3 L + 2000 L delivered − 3733.3 L now = 600 L drawn, booked at the new level.
+ var drawn = await db.Consumption.AsNoTracking().SingleAsync(c => c.MeterId == meterId);
+ Assert.Equal(600d, drawn.Amount, 1);
+ Assert.Equal(Yesterday, drawn.Time);
+ var delivery = await db.MeterEvents.AsNoTracking().SingleAsync(e => e.MeterId == meterId && e.EventType == MeterEventType.Delivery);
+ Assert.Equal("L", delivery.Unit);
+
+ await db.Tanks.Where(t => t.MeterId == meterId).ExecuteDeleteAsync();
+ await CleanupAsync(db, meterId);
+ }
+
+ [Fact]
+ public async Task A_note_needs_text_and_is_offered_on_every_mode()
+ {
+ await using var db = fx.CreateContext();
+ var meterId = await CreateMeterAsync(db, MeterMode.InstantRate, "W");
+ var service = NewService(db);
+
+ Assert.Equal(MeterEventProblem.NoteRequired,
+ (await service.RecordAsync(meterId, new MeterEventDraft(MeterEventType.Note, SwapAt) { Notes = " " })).Problem);
+ Assert.Equal(MeterEventProblem.NotRecordableForMode,
+ (await service.RecordAsync(meterId, new MeterEventDraft(MeterEventType.MeterSwap, SwapAt) { NewValue = 0 })).Problem);
+
+ var note = await service.RecordAsync(meterId, new MeterEventDraft(MeterEventType.Note, SwapAt) { Notes = " Sensor moved to the new fuse box " });
+
+ Assert.True(note.Succeeded);
+ Assert.Equal("Sensor moved to the new fuse box", (await db.MeterEvents.AsNoTracking().SingleAsync(e => e.Id == note.EventId)).Notes);
+
+ await CleanupAsync(db, meterId);
+ }
+
+ [Fact]
+ public async Task Deleting_a_swap_takes_its_start_reading_along_and_restores_the_series()
+ {
+ await using var db = fx.CreateContext();
+ var meterId = await CreateMeterAsync(db, MeterMode.CumulativeCounter, "m3");
+ var ingestion = NewIngestion(db);
+ await ingestion.IngestByMeterAsync(meterId, Yesterday, 861, quality: ReadingQuality.Manual);
+ var service = NewService(db);
+ var before = await ConsumptionAsync(db, meterId);
+
+ var swap = await service.RecordAsync(meterId, new MeterEventDraft(MeterEventType.MeterSwap, SwapAt) { PrevValue = 875, NewValue = 0 });
+ var deleted = await service.DeleteEventAsync(meterId, swap.EventId!.Value);
+
+ Assert.True(deleted.Succeeded);
+ Assert.False(await db.MeterEvents.AnyAsync(e => e.MeterId == meterId));
+ Assert.False(await db.Readings.AnyAsync(r => r.MeterId == meterId && r.Time == SwapAt));
+ Assert.Equal(before, await ConsumptionAsync(db, meterId));
+
+ // Re-recording it correctly works straight away — nothing was left behind to trip over.
+ Assert.True((await service.RecordAsync(meterId, new MeterEventDraft(MeterEventType.MeterSwap, SwapAt) { PrevValue = 873, NewValue = 0 })).Succeeded);
+
+ await CleanupAsync(db, meterId);
+ }
+
+ [Fact]
+ public async Task Deleting_a_swap_keeps_a_real_reading_later_typed_at_the_same_instant()
+ {
+ await using var db = fx.CreateContext();
+ var meterId = await CreateMeterAsync(db, MeterMode.CumulativeCounter, "m3");
+ var ingestion = NewIngestion(db);
+ await ingestion.IngestByMeterAsync(meterId, Yesterday, 861, quality: ReadingQuality.Manual);
+ var service = NewService(db);
+
+ var swap = await service.RecordAsync(meterId, new MeterEventDraft(MeterEventType.MeterSwap, SwapAt) { PrevValue = 873, NewValue = 0 });
+ await ingestion.IngestByMeterAsync(meterId, SwapAt, 0.3, quality: ReadingQuality.Manual);
+ await service.DeleteEventAsync(meterId, swap.EventId!.Value);
+
+ Assert.True(await db.Readings.AnyAsync(r => r.MeterId == meterId && r.Time == SwapAt));
+
+ await CleanupAsync(db, meterId);
+ }
+
+ [Fact]
+ public async Task Imported_events_and_non_manual_readings_are_not_deletable_here()
+ {
+ await using var db = fx.CreateContext();
+ var meterId = await CreateMeterAsync(db, MeterMode.CumulativeCounter, "m3");
+ var batch = new ImportBatch { SourceName = "test.csv", CreatedAt = DateTimeOffset.UtcNow };
+ db.ImportBatches.Add(batch);
+ await db.SaveChangesAsync();
+ var imported = new MeterEvent { MeterId = meterId, Time = Yesterday, EventType = MeterEventType.MeterSwap, PrevValue = 1, NewValue = 0, ImportBatchId = batch.Id };
+ db.MeterEvents.Add(imported);
+ db.Readings.Add(new Reading { MeterId = meterId, Time = SwapAt, Value = 5, Quality = ReadingQuality.Measured });
+ await db.SaveChangesAsync();
+ var service = NewService(db);
+
+ Assert.Equal(MeterEventProblem.Imported, (await service.DeleteEventAsync(meterId, imported.Id)).Problem);
+ Assert.Equal(MeterEventProblem.NotManual, (await service.DeleteManualReadingAsync(meterId, SwapAt)).Problem);
+ Assert.Equal(MeterEventProblem.NotFound, (await service.DeleteEventAsync(meterId + 100_000, imported.Id)).Problem);
+
+ await db.MeterEvents.Where(e => e.MeterId == meterId).ExecuteDeleteAsync();
+ await db.ImportBatches.Where(b => b.Id == batch.Id).ExecuteDeleteAsync();
+ await CleanupAsync(db, meterId);
+ }
+
+ [Fact]
+ public async Task A_mistyped_manual_reading_can_be_deleted_and_the_next_one_is_accepted_again()
+ {
+ await using var db = fx.CreateContext();
+ var meterId = await CreateMeterAsync(db, MeterMode.CumulativeCounter, "m3");
+ var ingestion = NewIngestion(db);
+ await ingestion.IngestByMeterAsync(meterId, Yesterday, 1873.4, quality: ReadingQuality.Manual);
+ await ingestion.IngestByMeterAsync(meterId, Yesterday.AddHours(1), 18734, quality: ReadingQuality.Manual); // typo
+ Assert.Equal(IngestionOutcome.RejectedDecrease,
+ await ingestion.IngestByMeterAsync(meterId, Yesterday.AddHours(2), 1874, quality: ReadingQuality.Manual));
+
+ var deleted = await NewService(db).DeleteManualReadingAsync(meterId, Yesterday.AddHours(1));
+
+ Assert.True(deleted.Succeeded);
+ Assert.DoesNotContain(await ConsumptionAsync(db, meterId), c => c > 10_000);
+ Assert.Equal(IngestionOutcome.Written,
+ await ingestion.IngestByMeterAsync(meterId, Yesterday.AddHours(2), 1874, quality: ReadingQuality.Manual));
+
+ await CleanupAsync(db, meterId);
+ }
+
+ [Fact]
+ public async Task The_context_warns_about_readings_after_a_backdated_event_and_live_sources()
+ {
+ await using var db = fx.CreateContext();
+ var meterId = await CreateMeterAsync(db, MeterMode.CumulativeCounter, "m3");
+ var ingestion = NewIngestion(db);
+ await ingestion.IngestByMeterAsync(meterId, Yesterday, 861, quality: ReadingQuality.Manual);
+ await ingestion.IngestByMeterAsync(meterId, SwapAt.AddHours(1), 862, quality: ReadingQuality.Manual);
+ await ingestion.IngestByMeterAsync(meterId, SwapAt.AddHours(2), 863, quality: ReadingQuality.Manual);
+ db.MeterSources.Add(new MeterSource { MeterId = meterId, SourceType = SourceType.HomeAssistant, IsEnabled = true });
+ db.MeterSources.Add(new MeterSource { MeterId = meterId, SourceType = SourceType.Manual, IsEnabled = true });
+ await db.SaveChangesAsync();
+
+ var context = await NewService(db).GetContextAsync(meterId, SwapAt);
+
+ Assert.NotNull(context);
+ Assert.Equal(new ReadingPoint(Yesterday, 861), context.Previous);
+ Assert.Equal(SwapAt.AddHours(1), context.Next!.Time);
+ Assert.Equal(2, context.ReadingsAfter);
+ Assert.False(context.ReadingAtTime);
+ Assert.Equal(1, context.LiveSources); // manual sources do not keep feeding the old register
+ Assert.Equal(12d, context.Tail(873)!.Value, 9);
+
+ await db.MeterSources.Where(s => s.MeterId == meterId).ExecuteDeleteAsync();
+ await CleanupAsync(db, meterId);
+ }
+
+ [Fact]
+ public async Task A_second_tank_level_at_the_same_instant_is_refused_instead_of_crashing_the_recompute()
+ {
+ await using var db = fx.CreateContext();
+ var meterId = await CreateMeterAsync(db, MeterMode.ConsumableBalance, "L");
+ var service = NewService(db);
+
+ Assert.True((await service.RecordAsync(meterId, new MeterEventDraft(MeterEventType.TankLevel, Yesterday.AddDays(-30)) { Amount = 3000 })).Succeeded);
+ Assert.True((await service.RecordAsync(meterId, new MeterEventDraft(MeterEventType.TankLevel, SwapAt) { Amount = 2500 })).Succeeded);
+
+ // Re-entered within the same minute to fix a typo: two levels at one instant cannot both book.
+ var again = await service.RecordAsync(meterId, new MeterEventDraft(MeterEventType.TankLevel, SwapAt) { Amount = 2400 });
+
+ Assert.Equal(MeterEventProblem.LevelAtSameTime, again.Problem);
+ Assert.Equal(500d, (await db.Consumption.AsNoTracking().SingleAsync(c => c.MeterId == meterId)).Amount, 9);
+
+ await CleanupAsync(db, meterId);
+ }
+
+ [Fact]
+ public async Task A_boundary_cannot_be_removed_while_a_later_swap_was_measured_against_it()
+ {
+ await using var db = fx.CreateContext();
+ var meterId = await CreateMeterAsync(db, MeterMode.CumulativeCounter, "m3");
+ var ingestion = NewIngestion(db);
+ await ingestion.IngestByMeterAsync(meterId, Yesterday, 861, quality: ReadingQuality.Manual);
+ var service = NewService(db);
+
+ // A reset, then — a minute later, with no reading in between — a swap whose old register is
+ // measured from the reset's start reading of 0.
+ var reset = await service.RecordAsync(meterId, new MeterEventDraft(MeterEventType.CounterReset, SwapAt) { PrevValue = 873, NewValue = 0 });
+ var swap = await service.RecordAsync(meterId, new MeterEventDraft(MeterEventType.MeterSwap, SwapAt.AddMinutes(1)) { PrevValue = 0, NewValue = 0 });
+ Assert.True(reset.Succeeded && swap.Succeeded);
+ var before = await ConsumptionAsync(db, meterId);
+
+ // Removing the reset (or just its start reading) would re-measure that swap from 861: −861 m³.
+ Assert.Equal(MeterEventProblem.LaterBoundaryDependsOnIt, (await service.DeleteEventAsync(meterId, reset.EventId!.Value)).Problem);
+ Assert.Equal(MeterEventProblem.LaterBoundaryDependsOnIt, (await service.DeleteManualReadingAsync(meterId, SwapAt)).Problem);
+ Assert.Equal(before, await ConsumptionAsync(db, meterId));
+ Assert.DoesNotContain(before, c => c < 0);
+
+ // Later first, then earlier: both go, and nothing negative is ever booked on the way.
+ Assert.True((await service.DeleteEventAsync(meterId, swap.EventId!.Value)).Succeeded);
+ Assert.True((await service.DeleteEventAsync(meterId, reset.EventId!.Value)).Succeeded);
+ Assert.DoesNotContain(await ConsumptionAsync(db, meterId), c => c < 0);
+
+ await CleanupAsync(db, meterId);
+ }
+
+ [Fact]
+ public async Task A_typo_cannot_be_deleted_out_from_under_a_swap_recorded_after_it()
+ {
+ await using var db = fx.CreateContext();
+ var meterId = await CreateMeterAsync(db, MeterMode.CumulativeCounter, "m3");
+ var ingestion = NewIngestion(db);
+ await ingestion.IngestByMeterAsync(meterId, Yesterday, 1873, quality: ReadingQuality.Manual);
+ await ingestion.IngestByMeterAsync(meterId, Yesterday.AddHours(1), 18734, quality: ReadingQuality.Manual); // typo
+ var service = NewService(db);
+ var swap = await service.RecordAsync(meterId, new MeterEventDraft(MeterEventType.MeterSwap, SwapAt) { PrevValue = 18734, NewValue = 0 });
+ Assert.True(swap.Succeeded);
+
+ Assert.Equal(MeterEventProblem.LaterBoundaryDependsOnIt,
+ (await service.DeleteManualReadingAsync(meterId, Yesterday.AddHours(1))).Problem);
+
+ await CleanupAsync(db, meterId);
+ }
+
+ [Fact]
+ public async Task Without_an_earlier_reading_the_old_register_is_measured_from_the_baseline()
+ {
+ await using var db = fx.CreateContext();
+ var meterId = await CreateMeterAsync(db, MeterMode.CumulativeCounter, "m3", initialBaseline: 900);
+ var service = NewService(db);
+
+ var context = await service.GetContextAsync(meterId, SwapAt);
+ Assert.Null(context!.Previous);
+ Assert.Equal(50d, context.Tail(950)!.Value, 9);
+
+ // Below the baseline would book a negative tail, exactly as below a reading would.
+ Assert.Equal(MeterEventProblem.OldRegisterBelowPreviousReading,
+ (await service.RecordAsync(meterId, new MeterEventDraft(MeterEventType.MeterSwap, SwapAt) { PrevValue = 873, NewValue = 0 })).Problem);
+
+ Assert.True((await service.RecordAsync(meterId, new MeterEventDraft(MeterEventType.MeterSwap, SwapAt) { PrevValue = 950, NewValue = 0 })).Succeeded);
+ Assert.Equal([50d], await ConsumptionAsync(db, meterId));
+
+ await CleanupAsync(db, meterId);
+ }
+
+ private static MeterEventService NewService(MeterVaultDbContext db)
+ {
+ var normalization = new NormalizationService(db, NormalizationEngine.CreateDefault());
+ return new MeterEventService(db, new IngestionService(db, normalization), normalization);
+ }
+
+ private static IngestionService NewIngestion(MeterVaultDbContext db) =>
+ new(db, new NormalizationService(db, NormalizationEngine.CreateDefault()));
+
+ private static async Task> ConsumptionAsync(MeterVaultDbContext db, int meterId) =>
+ await db.Consumption.AsNoTracking().Where(c => c.MeterId == meterId).OrderBy(c => c.Time).Select(c => c.Amount).ToListAsync();
+
+ private static async Task CreateMeterAsync(MeterVaultDbContext db, MeterMode mode, string unit, double initialBaseline = 0)
+ {
+ await DatabaseSeeder.SeedAsync(db);
+ var type = await db.EnergyTypes.FirstAsync(t => t.Key == "water");
+ var meter = new Meter { Name = $"events-{Guid.NewGuid():N}", EnergyTypeId = type.Id, Mode = mode, Unit = unit, InitialBaseline = initialBaseline };
+ db.Meters.Add(meter);
+ await db.SaveChangesAsync();
+ return meter.Id;
+ }
+
+ private static async Task CleanupAsync(MeterVaultDbContext db, int meterId)
+ {
+ await db.Readings.Where(r => r.MeterId == meterId).ExecuteDeleteAsync();
+ await db.MeterEvents.Where(e => e.MeterId == meterId).ExecuteDeleteAsync();
+ await db.Consumption.Where(c => c.MeterId == meterId).ExecuteDeleteAsync();
+ await db.Meters.Where(m => m.Id == meterId).ExecuteDeleteAsync();
+ }
+}
diff --git a/tests/Integration.Tests/Ingestion/MonthAttributionTests.cs b/tests/Integration.Tests/Ingestion/MonthAttributionTests.cs
new file mode 100644
index 0000000..074b321
--- /dev/null
+++ b/tests/Integration.Tests/Ingestion/MonthAttributionTests.cs
@@ -0,0 +1,347 @@
+using Dapper;
+using MeterVault.Core.Domain;
+using MeterVault.Core.Normalization;
+using MeterVault.Infrastructure.Costing;
+using MeterVault.Infrastructure.Ingestion;
+using MeterVault.Infrastructure.Normalization;
+using MeterVault.Infrastructure.Options;
+using MeterVault.Infrastructure.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace MeterVault.Integration.Tests.Ingestion;
+
+///
+/// Consumption between two readings shows up in the months it accrued in — checked the way the charts
+/// see it, bucketed by the database in the instance timezone — and stored data built under the old rule
+/// is rebuilt once instead of waiting for each meter's next reading.
+///
+[Collection("Timescale")]
+public sealed class MonthAttributionTests(TimescaleFixture fx)
+{
+ private static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
+
+ /// A Berlin wall-clock time as the UTC instant the database stores.
+ private static DateTimeOffset BerlinTime(int year, int month, int day, int hour) =>
+ new DateTimeOffset(new DateTime(year, month, day, hour, 0, 0), Berlin.GetUtcOffset(new DateTime(year, month, day, hour, 0, 0)))
+ .ToUniversalTime();
+
+ [Fact]
+ public async Task A_reading_six_weeks_after_the_last_fills_both_months_it_covers()
+ {
+ await using var db = fx.CreateContext();
+ var meterId = await CreateMeterAsync(db);
+ var ingestion = new IngestionService(db, BerlinNormalization(db));
+ var august1 = BerlinTime(2026, 8, 1, 9);
+ var september16 = BerlinTime(2026, 9, 16, 18);
+
+ await ingestion.IngestByMeterAsync(meterId, august1, 700, quality: ReadingQuality.Manual);
+ await ingestion.IngestByMeterAsync(meterId, september16, 746, quality: ReadingQuality.Manual);
+
+ var months = await MonthlyAsync(db, meterId);
+ var expectedAugust = 46 * ((BerlinTime(2026, 9, 1, 0) - august1) / (september16 - august1));
+
+ // August also holds the first reading's 700, counted from the meter's zero baseline.
+ Assert.Equal(700 + expectedAugust, months[new DateOnly(2026, 8, 1)], 6);
+ Assert.Equal(46 - expectedAugust, months[new DateOnly(2026, 9, 1)], 6);
+
+ await CleanupAsync(db, meterId);
+ }
+
+ [Fact]
+ public async Task Stored_consumption_from_an_older_revision_is_rebuilt_once()
+ {
+ await using var db = fx.CreateContext();
+ var meterId = await CreateMeterAsync(db);
+ db.Readings.AddRange(
+ new Reading { MeterId = meterId, Time = BerlinTime(2026, 8, 1, 9), Value = 700, Quality = ReadingQuality.Manual },
+ new Reading { MeterId = meterId, Time = BerlinTime(2026, 9, 16, 18), Value = 746, Quality = ReadingQuality.Manual });
+ // What the previous rule stored: the whole six weeks on the September reading.
+ db.Consumption.AddRange(
+ new Consumption { MeterId = meterId, Time = BerlinTime(2026, 8, 1, 9), Amount = 700, Quality = ReadingQuality.Manual },
+ new Consumption { MeterId = meterId, Time = BerlinTime(2026, 9, 16, 18), Amount = 46, Quality = ReadingQuality.Manual });
+ await SetRevisionAsync(db, "1");
+
+ var upgrade = new NormalizationUpgrade(db, BerlinNormalization(db), NullLogger.Instance);
+ var rebuilt = await upgrade.RunAsync();
+
+ Assert.True(rebuilt >= 1);
+ var months = await MonthlyAsync(db, meterId);
+ Assert.True(months[new DateOnly(2026, 8, 1)] > 700 + 30, "August did not get its share back.");
+ var stored = await db.AppSettings.AsNoTracking().SingleAsync(s => s.Key == NormalizationUpgrade.SettingKey);
+ Assert.Equal(NormalizationUpgrade.CurrentRevision.ToString(System.Globalization.CultureInfo.InvariantCulture), stored.Value);
+
+ // Up to date now: the next start does nothing.
+ Assert.Equal(0, await new NormalizationUpgrade(db, BerlinNormalization(db), NullLogger.Instance).RunAsync());
+
+ await CleanupAsync(db, meterId);
+ }
+
+ [Fact]
+ public async Task A_meter_that_cannot_be_rebuilt_neither_stops_startup_nor_the_other_meters()
+ {
+ await using var db = fx.CreateContext();
+ var good = await CreateMeterAsync(db);
+ var broken = await CreateMeterAsync(db);
+ db.Readings.AddRange(
+ new Reading { MeterId = good, Time = BerlinTime(2026, 8, 1, 9), Value = 700, Quality = ReadingQuality.Manual },
+ new Reading { MeterId = good, Time = BerlinTime(2026, 9, 16, 18), Value = 746, Quality = ReadingQuality.Manual });
+ await db.SaveChangesAsync();
+ // A mode this build cannot read: loading the meter throws, as any unexpected data would.
+ await db.Database.ExecuteSqlInterpolatedAsync($"UPDATE meter SET mode = 'FromTheFuture' WHERE id = {broken}");
+ await SetRevisionAsync(db, "1");
+
+ try
+ {
+ var rebuilt = await Upgrade(db).RunAsync();
+
+ Assert.True(rebuilt >= 1);
+ Assert.True((await MonthlyAsync(db, good)).ContainsKey(new DateOnly(2026, 8, 1)), "The healthy meter was not rebuilt.");
+ Assert.Contains(broken, await PendingAsync(db));
+
+ // The next start retries only what failed — and once it can be read, it is rebuilt.
+ Assert.Equal(0, await Upgrade(db).RunAsync());
+ await db.Database.ExecuteSqlInterpolatedAsync($"UPDATE meter SET mode = 'CumulativeCounter' WHERE id = {broken}");
+ Assert.Equal(1, await Upgrade(db).RunAsync());
+ Assert.Empty(await PendingAsync(db));
+ Assert.Equal(0, await Upgrade(db).RunAsync());
+ }
+ finally
+ {
+ await db.Database.ExecuteSqlInterpolatedAsync($"UPDATE meter SET mode = 'CumulativeCounter' WHERE id = {broken}");
+ await CleanupAsync(db, good);
+ await CleanupAsync(db, broken);
+ }
+ }
+
+ [Fact]
+ public async Task Rows_of_earlier_monthly_imports_are_marked_as_months_before_the_rebuild()
+ {
+ await using var db = fx.CreateContext();
+ var monthly = await CreateMeterAsync(db);
+ var autoMonthly = await CreateMeterAsync(db);
+ var daily = await CreateMeterAsync(db);
+ var monthBatch = new ImportBatch { SourceName = "months.csv", Mapping = """{"dateKind":"MonthName"}""" };
+ // The wizard's default: auto-detected dates, which do not say whether a row named a month or a day.
+ var autoMonthBatch = new ImportBatch { SourceName = "auto-months.csv", Mapping = """{"dateKind":"Auto"}""" };
+ var dayBatch = new ImportBatch { SourceName = "days.csv", Mapping = """{"dateKind":"Auto"}""" };
+ db.ImportBatches.AddRange(monthBatch, autoMonthBatch, dayBatch);
+ await db.SaveChangesAsync();
+
+ // As an import before revision 2 stored them: nothing but the midnight stamp on the 1st.
+ foreach (var (meter, batch) in new[] { (monthly, monthBatch.Id), (autoMonthly, autoMonthBatch.Id), (daily, dayBatch.Id) })
+ {
+ db.Readings.AddRange(
+ new Reading { MeterId = meter, Time = Utc(2026, 6, 1), Value = 100, Quality = ReadingQuality.Imported, ImportBatchId = batch },
+ new Reading { MeterId = meter, Time = Utc(2026, 7, 1), Value = 130, Quality = ReadingQuality.Imported, ImportBatchId = batch });
+ }
+
+ // A reading off the 1st gives the auto-detected batch away as day-dated.
+ db.Readings.Add(new Reading { MeterId = daily, Time = Utc(2026, 7, 15), Value = 140, Quality = ReadingQuality.Imported, ImportBatchId = dayBatch.Id });
+ await db.SaveChangesAsync();
+ db.ChangeTracker.Clear();
+ await SetRevisionAsync(db, "1");
+
+ try
+ {
+ await Upgrade(db).RunAsync();
+
+ var flags = await db.Readings.AsNoTracking()
+ .Where(r => r.MeterId == monthly || r.MeterId == autoMonthly || r.MeterId == daily)
+ .Select(r => new { r.MeterId, r.Flags })
+ .ToListAsync();
+ Assert.All(flags.Where(f => f.MeterId == monthly), f => Assert.True(f.Flags.HasFlag(ReadingFlags.MonthLabel)));
+ // Every row on the 1st across months: a monthly table, as those rows were always attributed.
+ Assert.All(flags.Where(f => f.MeterId == autoMonthly), f => Assert.True(f.Flags.HasFlag(ReadingFlags.MonthLabel)));
+ Assert.All(flags.Where(f => f.MeterId == daily), f => Assert.False(f.Flags.HasFlag(ReadingFlags.MonthLabel)));
+
+ // "Juli 2026" now books July's 30 under July, not June.
+ Assert.Equal(30, (await MonthlyAsync(db, monthly))[new DateOnly(2026, 7, 1)], 6);
+ Assert.Equal(30, (await MonthlyAsync(db, autoMonthly))[new DateOnly(2026, 7, 1)], 6);
+ }
+ finally
+ {
+ await CleanupAsync(db, monthly);
+ await CleanupAsync(db, autoMonthly);
+ await CleanupAsync(db, daily);
+ await db.ImportBatches.Where(b => b.Id == monthBatch.Id || b.Id == autoMonthBatch.Id || b.Id == dayBatch.Id).ExecuteDeleteAsync();
+ }
+ }
+
+ [Fact]
+ public async Task Costs_are_bucketed_in_the_zone_months_are_divided_in()
+ {
+ // London is an hour behind Berlin: August's share is stamped at 23:59:59 London time, which is
+ // already September in Berlin. Bucketed in a hard-coded Berlin, the fix would not show.
+ await using var db = fx.CreateContext();
+ var meterId = await CreateMeterAsync(db);
+ var london = Microsoft.Extensions.Options.Options.Create(new MeterVaultOptions { TimeZone = "Europe/London" });
+ var ingestion = new IngestionService(db, new NormalizationService(db, NormalizationEngine.CreateDefault(), london));
+
+ await ingestion.IngestByMeterAsync(meterId, Utc(2026, 8, 1).AddHours(8), 700, quality: ReadingQuality.Manual);
+ await ingestion.IngestByMeterAsync(meterId, Utc(2026, 9, 16).AddHours(17), 746, quality: ReadingQuality.Manual);
+
+ try
+ {
+ var costs = await new CostService(fx, london).GetMeterCostsAsync(meterId, Utc(2026, 7, 1), Utc(2026, 10, 1));
+ var august = Assert.Single(costs, c => c.Period == new DateOnly(2026, 8, 1));
+ var september = Assert.Single(costs, c => c.Period == new DateOnly(2026, 9, 1));
+
+ Assert.True(august.Consumption > 730, $"August holds {august.Consumption}");
+ Assert.True(september.Consumption < 16, $"September holds {september.Consumption}");
+ }
+ finally
+ {
+ await CleanupAsync(db, meterId);
+ }
+ }
+
+ [Fact]
+ public async Task A_reading_inside_an_imported_month_is_judged_against_the_month_before_it()
+ {
+ // "August 2026" = 731 is stamped on 1 August but is the register on 31 August. A photo of the meter
+ // from 20 August showing 720 is not a drop from 731.
+ await using var db = fx.CreateContext();
+ var meterId = await CreateMeterAsync(db);
+ db.Readings.AddRange(
+ new Reading { MeterId = meterId, Time = Utc(2026, 7, 1), Value = 699, Quality = ReadingQuality.Imported, Flags = ReadingFlags.MonthLabel },
+ new Reading { MeterId = meterId, Time = Utc(2026, 8, 1), Value = 731, Quality = ReadingQuality.Imported, Flags = ReadingFlags.MonthLabel });
+ await db.SaveChangesAsync();
+ db.ChangeTracker.Clear();
+ var ingestion = new IngestionService(db, BerlinNormalization(db));
+
+ try
+ {
+ Assert.Equal(IngestionOutcome.Written, await ingestion.IngestByMeterAsync(meterId, BerlinTime(2026, 8, 20, 9), 720, quality: ReadingQuality.Manual));
+ // ...while a real drop below July's register still is one.
+ Assert.Equal(IngestionOutcome.RejectedDecrease, await ingestion.IngestByMeterAsync(meterId, BerlinTime(2026, 8, 21, 9), 650, quality: ReadingQuality.Manual));
+
+ var months = await MonthlyAsync(db, meterId);
+ Assert.Equal(32, months[new DateOnly(2026, 8, 1)], 6);
+ }
+ finally
+ {
+ await CleanupAsync(db, meterId);
+ }
+ }
+
+ [Fact]
+ public async Task A_live_value_written_onto_a_month_row_turns_it_into_a_reading_at_that_instant()
+ {
+ await using var db = fx.CreateContext();
+ var meterId = await CreateMeterAsync(db);
+ db.Readings.AddRange(
+ new Reading { MeterId = meterId, Time = Utc(2026, 7, 1), Value = 699, Quality = ReadingQuality.Imported, Flags = ReadingFlags.MonthLabel },
+ new Reading { MeterId = meterId, Time = Utc(2026, 8, 1), Value = 731, Quality = ReadingQuality.Imported, Flags = ReadingFlags.MonthLabel });
+ await db.SaveChangesAsync();
+ db.ChangeTracker.Clear();
+ var ingestion = new IngestionService(db, BerlinNormalization(db));
+
+ try
+ {
+ // A hand correction keeps the month row a month row...
+ await ingestion.IngestByMeterAsync(meterId, Utc(2026, 7, 1), 700, quality: ReadingQuality.Manual);
+ // ...an API/HA value at that instant is what the register showed then.
+ await ingestion.IngestByMeterAsync(meterId, Utc(2026, 8, 1), 702);
+
+ var rows = await db.Readings.AsNoTracking().Where(r => r.MeterId == meterId).OrderBy(r => r.Time).ToListAsync();
+ Assert.True(rows[0].Flags.HasFlag(ReadingFlags.MonthLabel));
+ Assert.False(rows[1].Flags.HasFlag(ReadingFlags.MonthLabel));
+ Assert.Equal(ReadingQuality.Measured, rows[1].Quality);
+ }
+ finally
+ {
+ await CleanupAsync(db, meterId);
+ }
+ }
+
+ [Fact]
+ public async Task Behind_utc_a_period_starts_at_local_midnight_so_month_end_shares_stay_in_it()
+ {
+ // New York: December's share of a 10 December to 20 January interval is stamped at 23:59:59 on
+ // 31 December local, which is already 1 January in UTC. A year requested as UTC midnights lost it.
+ await using var db = fx.CreateContext();
+ var newYork = Microsoft.Extensions.Options.Options.Create(new MeterVaultOptions { TimeZone = "America/New_York" });
+ var type = new EnergyType { Key = $"flow-{Guid.NewGuid():N}", DisplayName = "Flow test", BaseUnit = "m3", DefaultMode = MeterMode.CumulativeCounter };
+ db.EnergyTypes.Add(type);
+ await db.SaveChangesAsync();
+ var meter = new Meter { Name = $"ny-{Guid.NewGuid():N}", EnergyTypeId = type.Id, Mode = MeterMode.CumulativeCounter, Unit = "m3" };
+ db.Meters.Add(meter);
+ await db.SaveChangesAsync();
+ var ingestion = new IngestionService(db, new NormalizationService(db, NormalizationEngine.CreateDefault(), newYork));
+ await ingestion.IngestByMeterAsync(meter.Id, new DateTimeOffset(2026, 12, 10, 17, 0, 0, TimeSpan.Zero), 100, quality: ReadingQuality.Manual);
+ await ingestion.IngestByMeterAsync(meter.Id, new DateTimeOffset(2027, 1, 20, 17, 0, 0, TimeSpan.Zero), 141, quality: ReadingQuality.Manual);
+
+ try
+ {
+ var december = await new MeterVault.Infrastructure.Dashboard.FlowService(fx, newYork)
+ .GetFlowAsync(type.Id, new DateOnly(2026, 12, 1), new DateOnly(2027, 1, 1));
+ var node = Assert.Single(december.Nodes, n => n.MeterId == meter.Id);
+
+ // The first reading's 100 plus December's 21.5 of the 41 days' 41 m3 (noon on the 10th to midnight).
+ Assert.Equal(121.5, node.Value, 3);
+ }
+ finally
+ {
+ await CleanupAsync(db, meter.Id);
+ await db.EnergyTypes.Where(t => t.Id == type.Id).ExecuteDeleteAsync();
+ }
+ }
+
+ private static DateTimeOffset Utc(int year, int month, int day) => new(year, month, day, 0, 0, 0, TimeSpan.Zero);
+
+ private static NormalizationUpgrade Upgrade(MeterVaultDbContext db) =>
+ new(db, BerlinNormalization(db), NullLogger.Instance);
+
+ private static async Task PendingAsync(MeterVaultDbContext db)
+ {
+ var setting = await db.AppSettings.AsNoTracking().FirstOrDefaultAsync(s => s.Key == NormalizationUpgrade.PendingSettingKey);
+ return setting is null ? [] : System.Text.Json.JsonSerializer.Deserialize(setting.Value) ?? [];
+ }
+
+ private static NormalizationService BerlinNormalization(MeterVaultDbContext db) =>
+ new(db, NormalizationEngine.CreateDefault(),
+ Microsoft.Extensions.Options.Options.Create(new MeterVaultOptions { TimeZone = "Europe/Berlin" }));
+
+ private static async Task> MonthlyAsync(MeterVaultDbContext db, int meterId)
+ {
+ const string sql =
+ "SELECT (time_bucket(INTERVAL '1 month', \"time\", 'Europe/Berlin') AT TIME ZONE 'Europe/Berlin')::date AS period, " +
+ "sum(amount) AS amount FROM consumption WHERE meter_id = @meterId GROUP BY period ORDER BY period";
+ var rows = await db.Database.GetDbConnection().QueryAsync<(DateOnly Period, double Amount)>(sql, new { meterId });
+ return rows.ToDictionary(r => r.Period, r => r.Amount);
+ }
+
+ private static async Task SetRevisionAsync(MeterVaultDbContext db, string revision)
+ {
+ var setting = await db.AppSettings.FirstOrDefaultAsync(s => s.Key == NormalizationUpgrade.SettingKey);
+ if (setting is null)
+ {
+ db.AppSettings.Add(new AppSetting { Key = NormalizationUpgrade.SettingKey, Value = revision });
+ }
+ else
+ {
+ setting.Value = revision;
+ }
+
+ await db.SaveChangesAsync();
+ db.ChangeTracker.Clear();
+ }
+
+ private static async Task CreateMeterAsync(MeterVaultDbContext db)
+ {
+ await DatabaseSeeder.SeedAsync(db);
+ var type = await db.EnergyTypes.FirstAsync(t => t.Key == "water");
+ var meter = new Meter { Name = $"months-{Guid.NewGuid():N}", EnergyTypeId = type.Id, Mode = MeterMode.CumulativeCounter, Unit = "m3" };
+ db.Meters.Add(meter);
+ await db.SaveChangesAsync();
+ return meter.Id;
+ }
+
+ private static async Task CleanupAsync(MeterVaultDbContext db, int meterId)
+ {
+ await db.Readings.Where(r => r.MeterId == meterId).ExecuteDeleteAsync();
+ await db.Consumption.Where(c => c.MeterId == meterId).ExecuteDeleteAsync();
+ await db.Meters.Where(m => m.Id == meterId).ExecuteDeleteAsync();
+ }
+}
diff --git a/tests/Integration.Tests/InstanceTimeZoneTests.cs b/tests/Integration.Tests/InstanceTimeZoneTests.cs
new file mode 100644
index 0000000..e046467
--- /dev/null
+++ b/tests/Integration.Tests/InstanceTimeZoneTests.cs
@@ -0,0 +1,36 @@
+using MeterVault.Infrastructure.Options;
+
+namespace MeterVault.Integration.Tests;
+
+///
+/// The configured zone id reaches .NET and PostgreSQL alike. Pure, so no Docker.
+///
+public sealed class InstanceTimeZoneTests
+{
+ [Fact]
+ public void A_windows_zone_id_is_turned_into_the_iana_id_postgres_understands()
+ {
+ if (!TimeZoneInfo.TryFindSystemTimeZoneById("W. Europe Standard Time", out _))
+ {
+ return; // No Windows-id support on this host (no ICU): nothing to translate.
+ }
+
+ Assert.Equal("Europe/Berlin", InstanceTimeZone.Canonical("W. Europe Standard Time"));
+ }
+
+ [Theory]
+ [InlineData("Europe/Berlin")]
+ [InlineData("America/New_York")]
+ [InlineData("Not/AZone")]
+ public void Iana_and_unknown_ids_are_left_as_they_are(string id) =>
+ Assert.Equal(id, InstanceTimeZone.Canonical(id));
+
+ [Fact]
+ public void A_local_date_starts_at_its_local_midnight()
+ {
+ var berlin = InstanceTimeZone.Resolve("Europe/Berlin");
+
+ Assert.Equal(new DateTimeOffset(2026, 7, 31, 22, 0, 0, TimeSpan.Zero), InstanceTimeZone.StartOf(new DateOnly(2026, 8, 1), berlin));
+ Assert.Equal(TimeZoneInfo.Utc, InstanceTimeZone.Resolve("Not/AZone"));
+ }
+}
diff --git a/tests/Integration.Tests/LocalTimeEntryTests.cs b/tests/Integration.Tests/LocalTimeEntryTests.cs
new file mode 100644
index 0000000..19a0594
--- /dev/null
+++ b/tests/Integration.Tests/LocalTimeEntryTests.cs
@@ -0,0 +1,103 @@
+using MeterVault.App;
+using MeterVault.Core.Domain;
+
+namespace MeterVault.Integration.Tests;
+
+///
+/// Hand-entered timestamps and meter-page addresses: the pure pieces behind the reading and event
+/// dialogs and the deep links into a meter. No database, so these run without Docker.
+///
+public sealed class LocalTimeEntryTests
+{
+ private static readonly TimeZoneInfo Berlin = LocalTimeEntry.Resolve("Europe/Berlin");
+
+ [Fact]
+ public void Wall_clock_time_is_read_in_the_instance_timezone()
+ {
+ var entry = new LocalTimeEntry(Berlin) { Date = new DateTime(2026, 9, 17), TimeOfDay = new TimeSpan(9, 30, 0) };
+
+ Assert.Equal(new DateTimeOffset(2026, 9, 17, 7, 30, 0, TimeSpan.Zero), entry.Utc); // CEST = UTC+2
+ }
+
+ [Fact]
+ public void A_spring_forward_time_names_no_instant()
+ {
+ var entry = new LocalTimeEntry(Berlin) { Date = new DateTime(2026, 3, 29), TimeOfDay = new TimeSpan(2, 30, 0) };
+
+ Assert.True(entry.IsSkipped);
+ Assert.Null(entry.Utc);
+ }
+
+ [Fact]
+ public void An_ambiguous_autumn_time_resolves_to_standard_time()
+ {
+ var entry = new LocalTimeEntry(Berlin) { Date = new DateTime(2026, 10, 25), TimeOfDay = new TimeSpan(2, 30, 0) };
+
+ Assert.Equal(new DateTimeOffset(2026, 10, 25, 1, 30, 0, TimeSpan.Zero), entry.Utc); // CET = UTC+1
+ }
+
+ [Fact]
+ public void Setting_an_instant_round_trips_to_the_minute()
+ {
+ var entry = new LocalTimeEntry(Berlin);
+ entry.Set(new DateTimeOffset(2026, 9, 17, 7, 30, 42, TimeSpan.Zero));
+
+ Assert.Equal(new DateTime(2026, 9, 17), entry.Date);
+ Assert.Equal(new TimeSpan(9, 30, 0), entry.TimeOfDay);
+ Assert.Equal(new DateTimeOffset(2026, 9, 17, 7, 30, 0, TimeSpan.Zero), entry.Utc);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData("Not/AZone")]
+ public void An_unknown_timezone_falls_back_to_utc(string? id) =>
+ Assert.Equal(TimeZoneInfo.Utc, LocalTimeEntry.Resolve(id));
+
+ [Fact]
+ public void Meter_links_address_a_tab_and_an_action()
+ {
+ Assert.Equal("/meters/7", MeterLinks.Detail(7));
+ Assert.Equal("/meters/7?tab=events&action=swap", MeterLinks.Event(7, MeterEventType.MeterSwap));
+ Assert.Equal("/meters/7?tab=readings&action=reading", MeterLinks.QuickEntry(7, MeterMode.CumulativeCounter));
+ Assert.Equal("/meters/7?tab=events&action=tank-level", MeterLinks.QuickEntry(7, MeterMode.ConsumableBalance));
+ Assert.Null(MeterLinks.QuickEntry(7, MeterMode.Virtual));
+ }
+
+ [Fact]
+ public void Source_and_connector_links_carry_the_way_back()
+ {
+ Assert.Equal("/meters/7?tab=sources&action=source", MeterLinks.Source(7));
+ Assert.Equal(
+ "/meters/7?tab=sources&action=source&source=3&type=Tasmota&connector=12",
+ MeterLinks.Source(7, sourceId: 3, sourceType: SourceType.Tasmota, connectorId: 12));
+
+ Assert.Equal(
+ "/admin/connectors?new=MqttBroker&meter=7&type=Tasmota",
+ MeterLinks.NewConnector(7, sourceId: null, SourceType.Tasmota, EndpointType.MqttBroker));
+ Assert.Equal(
+ "/admin/connectors?edit=12&meter=7&source=3&type=HomeAssistant",
+ MeterLinks.EditConnector(7, sourceId: 3, SourceType.HomeAssistant, connectorId: 12));
+ }
+
+ [Fact]
+ public void Every_event_type_round_trips_through_its_action()
+ {
+ foreach (var type in Enum.GetValues())
+ {
+ Assert.Equal(type, MeterLinks.EventFor(MeterLinks.ActionFor(type)));
+ }
+
+ Assert.Null(MeterLinks.EventFor(MeterLinks.ActionReading));
+ Assert.Null(MeterLinks.EventFor(null));
+ }
+
+ [Theory]
+ [InlineData(null, 0)]
+ [InlineData("readings", 0)]
+ [InlineData("EVENTS", 2)]
+ [InlineData("sources", 4)]
+ [InlineData("nonsense", 0)]
+ public void Tab_keys_map_to_panel_indexes(string? tab, int expected) =>
+ Assert.Equal(expected, MeterLinks.TabIndex(tab));
+}
diff --git a/tests/Integration.Tests/ReadingEntryTests.cs b/tests/Integration.Tests/ReadingEntryTests.cs
index 1d3d35e..83dd78b 100644
--- a/tests/Integration.Tests/ReadingEntryTests.cs
+++ b/tests/Integration.Tests/ReadingEntryTests.cs
@@ -49,6 +49,18 @@ public sealed class ReadingEntryTests
Assert.Equal(12345.6, entry.Value!.Value, 9);
}
+ [Fact]
+ public void Prefill_keeps_every_decimal_a_sensor_reported()
+ {
+ // Rounded to three decimals, 861.1234 would prefill as 861.123 — below the stored reading, so
+ // the dialog would flag its own untouched prefill as a decrease.
+ var entry = new ReadingEntry();
+ entry.Prefill(861.1234);
+
+ Assert.Equal("861,1234", entry.Text);
+ Assert.Equal(861.1234, entry.Value!.Value, 9);
+ }
+
[Fact]
public void Prefill_does_not_invent_precision_the_meter_never_reported()
{
diff --git a/tests/Integration.Tests/Reconciliation/GapSplittingIsInertOnFixturesTests.cs b/tests/Integration.Tests/Reconciliation/GapSplittingIsInertOnFixturesTests.cs
index f2392fb..3950a4b 100644
--- a/tests/Integration.Tests/Reconciliation/GapSplittingIsInertOnFixturesTests.cs
+++ b/tests/Integration.Tests/Reconciliation/GapSplittingIsInertOnFixturesTests.cs
@@ -6,48 +6,68 @@ using static MeterVault.Integration.Tests.Reconciliation.ReconciliationSupport;
namespace MeterVault.Integration.Tests.Reconciliation;
///
-/// Gap splitting apportions a long unread stretch across the months it covers. The reference sheets
-/// are read monthly and must never trigger it, or their months would silently shift and the whole
-/// golden-fixture oracle (SDD §13) would be measuring the splitter instead of the normalizer.
+/// Month attribution divides an interval that crosses a month boundary. The reference sheets are
+/// monthly tables whose rows each carry exactly their own month, and must never be divided, or their
+/// months would silently shift and the whole golden-fixture oracle (SDD §13) would be measuring the
+/// attribution instead of the normalizer.
///
///
-/// The reconciliation suites already compare month by month, so a spurious split would surface there
-/// as a numeric failure. This asserts the mechanism directly instead of relying on that side effect:
-/// it proves the rule was evaluated against real fixture cadence and declined to fire, rather than
-/// the fixtures simply having no gaps to find.
+/// The reconciliation suites already compare month by month, so a spurious division would surface
+/// there as a numeric failure. This asserts the mechanism directly instead of relying on that side
+/// effect — and in the instance timezone, where month boundaries sit an hour or two away from the UTC
+/// midnights the importer stamps rows at.
///
public sealed class GapSplittingIsInertOnFixturesTests
{
+ private static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
+
[Theory]
[InlineData(ReferenceProfiles.Haus, MeterMode.CumulativeCounter)]
[InlineData(ReferenceProfiles.Netz, MeterMode.CumulativeCounter)]
[InlineData(ReferenceProfiles.Auto, MeterMode.CumulativeCounter)]
[InlineData(ReferenceProfiles.Solar1, MeterMode.GenerationCounter)]
[InlineData(ReferenceProfiles.Solar2, MeterMode.GenerationCounter)]
- public void Electricity_meters_produce_exactly_one_row_per_reading(int meterId, MeterMode mode)
+ public void Electricity_meters_produce_exactly_one_row_per_reading_at_that_reading(int meterId, MeterMode mode)
{
var staged = Stage(ReferenceProfiles.Electricity(), Electricity);
- var readings = staged.Readings.Count(r => r.MeterId == meterId);
+ var readings = staged.Readings.Where(r => r.MeterId == meterId).OrderBy(r => r.Time).ToList();
- var computed = Normalize(staged, new MeterConfig { MeterId = meterId, Mode = mode, Unit = "kWh" });
+ foreach (var zone in new[] { TimeZoneInfo.Utc, Berlin })
+ {
+ var computed = NormalizationEngine.CreateDefault().Normalize(new NormalizationContext
+ {
+ Meter = new MeterConfig { MeterId = meterId, Mode = mode, Unit = "kWh" },
+ Readings = readings,
+ TimeZone = zone,
+ });
- Assert.True(readings > 20, $"meter {meterId}: expected a real series, got {readings} readings.");
- Assert.Equal(readings, computed.Count);
+ Assert.True(readings.Count > 20, $"meter {meterId}: expected a real series, got {readings.Count} readings.");
+ Assert.Equal(readings.Count, computed.Count);
+ Assert.Equal(readings.Select(r => r.Time), computed.Select(c => c.Time));
+ }
}
[Fact]
- public void No_fixture_interval_is_long_enough_to_split()
+ public void No_fixture_interval_is_divided()
{
var staged = Stage(ReferenceProfiles.Electricity(), Electricity);
foreach (var group in staged.Readings.GroupBy(r => r.MeterId))
{
- var times = group.Select(r => r.Time).OrderBy(t => t).ToList();
- for (var i = 1; i < times.Count; i++)
+ var readings = group.OrderBy(r => r.Time).ToList();
+ for (var i = 1; i < readings.Count; i++)
{
- Assert.False(
- GapAttribution.ShouldSplit(times[i - 1], times[i]),
- $"meter {group.Key}: {times[i - 1]:yyyy-MM-dd} → {times[i]:yyyy-MM-dd} would be split.");
+ Assert.True(GapAttribution.IsMonthLabel(readings[i]), $"meter {group.Key}: {readings[i].Time:O} is not a month row.");
+
+ var segments = GapAttribution.Attribute(
+ GapAttribution.EffectiveTime(readings[i - 1], Berlin),
+ GapAttribution.EffectiveTime(readings[i], Berlin),
+ readings[i].Time,
+ 1,
+ Berlin);
+
+ var only = Assert.Single(segments);
+ Assert.Equal(readings[i].Time, only.Time);
}
}
}
|