Compare commits

...
2 Commits
Author SHA1 Message Date
Florian Schmidt c0f52dbb6f Release 0.3.0
version-tag / tag-if-newer (push) Successful in 7s
ci / build-test (push) Successful in 1m50s
Editing VERSION on master is what tags v0.3.0 and publishes the image, so
this is the whole change.

What an operator gets: meter swaps, resets, tank levels and deliveries
recorded from the meter page, the navigation around it, and consumption
attributed to the months it accrued in rather than to whichever reading
closed the interval.

The first start after the update re-derives all stored consumption before
the web server listens, so the instance is briefly unreachable and
historical monthly figures can shift once. README's "Upgrading to 0.3.0"
has the details and the one log line worth checking afterwards.
2026-09-17 21:09:27 +02:00
Florian Schmidt aacdc28d70 Meters: record events from the UI, and book consumption in the months it accrued in
Two threads that ended up in the same files. One is navigation: a meter
swap that happened today had no click path at all, and most per-meter
tasks were reachable only by knowing which admin page owned them. The
other is attribution: readings on 1 August and 16 September showed six
weeks of water under September and nothing under August.

Meter events from the UI

Swap, counter reset, tank level, delivery and note are recorded through
MeterEventService rather than ad-hoc inserts, so the dialog's verdict and
the saved result come from the same Validate call, and every record or
delete recomputes the meter inside one transaction. MeterEventRules
decides which events a mode offers -- a tank has no register to swap, and
Correction is offered nowhere because nothing reads it.

A swap is stored as the event at T plus a manual reading of the new
register's start value at exactly T. That pairing is the whole trick: the
boundary window is (previousReading, reading], so the old register's tail
books at T and every later reading counts from the new start. Writing the
old final value as the reading at T instead -- the obvious thing -- double
counts the tail and then rejects every reading the new register produces.
Deleting a swap removes that start reading only while it is still the
untouched start value, and only Manual readings can be deleted at all.

Navigation

The meter page is now the hub: primary entry by mode, a "Record event"
menu, and Edit through a shared MeterEditor that also owns tank setup.
Other pages link into it with MeterLinks (/meters/{id}?tab=...&action=...),
whose action is consumed once after the interactive render and dropped
from the address -- the reverse order flashes the dialog and closes it,
because a circuit's first location change dismisses every open dialog.
The app bar gains a "Find a meter" dialog with the same quick entry.

A source that has no usable connector now links to creating (or enabling)
one and comes back to the same source dialog with the connector picked
and everything typed still there; the draft survives in a circuit-scoped
DraftStore, and the way back is a meter id rather than a URL, so the page
cannot be made to redirect anywhere else. The connector list shows which
meters use each connector, import batches list the meters and categories
they wrote to, the meter editor owns the meter's own cost categories, and
the dashboard's empty cost panel names the first missing step instead of
listing every admin page.

Months

A reading is an instant, and what it measures accrued over the time since
the previous one. Booking the whole delta at the closing reading misfiles
it whenever the interval crosses a month boundary, so a plain increase is
now divided at local month boundaries in proportion to elapsed time, each
share stamped inside its month and marked estimated: the meter recorded a
total, not a shape. The parts always sum to the original.

Imported monthly tables are the exception that keeps the golden fixtures
reconciling. "Mai 2026" carries the register at the end of May but is
stamped on the 1st, so the importer -- the only place that still knows
whether the date cell named a month or a day -- flags it MonthLabel, and
the engine reads it as the end of its month. Inferring that from the
stamp instead would catch day-dated rows: a sheet with "01.08.2026" in it
is not a monthly table, and reading it as one moves two thirds of July
into August.

ReadingTimeline is the single ordering built on that: effective time,
then stamp. The register normalizers walk it, and so do the decrease
guard and the event dialog, which is what stops them disagreeing about
which reading is "previous" -- a sheet imported after live readings of the
same month used to count that month twice, and a mid-month reading below
the month's end value was rejected as a drop. A swap detected in a
monthly table applies from the start of that local month, i.e. to the
first reading in it, and a recorded start value never counts above the
reading it lands on.

Every reader buckets in the configured timezone rather than a hardcoded
one, and turns a requested date into that zone's local midnight, so the
divided shares are read back under the months they were stamped in. The
zone id is normalised to its IANA form, because .NET accepts a Windows id
that PostgreSQL will not bucket by, and both are checked at startup.

Stored consumption is derived, so a rule change reaches a meter only at
its next reading -- weeks, for a meter read monthly. NormalizationUpgrade
records the revision and zone the stored series was built with and
rebuilds everything once at startup when either differs, each meter in
its own transaction. A meter that fails is logged, kept in
normalization_pending and retried at the next start: one bad series must
never keep the application down.

What an operator sees once

Existing charts change on the first start after the update: months that
carried a neighbour's use give it back. Rows of earlier imports from
monthly tables are marked as such before anything is recomputed, and if
that marking fails nothing is rebuilt or recorded, so the upgrade simply
runs again next time rather than shifting every imported month by one. A
wizard import whose date format was left on auto-detect is treated as a
monthly table when all of its rows sit on the 1st across at least two
months -- exactly how those rows were attributed before -- and each such
batch is named in the log, because a day-dated sheet always read on the
1st looks identical; revert and re-import it with the day format if that
is what it was.

Tests: 120 unit and 230 integration, including the reference fixtures,
which still reconcile month for month.
2026-09-17 21:09:17 +02:00
77 changed files with 6873 additions and 829 deletions
+2 -2
View File
File diff suppressed because one or more lines are too long
+22 -1
View File
@@ -37,6 +37,27 @@ full design.
- **REST API + OpenAPI/Swagger**, API-key auth, reverse-proxy trust (Authelia/Traefik).
- **JSON config export/import** for portability; Docker Compose + multi-arch image.
## Upgrading to 0.3.0
This release changes where consumption lands. Back the database up first (`pg_dump`), then start the
new image once and let it finish:
- **The first start re-derives all stored consumption** before the web server listens. The app is
unreachable while it runs (Compose may report the container unhealthy after ~105 s) — let it finish
rather than killing it. Progress and any meter it could not rebuild are logged.
- **Figures change once.** Consumption between two readings is now attributed to the months it
accrued in instead of landing entirely on the later reading, so historical months and their costs
can shift; rows that had to be divided are marked *estimated*.
- **Imported monthly tables are marked as such** in place before anything is recomputed, so they keep
reconciling. If that step fails nothing is rebuilt and the whole upgrade simply runs again next
start.
- **Check the log once** for `had its dates auto-detected`: a CSV imported through the wizard with the
date format left on auto-detect is treated as a monthly table when all its rows sit on the 1st.
If such a sheet really was day-dated, revert that batch on `/import` and import it again with the
day format.
- **Rolling back** to 0.2.0 leaves the re-attributed consumption in place; it is re-derived under the
old rules only as each meter next ingests a reading.
## Quick start (Docker)
```bash
@@ -66,7 +87,7 @@ Configuration is via environment variables (`Section__Key` double-underscore map
| Variable | Purpose |
|----------|---------|
| `ConnectionStrings__Default` | PostgreSQL/Timescale connection string |
| `MeterVault__TimeZone` | Local timezone for buckets/display (default `Europe/Berlin`) |
| `MeterVault__TimeZone` | IANA timezone for buckets, display **and month attribution** (default `Europe/Berlin`). Changing it re-derives every meter's stored consumption at the next start, and historical monthly figures can shift. It must be an id both .NET and PostgreSQL know; anything else falls back to UTC and is reported in the log. |
| `MeterVault__Locale` | Default UI language, `en` or `de` (default `en`). Each visitor can switch it from the app bar; the choice is remembered in a cookie. |
| `MeterVault__ApiKeys__0` | An API key accepted on the `X-Api-Key` header |
| `MeterVault__AllowAnonymousApi` | `true` to open the REST API without a key (trusted LAN only) |
+1 -1
View File
@@ -1 +1 @@
0.2.0
0.3.0
+2
View File
@@ -30,6 +30,8 @@ services:
environment:
ConnectionStrings__Default: "Host=db;Port=5432;Database=metervault;Username=metervault;Password=${METERVAULT_DB_PASSWORD:-metervault}"
ASPNETCORE_ENVIRONMENT: Production
# Buckets, display AND month attribution. Changing it re-derives stored consumption at the next
# start; must be an IANA id both .NET and PostgreSQL know.
MeterVault__TimeZone: ${METERVAULT_TIMEZONE:-Europe/Berlin}
MeterVault__Currency: ${METERVAULT_CURRENCY:-EUR}
MeterVault__Locale: ${METERVAULT_LOCALE:-en}
+2 -2
View File
@@ -18,11 +18,11 @@
<Config Name="Database connection" Target="ConnectionStrings__Default" Default="Host=timescaledb;Port=5432;Database=metervault;Username=metervault;Password=changeme" Mode="" Description="PostgreSQL/TimescaleDB connection string" Type="Variable" Display="always" Required="true">Host=timescaledb;Port=5432;Database=metervault;Username=metervault;Password=changeme</Config>
<Config Name="Time zone" Target="MeterVault__TimeZone" Default="Europe/Berlin" Mode="" Description="IANA timezone for bucketing/display" Type="Variable" Display="always" Required="false">Europe/Berlin</Config>
<Config Name="Time zone" Target="MeterVault__TimeZone" Default="Europe/Berlin" Mode="" Description="IANA timezone for bucketing, display and month attribution. Changing it re-derives stored consumption at the next start." Type="Variable" Display="always" Required="false">Europe/Berlin</Config>
<Config Name="Language" Target="MeterVault__Locale" Default="en" Mode="" Description="Default UI language: en or de. Each visitor can switch it in the app bar." Type="Variable" Display="always" Required="false">en</Config>
<Config Name="API key" Target="MeterVault__ApiKeys__0" Default="" Mode="" Description="API key for the REST API (X-Api-Key header). Leave blank to leave the API open." Type="Variable" Display="always" Required="false" Mask="true"/>
<Config Name="API key" Target="MeterVault__ApiKeys__0" Default="" Mode="" Description="API key for the REST API (X-Api-Key header). Leave blank and the API stays closed (401)." Type="Variable" Display="always" Required="false" Mask="true"/>
<Config Name="Reverse-proxy trust" Target="MeterVault__ReverseProxyTrust" Default="false" Mode="" Description="Honour X-Forwarded-User from a trusted auth proxy" Type="Variable" Display="advanced" Required="false">false</Config>
+4 -2
View File
@@ -429,9 +429,11 @@ The key ring must be persisted outside the app directory (`MeterVault__DataProte
### 7.1 Register → consumption
For `cumulative_counter`/`generation_counter`: for each new reading, `amount = value previous_value`. Persist to `consumption`. Cross a `meter_swap` as `(old_final prev) + (curr new_initial)`; a `counter_reset` starts a fresh baseline. Ignore/annotate negative deltas that lack an explaining event (flag as anomaly).
**Gap attribution.** A delta is booked at the reading that closes it — correct at the reporting cadence, and what the reference sheets do. After a long unread stretch it misleads: 78 days of PV output arriving as one July row makes June look idle. So an interval containing **two or more complete calendar months** is apportioned across the months it covers, in proportion to elapsed time, and every row it yields is marked `quality = estimated` the meter recorded a total, not a shape.
**Month attribution.** A reading is an instant, and the consumption between two readings accrued over the time between them. Booking the whole delta at the closing reading misfiles it whenever the interval crosses a month boundary: readings on 1 August and 16 September would show six weeks of use in September and none in August. So a plain increase whose interval crosses one or more **local** month boundaries (instance timezone, §10 — the months the charts bucket by) is divided at those boundaries in proportion to elapsed time. Each share is stamped inside its month — the closing reading keeps its own timestamp for the month it falls in, other shares take the last second of their month — and a divided interval's rows are marked `quality = estimated`: the meter recorded a total, not a shape. The parts always sum to the original.
The threshold is deliberately conservative. A monthly series contains exactly one whole month per interval and is never touched, which is what keeps the golden-fixture reconciliation (§13) measuring the normalizer rather than the splitter. Counting whole months *contained* rather than boundaries *crossed* keeps the rule stable when a reading lands hours late. Swap and reset amounts are never apportioned: they are explicit corrections booked at the event. Split points are UTC, so one can sit an hour or two from a displayed month edge (§10) — immaterial when dividing a multi-month gap, and the alternative is threading a timezone through an otherwise timezone-free engine.
Imported monthly tables keep the golden fixtures reconciling (§13). A row labelled "Mai 2026" carries the register at the *end* of May and May's consumption, but is stamped 00:00 UTC on 1 May so it files under its month. The importer flags such a row `reading.flags & month_label` — only it still knows whether the date cell named a month or a day, and a day-dated "01.08.2026" at the same midnight is an ordinary instant. A month label is read as the end of its month: readings are walked in that effective order by every register normalizer and by the checks that judge a new reading against its predecessor (so a sheet imported after live readings of the same month does not count the month twice, and a mid-month reading below the month's end value is not a decrease), consecutive rows span exactly their closing month and book unchanged, a skipped month is shared between the months the gap covers, and a live reading after the last imported row counts from the end of that row's month rather than claiming it a second time. A label is stamped inside the month it names — at its own timestamp where that lies in the local month, otherwise (zones behind UTC) at the month's local start. Swap and reset amounts are never divided: they are explicit corrections booked at the event. A swap the importer detected at a month row applies from the start of that local month, i.e. to the first reading in it, and a new register's recorded start value never counts above that reading. Should two rows still land on the same instant, the engine adds them into one estimated row rather than producing a duplicate key.
Every reader that buckets consumption by month or day (cost, trends, solar, consumables, flow, meter detail) buckets in the configured instance timezone — the same zone the division uses — never a hard-coded one, and starts and ends requested periods at local midnight. The zone id is normalised to its IANA form, and one unknown to .NET or PostgreSQL is reported at startup. Because consumption is derived, a change to these rules is applied to stored data at startup: `app_setting.normalization_revision` and `normalization_zone` record the rule revision and zone the stored series was built with, and every non-virtual meter is recomputed when either differs (the first run also flags month rows of earlier monthly imports, identified from each batch's stored mapping; a batch whose dates were auto-detected counts as monthly when all its rows sit on the 1st across at least two months, which is logged; if the flagging fails, nothing is rebuilt and the upgrade is retried at the next start). A meter whose rebuild fails is logged and listed in `normalization_pending`, retried at the next start, and never stops the application from starting.
### 7.2 Runtime → consumption (burner)
For `runtime_counter`: `amount = Δhours × rate`. `rate` comes from the linked `tank`: `fixed` (nozzle spec, L/h) or `empirical` (`Δlevel ÷ Δhours` measured between deliveries/level reads — reproduce the spreadsheet's 1.87/1.94/2.92 … behaviour). Expose both; default empirical when level data exists, else fixed.
+1 -1
View File
@@ -5,7 +5,7 @@ namespace MeterVault.App.Api;
/// <summary>
/// Endpoint filter enforcing the <c>X-Api-Key</c> header against the configured keys (SDD §9).
/// When no keys are configured the API is open — intended only for local development.
/// With no keys configured the API is closed, unless <c>AllowAnonymousApi</c> opts into an open one.
/// </summary>
public sealed class ApiKeyFilter(IOptions<MeterVaultOptions> options) : IEndpointFilter
{
@@ -2,6 +2,7 @@
@using System.Globalization
@using MeterVault.App.Theme
@inject NavigationManager Navigation
@inject IDialogService DialogService
<MudThemeProvider Theme="MeterVaultTheme.Instance" @bind-IsDarkMode="_darkMode" />
<MudPopoverProvider />
@@ -15,6 +16,10 @@
<MudIcon Icon="@Icons.Material.Filled.Bolt" Class="mr-2" />
<MudText Typo="Typo.h6">MeterVault</MudText>
<MudSpacer />
<MudTooltip Text="@S.Layout_FindMeter">
<MudIconButton Icon="@Icons.Material.Filled.Search" Color="Color.Inherit" OnClick="OpenMeterSearchAsync"
aria-label="@S.Layout_FindMeter" />
</MudTooltip>
<MudTooltip Text="@S.Layout_Language">
<MudMenu Icon="@Icons.Material.Filled.Translate" Color="Color.Inherit"
AriaLabel="@S.Layout_Language" Dense="true">
@@ -63,6 +68,16 @@
// A circuit is stuck with the culture it was opened under, so changing language is a real
// navigation: the endpoint writes the cookie and forceLoad tears the circuit down so the
// reload comes back translated. Returning to the current path keeps the reader in place.
private async Task OpenMeterSearchAsync() =>
await DialogService.ShowAsync<MeterSearchDialog>(S.Layout_FindMeter, new DialogOptions
{
MaxWidth = MaxWidth.Small,
FullWidth = true,
CloseButton = true,
CloseOnEscapeKey = true,
Position = DialogPosition.TopCenter,
});
private void SwitchCulture(string culture)
{
if (culture == _current)
+51 -5
View File
@@ -1,22 +1,33 @@
@implements IDisposable
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
@inject NavigationManager Navigation
@inject NavState NavState
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.EntityFrameworkCore
@using MeterVault.Core.Domain
@* Grouped by what the user came to do: look at the numbers, work with meters and data (readings,
swaps and imports all start from a meter or an import), or configure the instance. *@
<MudNavMenu>
<MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">@S.Nav_Overview</MudNavLink>
<MudNavLink Href="/trends" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.ShowChart">@S.Nav_Trends</MudNavLink>
<MudText Typo="Typo.overline" Color="Color.Secondary" Class="d-block px-4 pt-3">@S.Nav_SectionData</MudText>
<MudNavLink Href="/meters" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Speed">@S.Nav_Meters</MudNavLink>
<MudNavLink Href="/import" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.UploadFile">@S.Nav_Import</MudNavLink>
<MudText Typo="Typo.overline" Color="Color.Secondary" Class="d-block px-4 pt-3">@S.Nav_SectionEnergy</MudText>
@foreach (var type in _energyTypes)
{
<MudNavLink Href="@($"/energy/{type.Id}")" Match="NavLinkMatch.Prefix" Icon="@TypeIcon(type.Icon)">@type.DisplayName</MudNavLink>
}
<MudNavLink Href="/solar" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.WbSunny">@S.Nav_Solar</MudNavLink>
<MudNavLink Href="/consumables" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.OilBarrel">@S.Nav_Consumables</MudNavLink>
<MudNavLink Href="/meters" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Speed">@S.Nav_Meters</MudNavLink>
<MudNavLink Href="/import" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.UploadFile">@S.Nav_Import</MudNavLink>
<MudDivider Class="my-2" />
<MudNavGroup Title="@S.Nav_Admin" Icon="@Icons.Material.Filled.Settings" Expanded="false">
@* Opens by itself on an admin page — a bookmark, a reload, a language switch or a link from
elsewhere would otherwise land with the current page folded away out of sight. *@
<MudNavGroup Title="@S.Nav_Admin" Icon="@Icons.Material.Filled.Settings" @bind-Expanded="_adminExpanded">
<MudNavLink Href="/admin/energy-types" Icon="@Icons.Material.Filled.Category">@S.Nav_EnergyTypes</MudNavLink>
<MudNavLink Href="/admin/tariffs" Icon="@Icons.Material.Filled.Euro">@S.Nav_Tariffs</MudNavLink>
<MudNavLink Href="/admin/categories" Icon="@Icons.Material.Filled.Folder">@S.Nav_CostCategories</MudNavLink>
@@ -27,8 +38,17 @@
@code {
private List<EnergyType> _energyTypes = [];
private bool _adminExpanded;
protected override async Task OnInitializedAsync()
{
_adminExpanded = IsAdminPage(Navigation.Uri);
Navigation.LocationChanged += OnLocationChanged;
NavState.EnergyTypesChanged += OnEnergyTypesChanged;
await LoadEnergyTypesAsync();
}
private async Task LoadEnergyTypesAsync()
{
try
{
@@ -42,7 +62,33 @@
}
}
// Map the energy type's stored icon name to a Material icon; fall back to a generic gauge.
private bool IsAdminPage(string uri) =>
Navigation.ToBaseRelativePath(uri).StartsWith("admin/", StringComparison.OrdinalIgnoreCase);
// Opens only: collapsing it again when the user leaves is their call, not the menu's.
private void OnLocationChanged(object? sender, LocationChangedEventArgs e)
{
if (!_adminExpanded && IsAdminPage(e.Location))
{
_adminExpanded = true;
_ = InvokeAsync(StateHasChanged);
}
}
private void OnEnergyTypesChanged() =>
_ = InvokeAsync(async () =>
{
await LoadEnergyTypesAsync();
StateHasChanged();
});
public void Dispose()
{
Navigation.LocationChanged -= OnLocationChanged;
NavState.EnergyTypesChanged -= OnEnergyTypesChanged;
}
// Map the energy type's stored icon name to a Material icon; fall back to a bolt.
private static string TypeIcon(string? icon) => icon switch
{
"bolt" => Icons.Material.Filled.Bolt,
+168 -7
View File
@@ -4,6 +4,7 @@
@inject MeterVault.Infrastructure.Security.SecretProtector Secrets
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@inject NavigationManager Nav
@using Microsoft.EntityFrameworkCore
@using MeterVault.Infrastructure.Ingestion
@using MudBlazor
@@ -17,6 +18,14 @@
</MudButton>
</div>
@if (_returnTo is { } back)
{
@* Arrived from a meter's source dialog: the way back is always in view, saved or not. *@
<MudAlert Severity="Severity.Normal" Variant="Variant.Outlined" Class="mb-4" Dense="true" Icon="@Icons.Material.Filled.Sensors">
@Loc.F(S.Connectors_ForMeter, back.MeterName) <MudLink Href="@MeterLinks.Source(back.MeterId, back.SourceId, back.SourceType)">@Loc.F(S.Connectors_BackToMeter, back.MeterName)</MudLink>
</MudAlert>
}
<MudAlert Severity="Severity.Info" Class="mb-4" Dense="true">
@S.Connectors_SecretsNoticePrefix <b>@S.Connectors_SecretsNoticeEnvVar</b> @S.Connectors_SecretsNoticeSuffix
</MudAlert>
@@ -32,6 +41,7 @@ else
<MudTh>@S.Common_Name</MudTh>
<MudTh>@S.Common_Type</MudTh>
<MudTh>@S.Common_Enabled</MudTh>
<MudTh>@S.Connectors_UsedBy</MudTh>
<MudTh>@S.Connectors_LastStatus</MudTh>
<MudTh>@S.Common_LastSeen</MudTh>
<MudTh Style="text-align:right">@S.Common_Actions</MudTh>
@@ -40,6 +50,28 @@ else
<MudTd DataLabel="@S.Common_Name">@context.Name</MudTd>
<MudTd DataLabel="@S.Common_Type">@context.Type.Display()</MudTd>
<MudTd DataLabel="@S.Common_Enabled">@(context.IsEnabled ? S.Connectors_Yes : S.Connectors_No)</MudTd>
<MudTd DataLabel="@S.Connectors_UsedBy">
@if (_usage.TryGetValue(context.Id, out var users))
{
@foreach (var user in users.Take(UsersShown))
{
<MudLink Href="@MeterLinks.Detail(user.MeterId, MeterLinks.TabSources)" Class="mr-2">@user.MeterName</MudLink>
}
@if (users.Count > UsersShown)
{
<MudText Typo="Typo.caption" Inline="true">@Loc.F(S.Connectors_UsedByMore, users.Count - UsersShown)</MudText>
}
@if (!context.IsEnabled)
{
@* Workers skip disabled connectors, so every source on it has stopped. *@
<MudText Typo="Typo.caption" Color="Color.Warning" Class="d-block">@S.Connectors_DisabledInUse</MudText>
}
}
else
{
<MudText Typo="Typo.body2" Color="Color.Secondary">@S.Connectors_Unused</MudText>
}
</MudTd>
<MudTd DataLabel="@S.Connectors_LastStatus">@(context.LastStatus ?? "—")</MudTd>
<MudTd DataLabel="@S.Common_LastSeen">@(context.LastSeenAt?.ToString("yyyy-MM-dd HH:mm") ?? "—")</MudTd>
<MudTd DataLabel="@S.Common_Actions" Style="text-align:right">
@@ -134,7 +166,31 @@ else
</MudDialog>
@code {
/// <summary>Opens a new connector of this <see cref="EndpointType"/> once the page is interactive.</summary>
[SupplyParameterFromQuery(Name = "new")]
public string? NewParam { get; set; }
/// <summary>Opens this connector for editing once the page is interactive.</summary>
[SupplyParameterFromQuery(Name = "edit")]
public int? EditParam { get; set; }
/// <summary>The meter whose source dialog sent the user here; see <see cref="MeterLinks.NewConnector"/>.</summary>
[SupplyParameterFromQuery(Name = "meter")]
public int? MeterParam { get; set; }
[SupplyParameterFromQuery(Name = MeterLinks.ParamSource)]
public int? SourceParam { get; set; }
[SupplyParameterFromQuery(Name = MeterLinks.ParamSourceType)]
public string? SourceTypeParam { get; set; }
private const int UsersShown = 3;
private List<IngestionEndpoint>? _endpoints;
private Dictionary<int, List<ConnectorUser>> _usage = [];
private ReturnTarget? _returnTo;
private (EndpointType? New, int? Edit)? _pendingOpen;
private bool _droppingOpen;
private bool _editOpen;
private bool _testing;
private HaTestResult? _testResult;
@@ -143,10 +199,97 @@ else
protected override Task OnInitializedAsync() => LoadAsync();
protected override async Task OnParametersSetAsync()
{
if (MeterParam != _returnTo?.MeterId)
{
_returnTo = null;
if (MeterParam is { } meterId)
{
// Resolved against the database, so the way back names a meter that exists — and only
// ever leads to a meter page, whatever the query string says.
await using var db = await DbFactory.CreateDbContextAsync();
var name = await db.Meters.AsNoTracking().Where(m => m.Id == meterId).Select(m => m.Name).FirstOrDefaultAsync();
if (name is not null)
{
_returnTo = new ReturnTarget(meterId, name, SourceParam, null);
}
}
}
if (_returnTo is not null)
{
_returnTo = _returnTo with
{
SourceId = SourceParam,
SourceType = Enum.TryParse<SourceType>(SourceTypeParam, ignoreCase: true, out var sourceType) ? sourceType : null,
};
}
if (!string.IsNullOrEmpty(NewParam) || EditParam is not null)
{
_pendingOpen = (Enum.TryParse<EndpointType>(NewParam, ignoreCase: true, out var type) ? type : null, EditParam);
}
else
{
_droppingOpen = false;
}
}
/// <summary>
/// Opens a deep-linked connector dialog. The request is dropped from the address first and the dialog
/// opened once that navigation has come back — the order the meter page uses, for the same reason: a
/// circuit's first location change dismisses any dialog already open.
/// </summary>
protected override void OnAfterRender(bool firstRender)
{
if (_pendingOpen is not { } open || _endpoints is null)
{
return;
}
if (!string.IsNullOrEmpty(NewParam) || EditParam is not null)
{
if (!_droppingOpen)
{
_droppingOpen = true;
Nav.NavigateTo(Nav.GetUriWithQueryParameters(new Dictionary<string, object?>
{
["new"] = null,
["edit"] = null,
}), replace: true);
}
return;
}
_pendingOpen = null;
if (open.Edit is { } editId && _endpoints.FirstOrDefault(e => e.Id == editId) is { } endpoint)
{
OpenEdit(endpoint);
}
else if (open.New is { } newType)
{
OpenEdit(null);
_working.Type = newType;
}
StateHasChanged();
}
private async Task LoadAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
_endpoints = await db.IngestionEndpoints.AsNoTracking().OrderBy(e => e.Name).ToListAsync();
var users = await db.MeterSources.AsNoTracking()
.Where(s => s.EndpointId != null)
.Select(s => new { EndpointId = s.EndpointId!.Value, s.MeterId, MeterName = s.Meter!.Name })
.ToListAsync();
_usage = users
.GroupBy(u => u.EndpointId)
.ToDictionary(
g => g.Key,
g => g.DistinctBy(u => u.MeterId).OrderBy(u => u.MeterName).Select(u => new ConnectorUser(u.MeterId, u.MeterName)).ToList());
}
private void OpenEdit(IngestionEndpoint? endpoint)
@@ -282,25 +425,38 @@ else
}.ToJson();
await using var db = await DbFactory.CreateDbContextAsync();
IngestionEndpoint saved;
if (_working.Id == 0)
{
db.IngestionEndpoints.Add(new IngestionEndpoint
saved = new IngestionEndpoint
{
Type = _working.Type, Name = _working.Name.Trim(), Config = config, IsEnabled = _working.IsEnabled,
});
};
db.IngestionEndpoints.Add(saved);
}
else
{
var existing = await db.IngestionEndpoints.FirstAsync(e => e.Id == _working.Id);
existing.Type = _working.Type;
existing.Name = _working.Name.Trim();
existing.Config = config;
existing.IsEnabled = _working.IsEnabled;
saved = await db.IngestionEndpoints.FirstAsync(e => e.Id == _working.Id);
saved.Type = _working.Type;
saved.Name = _working.Name.Trim();
saved.Config = config;
saved.IsEnabled = _working.IsEnabled;
}
await db.SaveChangesAsync();
_editOpen = false;
Snackbar.Add(S.Common_Saved, Severity.Success);
// Back to the source that was waiting for this connector, with it picked — when it can serve that
// source. Anything else (disabled, or of another kind) keeps the user here, the way back in view.
if (_returnTo is { } back
&& saved.IsEnabled
&& (back.SourceType is not { } sourceType || SourceRouting.Serves(saved.Type, sourceType)))
{
Nav.NavigateTo(MeterLinks.Source(back.MeterId, back.SourceId, back.SourceType, saved.Id));
return;
}
await LoadAsync();
}
@@ -348,6 +504,11 @@ else
private static IReadOnlyList<string> SplitTopics(string? csv) =>
string.IsNullOrWhiteSpace(csv) ? [] : [.. csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)];
private sealed record ConnectorUser(int MeterId, string MeterName);
/// <summary>The meter source a user came here to set up a connector for.</summary>
private sealed record ReturnTarget(int MeterId, string MeterName, int? SourceId, SourceType? SourceType);
private sealed class EditModel
{
public int Id { get; set; }
@@ -2,6 +2,7 @@
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@inject NavState NavState
@using Microsoft.EntityFrameworkCore
@using MudBlazor
@@ -142,6 +143,7 @@ else
await db.SaveChangesAsync();
_editOpen = false;
Snackbar.Add(S.Common_Saved, Severity.Success);
NavState.NotifyEnergyTypesChanged();
await LoadAsync();
}
@@ -166,6 +168,7 @@ else
db.EnergyTypes.Remove(target);
await db.SaveChangesAsync();
Snackbar.Add(S.Common_Deleted, Severity.Success);
NavState.NotifyEnergyTypesChanged();
}
await LoadAsync();
+35 -5
View File
@@ -1,5 +1,6 @@
@page "/consumables"
@inject ConsumableService ConsumablesSvc
@inject Microsoft.Extensions.Options.IOptions<MeterVault.Infrastructure.Options.MeterVaultOptions> Options
@using MudBlazor
<PageTitle>MeterVault — @S.Consumables_PageTitle</PageTitle>
@@ -14,14 +15,26 @@
</MudSelect>
</div>
@* A consumable meter without a tank has no capacity or calibration, so it cannot be summarised —
but it must not just be missing from the page, with nothing saying where it went. *@
@foreach (var meter in _unconfigured)
{
<MudAlert Severity="Severity.Warning" Class="mb-3">
@Loc.F(S.Consumables_TankNotConfigured, meter.Name)
<MudButton Size="Size.Small" Variant="Variant.Text" Color="Color.Warning" Class="ml-2"
Href="@MeterLinks.Detail(meter.MeterId, action: MeterLinks.ActionEdit)">@S.MeterDetail_SetUpTank</MudButton>
</MudAlert>
}
@if (_items is null)
{
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
}
else if (_items.Count == 0)
else if (_items.Count == 0 && _unconfigured.Count == 0)
{
<MudAlert Severity="Severity.Info">
@S.Consumables_NoMetersLead <b>@MeterMode.ConsumableBalance.Display()</b> @S.Consumables_NoMetersTail
<MudLink Href="/meters">@S.Nav_Meters</MudLink>@S.Consumables_NoMetersOrImport
<MudLink Href="/import">@S.Nav_Import</MudLink>.
</MudAlert>
}
@@ -30,7 +43,15 @@ else
@foreach (var item in _items)
{
<MudPaper Class="pa-4 mb-4" Elevation="2">
<MudText Typo="Typo.h6" Class="mb-3">@item.Name</MudText>
@* The actions for a tank are the ones done standing next to it, so they sit on its card. *@
<div class="d-flex align-center flex-wrap mb-3" style="gap:.5rem">
<MudLink Href="@MeterLinks.Detail(item.MeterId)" Typo="Typo.h6">@item.Name</MudLink>
<MudSpacer />
<MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Straighten"
Href="@MeterLinks.Event(item.MeterId, MeterEventType.TankLevel)">@S.MeterDetail_RecordTankLevel</MudButton>
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Primary" StartIcon="@Icons.Material.Filled.LocalShipping"
Href="@MeterLinks.Event(item.MeterId, MeterEventType.Delivery)">@S.Consumables_RecordDelivery</MudButton>
</div>
<MudGrid>
<MudItem xs="12" md="4">
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.Consumables_TankLevel</MudText>
@@ -46,7 +67,7 @@ else
}
@if (item.LevelAsOf is { } asOf)
{
<text> · @Loc.F(S.Consumables_AsOf, asOf.ToString("yyyy-MM-dd"))</text>
<text> · @Loc.F(S.Consumables_AsOf, Local(asOf).ToString("yyyy-MM-dd"))</text>
}
</MudText>
</MudItem>
@@ -120,7 +141,7 @@ else
@foreach (var delivery in item.Deliveries)
{
<tr>
<td>@delivery.Time.ToString("yyyy-MM-dd")</td>
<td>@Local(delivery.Time).ToString("yyyy-MM-dd")</td>
<td style="text-align:right">@Format.Number(delivery.Amount, 0) @(delivery.Unit ?? item.Unit)</td>
</tr>
}
@@ -138,8 +159,16 @@ else
private int _months = 60;
private bool _loading;
private IReadOnlyList<ConsumableSummary>? _items;
private IReadOnlyList<UnconfiguredConsumable> _unconfigured = [];
private TimeZoneInfo _tz = TimeZoneInfo.Utc;
protected override Task OnInitializedAsync() => LoadAsync();
protected override Task OnInitializedAsync()
{
_tz = LocalTimeEntry.Resolve(Options.Value.TimeZone);
return LoadAsync();
}
private DateTimeOffset Local(DateTimeOffset instant) => TimeZoneInfo.ConvertTime(instant, _tz);
private async Task OnRangeChanged(int months)
{
@@ -160,6 +189,7 @@ else
{
var asOf = DateOnly.FromDateTime(DateTime.UtcNow);
var from = asOf.AddMonths(-_months);
_unconfigured = await ConsumablesSvc.GetUnconfiguredAsync();
_items = await ConsumablesSvc.GetConsumablesAsync(new DateOnly(from.Year, from.Month, 1), asOf.AddMonths(1));
}
finally
+26 -1
View File
@@ -55,7 +55,27 @@ else
}
else
{
<MudText Typo="Typo.body2" Color="Color.Secondary">@S.Dashboard_NoCostData</MudText>
@* Names the one step that is missing, in setup order, rather than every admin page. *@
<MudText Typo="Typo.body2" Color="Color.Secondary">
@switch (_setup?.FirstGap)
{
case CostSetupGap.NoMeters:
<span>@S.Dashboard_SetupNoMeters <MudLink Href="/meters">@S.Nav_Meters</MudLink> · <MudLink Href="/import">@S.Nav_Import</MudLink></span>
break;
case CostSetupGap.NoCategories:
<span>@S.Dashboard_SetupNoCategories <MudLink Href="/admin/categories">@S.Nav_CostCategories</MudLink></span>
break;
case CostSetupGap.NoMembers:
<span>@S.Dashboard_SetupNoMembers <MudLink Href="/meters">@S.Nav_Meters</MudLink> · <MudLink Href="/admin/categories">@S.Nav_CostCategories</MudLink></span>
break;
case CostSetupGap.NoTariffs:
<span>@S.Dashboard_SetupNoTariffs <MudLink Href="/admin/tariffs">@S.Nav_Tariffs</MudLink></span>
break;
default:
<span>@S.Dashboard_SetupNoCostsThisYear</span>
break;
}
</MudText>
}
</MudPaper>
</MudItem>
@@ -90,6 +110,7 @@ else
private DashboardSummary? _summary;
private IReadOnlyList<CategorySlice> _breakdown = [];
private IReadOnlyList<DifferenceRow> _difference = [];
private CostSetup? _setup;
protected override async Task OnInitializedAsync()
{
@@ -98,6 +119,10 @@ else
var yearStart = new DateOnly(asOf.Year, 1, 1);
_breakdown = await Dash.GetCategoryBreakdownAsync(yearStart, asOf.AddMonths(1));
if (_breakdown.Count == 0)
{
_setup = await Dash.GetCostSetupAsync();
}
_difference = await Dash.GetCategoryDifferenceAsync(yearStart, yearStart.AddYears(-1), asOf.AddMonths(1));
}
}
+30 -6
View File
@@ -2,6 +2,7 @@
@inject FlowService Flow
@inject MeterVault.Infrastructure.Costing.CostService Costs
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
@inject Microsoft.Extensions.Options.IOptions<MeterVault.Infrastructure.Options.MeterVaultOptions> Options
@using Microsoft.EntityFrameworkCore
@using MudBlazor
@@ -21,7 +22,7 @@
{
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
}
else if (!_graph.HasData)
else if (_meters.Count == 0)
{
<MudAlert Severity="Severity.Info">
@S.EnergyView_NoMetersIntro <MudLink Href="/meters">@S.Nav_Meters</MudLink>@S.EnergyView_NoMetersOr
@@ -29,6 +30,14 @@ else if (!_graph.HasData)
</MudAlert>
}
else
{
@* Meters without consumption in the range still exist — a new one, or a quiet stretch — and this
page is a natural way in to them, so the list below always renders; only the figures wait. *@
@if (!_graph.HasData)
{
<MudAlert Severity="Severity.Info" Class="mb-4">@S.EnergyView_NoDataInRange</MudAlert>
}
else
{
<MudGrid Class="mb-2">
<MudItem xs="12" sm="4">
@@ -83,19 +92,30 @@ else
}
}
</MudPaper>
}
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.h6" Class="mb-2">@S.Common_Meters</MudText>
<MudSimpleTable Dense="true" Hover="true">
<thead><tr><th>@S.Common_Name</th><th>@S.Common_Mode</th><th>@S.EnergyView_ColUpstreamOf</th><th style="text-align:right">@S.EnergyView_ColConsumption</th></tr></thead>
<thead><tr><th>@S.Common_Name</th><th>@S.Common_Mode</th><th>@S.EnergyView_ColUpstreamOf</th><th style="text-align:right">@S.EnergyView_ColConsumption</th><th></th></tr></thead>
<tbody>
@foreach (var meter in _meters)
{
<tr>
<td><MudLink Href="@($"/meters/{meter.Id}")">@meter.Name</MudLink></td>
<td><MudLink Href="@MeterLinks.Detail(meter.Id)">@meter.Name</MudLink></td>
<td>@meter.Mode.Display()</td>
<td>@UpstreamLabel(meter.Id)</td>
<td style="text-align:right">@Format.Number(NodeValue(meter.Id), 0) @_graph.Unit</td>
<td style="text-align:right">
@if (MeterLinks.QuickEntry(meter.Id, meter.Mode) is { } entry)
{
<MudTooltip Text="@(meter.Mode == MeterMode.ConsumableBalance ? S.MeterDetail_RecordTankLevel : S.MeterDetail_AddReading)">
<MudIconButton Icon="@(meter.Mode == MeterMode.ConsumableBalance ? Icons.Material.Filled.Straighten : Icons.Material.Filled.EditNote)"
Size="Size.Small" Color="Color.Primary" Href="@entry"
aria-label="@(meter.Mode == MeterMode.ConsumableBalance ? S.MeterDetail_RecordTankLevel : S.MeterDetail_AddReading)" />
</MudTooltip>
}
</td>
</tr>
}
</tbody>
@@ -138,7 +158,9 @@ else
var to = asOf.AddMonths(1);
var typeId = (short)Id;
_graph = await Flow.GetFlowAsync(typeId, from, to);
// Assigned last: the page branches on the graph being loaded, and must not render it
// against the previous type's (or an empty) meter list in between.
var graph = await Flow.GetFlowAsync(typeId, from, to);
await using var db = await DbFactory.CreateDbContextAsync();
_meters = await db.Meters.AsNoTracking().Where(m => m.EnergyTypeId == typeId).OrderBy(m => m.Name).ToListAsync();
@@ -150,14 +172,16 @@ else
.GroupBy(l => l.FromMeterId)
.ToDictionary(g => g.Key, g => g.Select(l => names.GetValueOrDefault(l.ToMeterId, $"#{l.ToMeterId}")).ToList());
var fromUtc = new DateTimeOffset(from.Year, from.Month, from.Day, 0, 0, 0, TimeSpan.Zero);
var toUtc = new DateTimeOffset(to.Year, to.Month, to.Day, 0, 0, 0, TimeSpan.Zero);
var zone = MeterVault.Infrastructure.Options.InstanceTimeZone.Resolve(Options.Value.TimeZone);
var fromUtc = MeterVault.Infrastructure.Options.InstanceTimeZone.StartOf(from, zone);
var toUtc = MeterVault.Infrastructure.Options.InstanceTimeZone.StartOf(to, zone);
double cost = 0;
foreach (var meter in _meters)
{
cost += (await Costs.GetMeterCostsAsync(meter.Id, fromUtc, toUtc, CostBucket.Month)).Sum(c => c.Cost);
}
_cost = cost;
_graph = graph;
}
finally
{
+82 -1
View File
@@ -90,13 +90,32 @@
else
{
<MudSimpleTable Dense="true" Hover="true">
<thead><tr><th>#</th><th>@S.Import_ColumnSource</th><th style="text-align:right">@S.Import_ColumnRows</th><th>@S.Import_ColumnImported</th><th>@S.Common_Status</th><th></th></tr></thead>
<thead><tr><th>#</th><th>@S.Import_ColumnSource</th><th>@S.Import_ColumnWritesTo</th><th style="text-align:right">@S.Import_ColumnRows</th><th>@S.Import_ColumnImported</th><th>@S.Common_Status</th><th></th></tr></thead>
<tbody>
@foreach (var batch in _batches)
{
<tr>
<td>@batch.Id</td>
<td>@(batch.SourceName ?? "—")</td>
<td>
@* 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)
{
<MudLink Href="@MeterLinks.Detail(meter.Id)" Class="mr-2">@meter.Name</MudLink>
}
@foreach (var category in targets.Categories)
{
<MudLink Href="/admin/categories" Class="mr-2" Color="Color.Secondary">@category</MudLink>
}
}
else
{
<span>—</span>
}
</td>
<td style="text-align:right">@batch.RowCount</td>
<td>@batch.CreatedAt.LocalDateTime.ToString("yyyy-MM-dd HH:mm")</td>
<td>
@@ -130,6 +149,7 @@
private string _profileName = "Strom";
private StagedImport? _preview;
private List<ImportBatch> _batches = [];
private Dictionary<int, BatchTargets> _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)]);
}
/// <summary>
/// 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.
/// </summary>
private static async Task<Dictionary<int, BatchTargets>> LoadTargetsAsync(MeterVaultDbContext db, List<int> 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<MeterTarget> Meters, IReadOnlyList<string> Categories);
private async Task RevertAsync(ImportBatch batch)
{
if (!await Confirm.ConfirmAsync(Dialogs, S.Import_RevertConfirmTitle,
File diff suppressed because it is too large Load Diff
+82 -253
View File
@@ -1,8 +1,8 @@
@page "/meters"
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> 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 @@
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
<MudText Typo="Typo.h4">@S.Common_Meters</MudText>
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => _editor!.OpenNewAsync())">
@S.Meters_AddMeter
</MudButton>
</div>
@@ -21,10 +21,19 @@
}
else
{
<MudTable Items="_meters" Dense="true" Hover="true" Elevation="2">
@* 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. *@
<MudTable Items="_meters" Dense="true" Hover="true" Elevation="2" Filter="Matches"
GroupBy="_byEnergyType" OnRowClick="@((TableRowClickEventArgs<Meter> e) => Nav.NavigateTo(MeterLinks.Detail(e.Item!.Id)))"
RowClass="cursor-pointer">
<ToolBarContent>
<MudTextField T="string" @bind-Value="_search" Immediate="true" Placeholder="@S.Meters_SearchPlaceholder"
Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Search" Clearable="true"
Class="mt-0" Style="max-width:420px" />
</ToolBarContent>
<HeaderContent>
<MudTh>@S.Common_Name</MudTh>
<MudTh>@S.Common_Type</MudTh>
<MudTh>@S.Common_Mode</MudTh>
<MudTh>@S.Common_Unit</MudTh>
<MudTh>@S.Meters_Sources</MudTh>
@@ -32,24 +41,53 @@ else
<MudTh>@S.Meters_Active</MudTh>
<MudTh Style="text-align:right">@S.Common_Actions</MudTh>
</HeaderContent>
<GroupHeaderTemplate>
<MudTh colspan="7" Class="mud-table-cell-custom-group">
<MudText Typo="Typo.subtitle2">@context.Key</MudText>
</MudTh>
</GroupHeaderTemplate>
<RowTemplate>
<MudTd DataLabel="@S.Common_Name"><MudLink Href="@($"/meters/{context.Id}")">@context.Name</MudLink></MudTd>
<MudTd DataLabel="@S.Common_Type">@context.EnergyType?.DisplayName</MudTd>
<MudTd DataLabel="@S.Common_Mode">@context.Mode.Display()</MudTd>
<MudTd DataLabel="@S.Common_Unit">@context.Unit</MudTd>
<MudTd DataLabel="@S.Meters_Sources">@context.Sources.Count</MudTd>
<MudTd DataLabel="@S.Common_LastSeen">
<MudTd DataLabel="@S.Common_Name">
@* 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. *@
<span @onclick:stopPropagation="true"><MudLink Href="@MeterLinks.Detail(context.Id)">@context.Name</MudLink></span>
@if (!context.IsActive)
{
<MudChip T="string" Size="Size.Small" Color="Color.Warning" Class="ml-2">@S.MeterDetail_Retired</MudChip>
}
</MudTd>
<MudTd DataLabel="@S.Common_Mode" HideSmall="true">@context.Mode.Display()</MudTd>
<MudTd DataLabel="@S.Common_Unit" HideSmall="true">@context.Unit</MudTd>
<MudTd DataLabel="@S.Meters_Sources" HideSmall="true">@context.Sources.Count</MudTd>
<MudTd DataLabel="@S.Common_LastSeen" HideSmall="true">
@{
var lastSeen = context.Sources.Where(s => s.LastSeenAt != null).Select(s => s.LastSeenAt).DefaultIfEmpty(null).Max();
}
@(lastSeen?.ToString("yyyy-MM-dd HH:mm") ?? "—")
</MudTd>
<MudTd DataLabel="@S.Meters_Active">@(context.IsActive ? S.Meters_Yes : S.Meters_No)</MudTd>
<MudTd DataLabel="@S.Meters_Active" HideSmall="true">@(context.IsActive ? S.Meters_Yes : S.Meters_No)</MudTd>
<MudTd DataLabel="@S.Common_Actions" Style="text-align:right">
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => OpenEdit(context))" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(context))" />
@* Buttons inside a clickable row must not also open the meter. *@
<div class="d-inline-flex" @onclick:stopPropagation="true">
@if (MeterLinks.QuickEntry(context.Id, context.Mode) is { } entry)
{
<MudTooltip Text="@(context.Mode == MeterMode.ConsumableBalance ? S.MeterDetail_RecordTankLevel : S.MeterDetail_AddReading)">
<MudIconButton Icon="@(context.Mode == MeterMode.ConsumableBalance ? Icons.Material.Filled.Straighten : Icons.Material.Filled.EditNote)"
Size="Size.Small" Color="Color.Primary" Href="@entry"
aria-label="@(context.Mode == MeterMode.ConsumableBalance ? S.MeterDetail_RecordTankLevel : S.MeterDetail_AddReading)" />
</MudTooltip>
}
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => _editor!.OpenAsync(context.Id))" aria-label="@S.MeterDetail_EditMeter" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(context))" aria-label="@S.Common_Delete" />
</div>
</MudTd>
</RowTemplate>
<NoRecordsContent>
@if (_meters.Count > 0)
{
<MudText Typo="Typo.body2" Color="Color.Secondary">@Loc.F(S.Meters_NoSearchMatch, _search)</MudText>
}
</NoRecordsContent>
</MudTable>
@if (_meters.Count == 0)
@@ -60,252 +98,66 @@ else
}
}
<MudDialog @bind-Visible="_editOpen" Options="_dialogOptions">
<TitleContent>
<MudText Typo="Typo.h6">@(_working.Id == 0 ? S.Meters_NewMeter : Loc.F(S.Meters_EditMeter, _working.Name))</MudText>
</TitleContent>
<DialogContent>
<MudTextField @bind-Value="_working.Name" Label="@S.Common_Name" Required="true" Class="mb-2" />
<MudSelect T="short" @bind-Value="_working.EnergyTypeId" Label="@S.Common_EnergyType" Class="mb-2">
@foreach (var t in _energyTypes)
{
<MudSelectItem T="short" Value="t.Id">@t.DisplayName</MudSelectItem>
}
</MudSelect>
<MudSelect T="MeterMode" @bind-Value="_working.Mode" Label="@S.Meters_MeasurementMode" Class="mb-2">
@foreach (var mode in Enum.GetValues<MeterMode>())
{
<MudSelectItem T="MeterMode" Value="mode">@mode.Display()</MudSelectItem>
}
</MudSelect>
@if (_working.Mode == MeterMode.Virtual)
{
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-2">
@S.Meters_VirtualHelp
</MudAlert>
}
else if (_working.Mode == MeterMode.InstantRate)
{
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-2">
@S.Meters_InstantRateHelpBefore <b>@S.Meters_InstantRateHelpPerHour</b> @S.Meters_InstantRateHelpAfter
</MudAlert>
}
<MudTextField @bind-Value="_working.Unit" Label="@S.Common_Unit" Required="true" Class="mb-2" />
<MudNumericField T="double" @bind-Value="_working.InitialBaseline" Label="@S.Meters_InitialBaseline" Class="mb-2" />
<MudSelect T="string" @bind-Value="_working.Role" Label="@S.Meters_PvRole" Class="mb-2">
<MudSelectItem T="string" Value="@("")">@S.Meters_RoleNone</MudSelectItem>
<MudSelectItem T="string" Value="@MeterRoles.TotalLoad">total_load</MudSelectItem>
<MudSelectItem T="string" Value="@MeterRoles.GridImport">grid_import</MudSelectItem>
<MudSelectItem T="string" Value="@MeterRoles.GridExport">grid_export</MudSelectItem>
</MudSelect>
<MudSelect T="int" MultiSelection="true" @bind-SelectedValues="_working.Upstream"
Label="@S.Meters_UpstreamLabel" Class="mb-2"
MultiSelectionTextFunc="@(ids => UpstreamText(ids))"
HelperText="@S.Meters_UpstreamHelp">
@foreach (var m in AvailableUpstream())
{
<MudSelectItem T="int" Value="m.Id">@m.Name</MudSelectItem>
}
</MudSelect>
<MudTextField @bind-Value="_working.Location" Label="@S.Meters_Location" Class="mb-2" />
<MudTextField @bind-Value="_working.SerialNumber" Label="@S.Meters_SerialNumber" Class="mb-2" />
<div class="d-flex" style="gap:1rem">
<MudTextField @bind-Value="_working.Manufacturer" Label="@S.Meters_Manufacturer" Class="mb-2" />
<MudTextField @bind-Value="_working.Model" Label="@S.Meters_Model" Class="mb-2" />
</div>
<MudSwitch T="bool" @bind-Value="_working.IsActive" Label="@S.Meters_Active" Color="Color.Primary" />
@if (_working.Id != 0 && _working.RecomputeNeeded)
{
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-2">@S.Meters_RecomputeNotice</MudAlert>
}
</DialogContent>
<DialogActions>
<MudButton OnClick="@(() => _editOpen = false)">@S.Common_Cancel</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">@S.Common_Save</MudButton>
</DialogActions>
</MudDialog>
<MeterEditor @ref="_editor" Saved="OnSavedAsync" SwapInsteadRequested="@(id => Nav.NavigateTo(MeterLinks.Event(id, MeterEventType.MeterSwap)))" />
@code {
private List<Meter>? _meters;
private List<EnergyType> _energyTypes = [];
private List<MeterLink> _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<Meter> _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<Meter> 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<int> Descendants(int meterId)
/// <summary>A new meter goes straight to its own page, where adding readings or a source is the next step.</summary>
private async Task OnSavedAsync((int MeterId, bool Created) saved)
{
var result = new HashSet<int>();
if (meterId == 0)
if (saved.Created)
{
return result;
}
var queue = new Queue<int>();
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<string> 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();
}
/// <summary>Reconciles the meter's incoming flow links to the selected upstream meters.</summary>
private static async Task SyncUpstreamAsync(MeterVault.Infrastructure.Persistence.MeterVaultDbContext db, int meterId, IEnumerable<int> 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<int> Upstream { get; set; } = new HashSet<int>();
public bool RecomputeNeeded => Mode != OriginalMode || Math.Abs(InitialBaseline - OriginalBaseline) > 1e-9;
}
}
+4 -3
View File
@@ -22,6 +22,7 @@ else if (!_summary.HasGeneration)
{
<MudAlert Severity="Severity.Info">
@S.Solar_NoGenerationLead <b>@MeterMode.GenerationCounter.Display()</b> @S.Solar_NoGenerationTail
<MudLink Href="/meters">@S.Nav_Meters</MudLink>@S.Solar_NoGenerationOrImport
<MudLink Href="/import">@S.Nav_Import</MudLink>.
</MudAlert>
}
@@ -76,7 +77,7 @@ else
@foreach (var meter in _summary.Meters)
{
<tr>
<td>@meter.Name</td>
<td><MudLink Href="@MeterLinks.Detail(meter.MeterId)">@meter.Name</MudLink></td>
<td style="text-align:right">@Format.Number(meter.Generation, 0) kWh</td>
</tr>
}
@@ -85,8 +86,8 @@ else
@if (!_summary.HasLoadContext)
{
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3">
@S.Solar_TagMetersLead <code>total_load</code> @S.Solar_TagMetersMid <code>grid_import</code>
@S.Solar_TagMetersTail
@S.Solar_TagMetersLead <code>total_load</code> @S.Solar_TagMetersMid <code>grid_import</code>@S.Solar_TagMetersTail
<MudLink Href="/meters">@S.Nav_Meters</MudLink>.
</MudAlert>
}
</MudPaper>
+4 -1
View File
@@ -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<TrendPoint> _points = [];
protected override Task OnInitializedAsync() => LoadAsync();
+559
View File
@@ -0,0 +1,559 @@
@using Microsoft.EntityFrameworkCore
@using MeterVault.Core.Normalization
@using MeterVault.Infrastructure.Normalization
@using MeterVault.Infrastructure.Persistence
@inject IDbContextFactory<MeterVaultDbContext> DbFactory
@inject INormalizationEngine Engine
@inject Microsoft.Extensions.Options.IOptions<MeterVault.Infrastructure.Options.MeterVaultOptions> 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. *@
<MudDialog Visible="_open" VisibleChanged="@(v => _open = v)" Options="_dialogOptions">
<TitleContent>
<MudText Typo="Typo.h6">@(_working.Id == 0 ? S.Meters_NewMeter : Loc.F(S.Meters_EditMeter, _working.Name))</MudText>
</TitleContent>
<DialogContent>
<MudTextField @bind-Value="_working.Name" Label="@S.Common_Name" Required="true" Class="mb-2" />
<MudSelect T="short" Value="_working.EnergyTypeId" ValueChanged="OnEnergyTypeChanged" Label="@S.Common_EnergyType" Class="mb-2">
@foreach (var t in _energyTypes)
{
<MudSelectItem T="short" Value="t.Id">@t.DisplayName</MudSelectItem>
}
</MudSelect>
<MudSelect T="MeterMode" @bind-Value="_working.Mode" Label="@S.Meters_MeasurementMode" Class="mb-2">
@foreach (var mode in Enum.GetValues<MeterMode>())
{
<MudSelectItem T="MeterMode" Value="mode">@mode.Display()</MudSelectItem>
}
</MudSelect>
@if (_working.Mode == MeterMode.Virtual)
{
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-2">
@S.Meters_VirtualHelp
</MudAlert>
}
else if (_working.Mode == MeterMode.InstantRate)
{
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-2">
@S.Meters_InstantRateHelpBefore <b>@S.Meters_InstantRateHelpPerHour</b> @S.Meters_InstantRateHelpAfter
</MudAlert>
}
<MudTextField @bind-Value="_working.Unit" Label="@S.Common_Unit" Required="true" Class="mb-2" />
@if (_working.Mode != MeterMode.ConsumableBalance)
{
<MudNumericField T="double" @bind-Value="_working.InitialBaseline" Label="@S.Meters_InitialBaseline" Class="mb-2" />
}
@if (_working.Mode == MeterMode.ConsumableBalance)
{
<MudPaper Outlined="true" Class="pa-3 mb-3">
<MudText Typo="Typo.subtitle2">@S.Meters_TankSection</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="d-block mb-2">@S.Meters_TankHelp</MudText>
<div class="d-flex flex-wrap" style="gap:0 1rem">
<MudNumericField T="double?" @bind-Value="_working.TankCapacity" Label="@S.Meters_TankCapacity" Min="0"
Style="min-width:140px" Class="flex-grow-1 mb-2" />
<MudTextField @bind-Value="_working.TankUnit" Label="@S.Common_Unit" Style="max-width:110px" Class="mb-2" />
</div>
<div class="d-flex flex-wrap" style="gap:0 1rem">
<MudNumericField T="double?" @bind-Value="_working.VolumePerCm" Label="@Loc.F(S.Meters_TankVolumePerCm, TankUnitLabel)" Min="0"
HelperText="@S.Meters_TankVolumePerCmHelp" Style="min-width:160px" Class="flex-grow-1 mb-2" />
<MudNumericField T="double" @bind-Value="_working.CalibrationOffset" Label="@Loc.F(S.Meters_TankOffset, TankUnitLabel)"
Disabled="@(_working.VolumePerCm is null)" Style="max-width:140px" Class="mb-2" />
</div>
<div class="d-flex flex-wrap" style="gap:0 1rem">
<MudSelect T="TankRateMode" @bind-Value="_working.RateMode" Label="@S.Meters_TankRateMode" Style="min-width:160px" Class="flex-grow-1 mb-2">
@foreach (var rateMode in Enum.GetValues<TankRateMode>())
{
<MudSelectItem T="TankRateMode" Value="rateMode">@rateMode.Display()</MudSelectItem>
}
</MudSelect>
@if (_working.RateMode == TankRateMode.Fixed)
{
<MudNumericField T="double?" @bind-Value="_working.FixedRate" Label="@Loc.F(S.Meters_TankFixedRate, TankUnitLabel)" Min="0"
Style="max-width:160px" Class="mb-2" />
}
</div>
</MudPaper>
}
<MudSelect T="string" @bind-Value="_working.Role" Label="@S.Meters_PvRole" HelperText="@S.Meters_PvRoleHelp" Class="mb-2">
<MudSelectItem T="string" Value="@("")">@S.Meters_RoleNone</MudSelectItem>
<MudSelectItem T="string" Value="@MeterRoles.TotalLoad">total_load</MudSelectItem>
<MudSelectItem T="string" Value="@MeterRoles.GridImport">grid_import</MudSelectItem>
<MudSelectItem T="string" Value="@MeterRoles.GridExport">grid_export</MudSelectItem>
</MudSelect>
<MudSelect T="int" MultiSelection="true" @bind-SelectedValues="_working.Upstream"
Label="@S.Meters_UpstreamLabel" Class="mb-2"
MultiSelectionTextFunc="@(ids => UpstreamText(ids))"
HelperText="@S.Meters_UpstreamHelp">
@foreach (var m in AvailableUpstream())
{
<MudSelectItem T="int" Value="m.Id">@m.Name</MudSelectItem>
}
</MudSelect>
@* 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)
{
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="d-block mb-2">
@S.Meters_NoCostCategories <MudLink Typo="Typo.caption" Href="/admin/categories">@S.Nav_CostCategories</MudLink>
</MudText>
}
else
{
<MudSelect T="int" MultiSelection="true" @bind-SelectedValues="_working.Categories"
Label="@S.Meters_CostCategories" Class="mb-2"
MultiSelectionTextFunc="@(ids => CategoryText(ids))"
HelperText="@CategoriesHelp">
@foreach (var category in _categories)
{
<MudSelectItem T="int" Value="category.Id">@category.Name</MudSelectItem>
}
</MudSelect>
}
<MudTextField @bind-Value="_working.Location" Label="@S.Meters_Location" Class="mb-2" />
<MudTextField @bind-Value="_working.SerialNumber" Label="@S.Meters_SerialNumber" Class="mb-2" />
<div class="d-flex" style="gap:1rem">
<MudTextField @bind-Value="_working.Manufacturer" Label="@S.Meters_Manufacturer" Class="mb-2" />
<MudTextField @bind-Value="_working.Model" Label="@S.Meters_Model" Class="mb-2" />
</div>
<MudSwitch T="bool" @bind-Value="_working.IsActive" Label="@S.Meters_Active" Color="Color.Primary" />
@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. *@
<MudAlert Severity="Severity.Warning" Dense="true" Class="mt-2">
@S.Meters_RetireSwapHint
<div class="mt-2">
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Warning"
StartIcon="@Icons.Material.Filled.SwapHoriz" OnClick="RecordSwapInsteadAsync">
@S.Meters_RecordSwapInstead
</MudButton>
</div>
</MudAlert>
}
@if (_working.Id != 0 && _working.RecomputeNeeded)
{
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-2">@S.Meters_RecomputeNotice</MudAlert>
}
</DialogContent>
<DialogActions>
<MudButton OnClick="@(() => _open = false)" Disabled="_saving">@S.Common_Cancel</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync" Disabled="_saving">@S.Common_Save</MudButton>
</DialogActions>
</MudDialog>
@code {
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
private bool _open;
private bool _saving;
private EditModel _working = new();
private List<EnergyType> _energyTypes = [];
private List<Meter> _meters = [];
private List<MeterLink> _allLinks = [];
private List<CostCategory> _categories = [];
private List<CostCategoryMember> _typeMemberships = [];
/// <summary>Raised after a save, with the meter's id and whether it was just created.</summary>
[Parameter]
public EventCallback<(int MeterId, bool Created)> Saved { get; set; }
/// <summary>
/// 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.
/// </summary>
[Parameter]
public EventCallback<int> 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();
}
/// <summary>
/// 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.
/// </summary>
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<string> ids) =>
string.Join(", ", ids.Select(idText =>
int.TryParse(idText, out var id) ? _categories.FirstOrDefault(c => c.Id == id)?.Name ?? idText : idText));
/// <summary>
/// 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.
/// </summary>
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<Meter> AvailableUpstream()
{
var descendants = Descendants(_working.Id);
return _meters.Where(m => m.EnergyTypeId == _working.EnergyTypeId && m.Id != _working.Id && !descendants.Contains(m.Id));
}
private HashSet<int> Descendants(int meterId)
{
var result = new HashSet<int>();
if (meterId == 0)
{
return result;
}
var queue = new Queue<int>();
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<string> 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;
}
}
/// <summary>
/// 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.
/// </summary>
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();
}
/// <summary>Reconciles the meter's incoming flow links to the selected upstream meters.</summary>
private static async Task SyncUpstreamAsync(MeterVaultDbContext db, int meterId, IEnumerable<int> 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();
}
/// <summary>
/// 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.
/// </summary>
private static async Task SyncCategoriesAsync(MeterVaultDbContext db, int meterId, IEnumerable<int> 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<int> Upstream { get; set; } = new HashSet<int>();
public IReadOnlyCollection<int> Categories { get; set; } = new HashSet<int>();
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; } = "";
/// <summary>A tank is kept once it exists, and created as soon as any of its fields is filled in.</summary>
public bool WantsTank => HasTank || TankCapacity is not null || VolumePerCm is not null || FixedRate is not null;
/// <summary>
/// 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.
/// </summary>
public string TankSignature => FormattableString.Invariant($"{VolumePerCm}|{CalibrationOffset}");
public bool RecomputeNeeded =>
Mode != OriginalMode
|| Math.Abs(InitialBaseline - OriginalBaseline) > 1e-9
|| (Mode == MeterMode.ConsumableBalance && WantsTank && TankSignature != OriginalTank);
}
}
@@ -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<MeterVault.Infrastructure.Options.MeterVaultOptions> Options
@inject ILogger<MeterEventDialog> 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. *@
<MudDialog Visible="_open" VisibleChanged="OnVisibleChangedAsync" Options="_options">
<TitleContent>
<MudText Typo="Typo.h6">@Loc.F(S.MeterEvent_Title, _type.Display(), MeterName)</MudText>
</TitleContent>
<DialogContent>
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="d-block mb-3">@MeterEventText.Intro(_type)</MudText>
<div class="d-flex align-center flex-wrap" style="gap:.75rem">
<MudDatePicker Date="_when.Date" DateChanged="OnDateChangedAsync" Label="@S.Common_Date" Variant="Variant.Outlined"
Class="flex-grow-1" Style="min-width:150px" />
<MudTimePicker Time="_when.TimeOfDay" TimeChanged="OnTimeChangedAsync" Label="@S.MeterDetail_TimeOfDay" Variant="Variant.Outlined"
Class="flex-grow-1" Style="min-width:130px" />
<MudButton Size="Size.Small" Variant="Variant.Text" StartIcon="@Icons.Material.Filled.Schedule" OnClick="SetNowAsync">@S.Common_Now</MudButton>
</div>
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="d-block mt-1 mb-3">@Loc.F(S.MeterDetail_LocalTimeIn, _when.Zone.Id)</MudText>
@if (IsBoundary)
{
<MudTextField T="string" Value="_prevText" ValueChanged="@(v => _prevText = v)" Immediate="true"
Label="@Loc.F(_type == MeterEventType.MeterSwap ? S.MeterEvent_OldFinalLabel : S.MeterEvent_BeforeResetLabel, Unit)"
HelperText="@(_type == MeterEventType.MeterSwap ? S.MeterEvent_OldFinalHelp : S.MeterEvent_BeforeResetHelp)"
Error="@IsUnparseable(_prevText)" ErrorText="@S.MeterEvent_NotANumber"
Variant="Variant.Outlined" InputMode="DecimalKeyboard" Class="mb-3" />
<MudTextField T="string" Value="_newText" ValueChanged="@(v => _newText = v)" Immediate="true"
Label="@Loc.F(_type == MeterEventType.MeterSwap ? S.MeterEvent_NewStartLabel : S.MeterEvent_AfterResetLabel, Unit)"
HelperText="@S.MeterEvent_NewStartHelp"
Error="@IsUnparseable(_newText)" ErrorText="@S.MeterEvent_NotANumber"
Variant="Variant.Outlined" InputMode="DecimalKeyboard" Class="mb-3" />
@if (_context is { } c)
{
<MudPaper Outlined="true" Class="pa-3 mb-2">
<MudText Typo="Typo.body2">
@(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))
</MudText>
@if (c.Tail(Parse(_prevText)) is { } tail)
{
<MudText Typo="Typo.body2" Color="@(tail < 0 ? Color.Error : Color.Default)">
@Loc.F(S.MeterEvent_TailBooked, Signed(tail), Unit)
</MudText>
}
@if (Parse(_newText) is { } start)
{
<MudText Typo="Typo.body2">@Loc.F(S.MeterEvent_CountsFrom, Register(start), Unit)</MudText>
}
</MudPaper>
@if (c.ReadingsAfter > 0 && c.Next is { } next)
{
<MudAlert Severity="Severity.Warning" Dense="true" Class="mb-2">
@Loc.F(S.MeterEvent_ReadingsAfterWarning,
c.ReadingsAfter >= MeterEventService.ReadingsAfterCap ? $"{MeterEventService.ReadingsAfterCap}+" : c.ReadingsAfter.ToString(CultureInfo.CurrentCulture),
Register(next.Value), Unit, Stamp(next.Time))
</MudAlert>
}
@if (c.LiveSources > 0)
{
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-2">@Loc.F(S.MeterEvent_LiveSourcesWarning, c.LiveSources)</MudAlert>
}
}
}
else if (_type is MeterEventType.TankLevel or MeterEventType.Delivery)
{
<div class="d-flex align-start" style="gap:.75rem">
<MudTextField T="string" Value="_amountText" ValueChanged="@(v => _amountText = v)" Immediate="true"
Label="@(_type == MeterEventType.TankLevel ? S.MeterEvent_LevelLabel : Loc.F(S.MeterEvent_DeliveredLabel, TankUnit))"
Error="@IsUnparseable(_amountText)" ErrorText="@S.MeterEvent_NotANumber"
Variant="Variant.Outlined" InputMode="DecimalKeyboard" Class="flex-grow-1 mb-3" />
@if (_type == MeterEventType.TankLevel)
{
<MudSelect T="bool" @bind-Value="_centimetres" Label="@S.Common_Unit" Variant="Variant.Outlined"
Disabled="@(_context?.Calibration is null)" Style="max-width:110px">
<MudSelectItem T="bool" Value="true">cm</MudSelectItem>
<MudSelectItem T="bool" Value="false">@TankUnit</MudSelectItem>
</MudSelect>
}
</div>
@if (_type == MeterEventType.TankLevel && _context is { } c)
{
@if (c.Calibration is null)
{
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="d-block mb-2">@S.MeterEvent_NoCalibrationHint</MudText>
}
<MudPaper Outlined="true" Class="pa-3 mb-2">
@if (Parse(_amountText) is { } level && _centimetres)
{
<MudText Typo="Typo.body2">@Loc.F(S.MeterEvent_LevelVolume, Format.Number(c.ToVolume(level, true), 0), TankUnit)</MudText>
}
<MudText Typo="Typo.body2">
@(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)
</MudText>
@if (c.DeliveredSinceLastLevel > 0)
{
<MudText Typo="Typo.body2">@Loc.F(S.MeterEvent_DeliveredSince, Format.Number(c.DeliveredSinceLastLevel, 0), TankUnit)</MudText>
}
@if (Parse(_amountText) is { } entered && c.UsedSinceLastLevel(c.ToVolume(entered, _centimetres)) is { } used)
{
<MudText Typo="Typo.body2" Color="@(used < 0 ? Color.Warning : Color.Default)">
@(used < 0
? Loc.F(S.MeterEvent_LevelRose, Format.Number(-used, 0), TankUnit)
: Loc.F(S.MeterEvent_UsedSince, Format.Number(used, 0), TankUnit))
</MudText>
}
</MudPaper>
}
}
<MudTextField T="string" @bind-Value="_notes" Immediate="true" Lines="@(_type == MeterEventType.Note ? 3 : 1)"
Label="@(_type == MeterEventType.Note ? S.MeterEvent_NoteLabel : S.MeterEvent_NotesOptional)"
Variant="Variant.Outlined" Class="mt-1" />
@if (_when.IsSkipped)
{
<MudAlert Severity="Severity.Error" Dense="true" Class="mt-3">@Loc.F(S.MeterDetail_SkippedTime, _when.Zone.Id)</MudAlert>
}
else if (_when.Utc is { } entered && entered > DateTimeOffset.UtcNow.AddMinutes(1))
{
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3">@S.MeterDetail_FutureTime</MudAlert>
}
@if (VisibleProblem is { } problem)
{
<MudAlert Severity="Severity.Error" Dense="true" Class="mt-3">@MeterEventText.Problem(problem)</MudAlert>
}
</DialogContent>
<DialogActions>
<MudButton OnClick="CancelAsync" Disabled="_saving">@S.Common_Cancel</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync" Disabled="@(!CanSave)">
@(_saving ? S.MeterDetail_Saving : S.Common_Save)
</MudButton>
</DialogActions>
</MudDialog>
@code {
/// <summary>
/// A decimal keyboard, so a phone offers the separator. Hoisted out of the markup because
/// <c>decimal</c> is a C# keyword and Razor would read the escape in an attribute as a transition.
/// </summary>
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;
/// <summary>Raised after an event was stored and the meter recomputed.</summary>
[Parameter]
public EventCallback<MeterEventType> Saved { get; set; }
/// <summary>Raised when the dialog is closed without saving.</summary>
[Parameter]
public EventCallback Cancelled { get; set; }
protected override void OnInitialized() => _when = new LocalTimeEntry(LocalTimeEntry.Resolve(Options.Value.TimeZone));
/// <summary>Opens the dialog for <paramref name="type"/>, at <paramref name="at"/> or now.</summary>
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;
/// <summary>The service's verdict on the current input, from the context for the time shown.</summary>
private MeterEventProblem Problem =>
_context is { } context && Draft is { } draft && _contextFor == draft.Time
? MeterEventService.Validate(context, draft)
: MeterEventProblem.None;
/// <summary>
/// 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.
/// </summary>
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();
}
/// <summary>
/// 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.
/// </summary>
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<MeterEventService>();
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<MeterEventService>();
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 _);
/// <summary>
/// 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.
/// </summary>
private static string EntryText(double value) => value.ToString("0.#########", CultureInfo.CurrentCulture);
/// <summary>A register value for display, at the same precision the checks use.</summary>
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)}";
}
@@ -0,0 +1,149 @@
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.EntityFrameworkCore
@inject IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> 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. *@
<MudDialog>
<DialogContent>
<MudTextField T="string" Value="_search" ValueChanged="@(v => _search = v)" Immediate="true" AutoFocus="true"
Placeholder="@S.MeterSearch_Placeholder" Variant="Variant.Outlined" Clearable="true"
Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Search"
OnKeyDown="OnKeyDown" Class="mb-2" />
@if (_meters is null)
{
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
}
else if (_meters.Count == 0)
{
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="pa-2">@S.MeterSearch_NoMeters</MudText>
}
else
{
var matches = Matches().ToList();
@if (matches.Count == 0)
{
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="pa-2">@Loc.F(S.Meters_NoSearchMatch, _search)</MudText>
}
<div style="max-height:60vh; overflow-y:auto">
@foreach (var meter in matches.Take(MaxShown))
{
var hit = meter;
<div class="d-flex align-center px-2 py-1">
@* 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. *@
<div class="flex-grow-1 mud-list-item-clickable" role="button" tabindex="0"
style="min-width:0; cursor:pointer; border-radius:4px"
@onclick="@(() => Go(MeterLinks.Detail(hit.Id)))"
@onkeydown="@(e => OnResultKey(e, hit))">
<MudText Typo="Typo.body1" Style="@(hit.IsActive ? null : "opacity:.6")">@hit.Name</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">@Describe(hit)</MudText>
</div>
@if (MeterLinks.QuickEntry(hit.Id, hit.Mode) is { } entry)
{
<MudTooltip Text="@(hit.Mode == MeterMode.ConsumableBalance ? S.MeterDetail_RecordTankLevel : S.MeterDetail_AddReading)">
<MudIconButton Icon="@(hit.Mode == MeterMode.ConsumableBalance ? Icons.Material.Filled.Straighten : Icons.Material.Filled.EditNote)"
Color="Color.Primary" OnClick="@(() => Go(entry))"
aria-label="@(hit.Mode == MeterMode.ConsumableBalance ? S.MeterDetail_RecordTankLevel : S.MeterDetail_AddReading)" />
</MudTooltip>
}
</div>
}
</div>
@if (matches.Count > MaxShown)
{
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="d-block pa-2">@Loc.F(S.MeterSearch_More, MaxShown, matches.Count)</MudText>
}
}
</DialogContent>
</MudDialog>
@code {
private const int MaxShown = 30;
private List<MeterHit>? _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<MeterHit> 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<string> { 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);
}
/// <summary>Enter opens the first match, so a typed name plus Enter is the whole interaction on a keyboard.</summary>
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);
}
+35
View File
@@ -0,0 +1,35 @@
using System.Diagnostics.CodeAnalysis;
namespace MeterVault.App;
/// <summary>
/// Unsaved dialog input that has to survive a detour to another page of the same circuit.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public sealed class DraftStore
{
private readonly Dictionary<string, object> _drafts = new(StringComparer.Ordinal);
public void Save(string key, object draft) => _drafts[key] = draft;
/// <summary>Hands back a draft once; it is gone afterwards.</summary>
public bool TryTake<T>(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);
}
+77
View File
@@ -0,0 +1,77 @@
namespace MeterVault.App;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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,
/// <see cref="TimeZoneInfo"/>'s default. Those two candidate instants are an hour apart on one night
/// a year, well inside the precision of a timestamp somebody typed.
/// </remarks>
public sealed class LocalTimeEntry(TimeZoneInfo zone)
{
public TimeZoneInfo Zone { get; } = zone;
/// <summary>Bound to the date picker; only the date part is used.</summary>
public DateTime? Date { get; set; }
/// <summary>Bound to the time picker; minute precision.</summary>
public TimeSpan? TimeOfDay { get; set; }
/// <summary>The wall-clock moment the pickers describe, or null until a date is chosen.</summary>
public DateTime? WallClock => Date is { } date ? date.Date + (TimeOfDay ?? TimeSpan.Zero) : null;
/// <summary>True when the wall-clock time falls in a spring-forward gap and so names no instant.</summary>
public bool IsSkipped =>
WallClock is { } wall && Zone.IsInvalidTime(DateTime.SpecifyKind(wall, DateTimeKind.Unspecified));
/// <summary>The UTC instant entered, or null while incomplete or skipped.</summary>
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);
}
}
/// <summary>Resolves the configured timezone id, falling back to UTC for an unknown one.</summary>
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;
}
}
/// <summary>Sets the pickers to <paramref name="instant"/> in the instance timezone, dropping seconds.</summary>
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);
/// <summary>An instant as the instance's wall clock, for display.</summary>
public DateTimeOffset Local(DateTimeOffset instant) => TimeZoneInfo.ConvertTime(instant, Zone);
}
+6 -1
View File
@@ -84,7 +84,7 @@ public static class DisplayNames
return string.Empty;
}
var names = new List<string>(3);
var names = new List<string>(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();
}
+58
View File
@@ -0,0 +1,58 @@
using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Ingestion;
namespace MeterVault.App.Localization;
/// <summary>How meter-event outcomes are worded — one place, shared by the event dialog and the meter page.</summary>
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,
};
/// <summary>The Material icon for an event type, so menus and the events list read at a glance.</summary>
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,
};
}
+396 -18
View File
@@ -240,6 +240,9 @@
<data name="Connectors_AddConnector" xml:space="preserve">
<value>Konnektor hinzufügen</value>
</data>
<data name="Connectors_BackToMeter" xml:space="preserve">
<value>Zurück zu „{0}“</value>
</data>
<data name="Connectors_BaseUrl" xml:space="preserve">
<value>Basis-URL (z. B. http://homeassistant.local:8123)</value>
</data>
@@ -252,6 +255,9 @@
<data name="Connectors_DeleteTitle" xml:space="preserve">
<value>Konnektor löschen</value>
</data>
<data name="Connectors_DisabledInUse" xml:space="preserve">
<value>Deaktiviert — diese Quellen erfassen nichts</value>
</data>
<data name="Connectors_EditTitle" xml:space="preserve">
<value>{0} bearbeiten</value>
</data>
@@ -270,6 +276,9 @@
<data name="Connectors_ExtraTopics" xml:space="preserve">
<value>Zusätzliche Topics (kommagetrennt, optional)</value>
</data>
<data name="Connectors_ForMeter" xml:space="preserve">
<value>Konnektor für eine Quelle von „{0}“ einrichten. Nach dem Speichern geht es zurück.</value>
</data>
<data name="Connectors_Host" xml:space="preserve">
<value>Host</value>
</data>
@@ -366,6 +375,15 @@
<data name="Connectors_TokenStored" xml:space="preserve">
<value>Langlebiges Zugriffstoken (gespeichert zum Ersetzen neu eingeben)</value>
</data>
<data name="Connectors_Unused" xml:space="preserve">
<value>Nicht verwendet</value>
</data>
<data name="Connectors_UsedBy" xml:space="preserve">
<value>Verwendet von</value>
</data>
<data name="Connectors_UsedByMore" xml:space="preserve">
<value>+{0} weitere</value>
</data>
<data name="Connectors_UsernameEnv" xml:space="preserve">
<value>Name der Benutzernamen-Umgebungsvariablen (optional)</value>
</data>
@@ -408,8 +426,11 @@
<data name="Consumables_NoMetersLead" xml:space="preserve">
<value>Keine Zähler für Vorräte gefunden. Legen Sie einen Zähler mit dem Modus</value>
</data>
<data name="Consumables_NoMetersOrImport" xml:space="preserve">
<value>, oder laden Sie die Referenzdaten über</value>
</data>
<data name="Consumables_NoMetersTail" xml:space="preserve">
<value>und einem Tank an oder laden Sie die Referenzdaten über</value>
<value>an und richten Sie seinen Tank ein unter</value>
</data>
<data name="Consumables_PageTitle" xml:space="preserve">
<value>Vorräte</value>
@@ -417,9 +438,15 @@
<data name="Consumables_PerDay" xml:space="preserve">
<value>({0} {1}/Tag)</value>
</data>
<data name="Consumables_RecordDelivery" xml:space="preserve">
<value>Lieferung erfassen</value>
</data>
<data name="Consumables_TankLevel" xml:space="preserve">
<value>Füllstand</value>
</data>
<data name="Consumables_TankNotConfigured" xml:space="preserve">
<value>Für {0} ist noch kein Tank eingerichtet — Füllstand, Füllgrad und Prognose brauchen Fassungsvermögen und Peilstab-Kalibrierung des Tanks.</value>
</data>
<data name="Consumables_UnitUsed" xml:space="preserve">
<value>Verbrauch ({0})</value>
</data>
@@ -438,8 +465,20 @@
<data name="Dashboard_LatestMonthWithData" xml:space="preserve">
<value>Letzter Monat mit Daten</value>
</data>
<data name="Dashboard_NoCostData" xml:space="preserve">
<value>Noch keine Kostendaten Tabelle importieren oder Tarife anlegen.</value>
<data name="Dashboard_SetupNoCategories" xml:space="preserve">
<value>Kosten werden je Kostenkategorie ausgewiesen, und es gibt noch keine:</value>
</data>
<data name="Dashboard_SetupNoCostsThisYear" xml:space="preserve">
<value>Dieses Jahr noch keine Kosten. Sie erscheinen, sobald ein Zähler in einer Kostenkategorie Verbrauch bei gültigem Tarif erfasst.</value>
</data>
<data name="Dashboard_SetupNoMembers" xml:space="preserve">
<value>Noch kein Zähler gehört zu einer Kostenkategorie. Kategorien beim Bearbeiten eines Zählers wählen oder je Kategorie zuordnen:</value>
</data>
<data name="Dashboard_SetupNoMeters" xml:space="preserve">
<value>Noch keine Zähler. Kosten beginnen mit einem Zähler — einen anlegen oder vorhandene Daten importieren:</value>
</data>
<data name="Dashboard_SetupNoTariffs" xml:space="preserve">
<value>Noch keine Tarife — Verbrauch braucht einen Preis, bevor er Kosten hat:</value>
</data>
<data name="Dashboard_ThisMonth" xml:space="preserve">
<value>Dieser Monat</value>
@@ -528,6 +567,9 @@
<data name="EnergyView_NoChainUpstream" xml:space="preserve">
<value>vorgelagerte Zähler</value>
</data>
<data name="EnergyView_NoDataInRange" xml:space="preserve">
<value>Für diese Zähler ist im gewählten Zeitraum noch kein Verbrauch erfasst.</value>
</data>
<data name="EnergyView_NoMetersIntro" xml:space="preserve">
<value>Für diese Energieart gibt es noch keine Zähler. Legen Sie welche an unter</value>
</data>
@@ -612,6 +654,9 @@
<data name="Enum_ReadingFlags_MeterSwap" xml:space="preserve">
<value>Zählerwechsel</value>
</data>
<data name="Enum_ReadingFlags_MonthLabel" xml:space="preserve">
<value>Monatsendstand</value>
</data>
<data name="Enum_ReadingQuality_Estimated" xml:space="preserve">
<value>Geschätzt</value>
</data>
@@ -879,6 +924,9 @@
<data name="Import_ColumnSource" xml:space="preserve">
<value>Quelle</value>
</data>
<data name="Import_ColumnWritesTo" xml:space="preserve">
<value>Geschrieben in</value>
</data>
<data name="Import_DryRunReferenceSheet" xml:space="preserve">
<value>Probelauf mit Referenzblatt</value>
</data>
@@ -957,6 +1005,9 @@
<data name="Layout_DarkMode" xml:space="preserve">
<value>Dunkler Modus</value>
</data>
<data name="Layout_FindMeter" xml:space="preserve">
<value>Zähler suchen</value>
</data>
<data name="Layout_Language" xml:space="preserve">
<value>Sprache</value>
</data>
@@ -975,6 +1026,9 @@
<data name="MeterDetail_AboutTheSame" xml:space="preserve">
<value>etwa gleich</value>
</data>
<data name="MeterDetail_AddFirstReading" xml:space="preserve">
<value>Ersten Zählerstand erfassen</value>
</data>
<data name="MeterDetail_AddReading" xml:space="preserve">
<value>Zählerstand erfassen</value>
</data>
@@ -984,6 +1038,9 @@
<data name="MeterDetail_AddSource" xml:space="preserve">
<value>Quelle hinzufügen</value>
</data>
<data name="MeterDetail_AnotherConnector" xml:space="preserve">
<value>Weiteren Konnektor einrichten</value>
</data>
<data name="MeterDetail_AttributeLabel" xml:space="preserve">
<value>Attribut (optional; leer = state)</value>
</data>
@@ -1002,12 +1059,18 @@
<data name="MeterDetail_Component" xml:space="preserve">
<value>Komponente</value>
</data>
<data name="MeterDetail_ConnectSource" xml:space="preserve">
<value>Live-Quelle verbinden</value>
</data>
<data name="MeterDetail_Connector" xml:space="preserve">
<value>Konnektor</value>
</data>
<data name="MeterDetail_ConnectorDisabled" xml:space="preserve">
<value>„{0}“ ist deaktiviert; diese Quelle würde also nie Daten erfassen. Zuerst aktivieren.</value>
</data>
<data name="MeterDetail_ConnectorOnlyDisabled" xml:space="preserve">
<value>„{0}“ ist deaktiviert und kann diese Quelle nicht bedienen —</value>
</data>
<data name="MeterDetail_ConnectorTypeMismatch" xml:space="preserve">
<value>„{0}“ ist ein {1}-Konnektor; eine {2}-Quelle benötigt {3}.</value>
</data>
@@ -1021,7 +1084,25 @@
<value>einen anlegen</value>
</data>
<data name="MeterDetail_DecreaseWarning" xml:space="preserve">
<value>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.</value>
<value>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.</value>
</data>
<data name="MeterDetail_DeleteBoundaryConfirm" xml:space="preserve">
<value>{0} vom {1} löschen? Der dabei erfasste Anfangsstand des neuen Zählwerks wird mit entfernt und der Verbrauch neu berechnet.</value>
</data>
<data name="MeterDetail_DeleteEvent" xml:space="preserve">
<value>Ereignis löschen</value>
</data>
<data name="MeterDetail_DeleteEventConfirm" xml:space="preserve">
<value>{0} vom {1} löschen? Der Verbrauch wird neu berechnet.</value>
</data>
<data name="MeterDetail_DeleteReading" xml:space="preserve">
<value>Diesen manuell erfassten Zählerstand löschen</value>
</data>
<data name="MeterDetail_DeleteReadingConfirm" xml:space="preserve">
<value>Zählerstand {0} {1} vom {2} löschen? Der Verbrauch wird ohne ihn neu berechnet.</value>
</data>
<data name="MeterDetail_DeleteReadingTitle" xml:space="preserve">
<value>Zählerstand löschen</value>
</data>
<data name="MeterDetail_DeleteSourceConfirm" xml:space="preserve">
<value>Diese {0}-Quelle löschen?</value>
@@ -1029,15 +1110,36 @@
<data name="MeterDetail_DeleteSourceTitle" xml:space="preserve">
<value>Quelle löschen</value>
</data>
<data name="MeterDetail_EditMeter" xml:space="preserve">
<value>Zähler bearbeiten</value>
</data>
<data name="MeterDetail_EditSource" xml:space="preserve">
<value>Quelle bearbeiten</value>
</data>
<data name="MeterDetail_EnableConnectorLink" xml:space="preserve">
<value>aktivieren</value>
</data>
<data name="MeterDetail_EnergyTypeFlow" xml:space="preserve">
<value>Energiefluss „{0}“ öffnen</value>
</data>
<data name="MeterDetail_EnterValue" xml:space="preserve">
<value>Wert eingeben</value>
</data>
<data name="MeterDetail_EntityIdLabel" xml:space="preserve">
<value>Entity-ID (z. B. sensor.house_power)</value>
</data>
<data name="MeterDetail_EventDeleted" xml:space="preserve">
<value>Ereignis gelöscht — Verbrauch neu berechnet.</value>
</data>
<data name="MeterDetail_EventsHintNote" xml:space="preserve">
<value>Notizen kommentieren die Historie dieses Zählers und ändern keine Werte.</value>
</data>
<data name="MeterDetail_EventsHintRegister" xml:space="preserve">
<value>Hier Zählerwechsel oder Zählerreset erfassen — die Historie bleibt an diesem Zähler durchgängig.</value>
</data>
<data name="MeterDetail_EventsHintTank" xml:space="preserve">
<value>Füllstände und Lieferungen bestimmen den Verbrauch dieses Tanks.</value>
</data>
<data name="MeterDetail_Flags" xml:space="preserve">
<value>Flags</value>
</data>
@@ -1047,6 +1149,18 @@
<data name="MeterDetail_FutureTime" xml:space="preserve">
<value>Dieser Zeitpunkt liegt in der Zukunft.</value>
</data>
<data name="MeterDetail_GetStarted" xml:space="preserve">
<value>Für diesen Zähler ist noch nichts erfasst — beginnen Sie mit einem ersten Wert.</value>
</data>
<data name="MeterDetail_GoToEvents" xml:space="preserve">
<value>Zu den Ereignissen</value>
</data>
<data name="MeterDetail_Imported" xml:space="preserve">
<value>Importiert</value>
</data>
<data name="MeterDetail_ImportedEventHint" xml:space="preserve">
<value>Stammt aus einem Import — zum Entfernen den Import zurücknehmen.</value>
</data>
<data name="MeterDetail_Kind" xml:space="preserve">
<value>Art</value>
</data>
@@ -1071,6 +1185,9 @@
<data name="MeterDetail_LocalTimeIn" xml:space="preserve">
<value>Ortszeit in {0}.</value>
</data>
<data name="MeterDetail_ManageTariffs" xml:space="preserve">
<value>Tarife verwalten</value>
</data>
<data name="MeterDetail_MeterGone" xml:space="preserve">
<value>Diesen Zähler gibt es nicht mehr.</value>
</data>
@@ -1093,7 +1210,7 @@
<value>Noch kein normalisierter Verbrauch.</value>
</data>
<data name="MeterDetail_NoEvents" xml:space="preserve">
<value>Keine Ereignisse (Zählerwechsel, Lieferungen, Korrekturen).</value>
<value>Noch keine Ereignisse erfasst.</value>
</data>
<data name="MeterDetail_NoRawReadings" xml:space="preserve">
<value>Noch keine Rohdaten.</value>
@@ -1104,6 +1221,9 @@
<data name="MeterDetail_NoSources" xml:space="preserve">
<value>Diesem Zähler ist keine Quelle zugeordnet. Eine Quelle hinzufügen, um Daten von MQTT/Tasmota oder Home Assistant zu erfassen.</value>
</data>
<data name="MeterDetail_NoTankConfigured" xml:space="preserve">
<value>Für diesen Zähler ist noch kein Tank eingerichtet — Füllstand, Füllgrad und Prognose brauchen Fassungsvermögen und Peilstab-Kalibrierung des Tanks.</value>
</data>
<data name="MeterDetail_NoTariffs" xml:space="preserve">
<value>Keine passenden Tarife.</value>
</data>
@@ -1149,11 +1269,14 @@
<data name="MeterDetail_Quality" xml:space="preserve">
<value>Qualität</value>
</data>
<data name="MeterDetail_ReadingDeleted" xml:space="preserve">
<value>Zählerstand gelöscht — Verbrauch neu berechnet.</value>
</data>
<data name="MeterDetail_ReadingLabel" xml:space="preserve">
<value>Zählerstand ({0})</value>
</data>
<data name="MeterDetail_ReadingRejected" xml:space="preserve">
<value>Abgelehnt — unter dem vorherigen Zählerstand bei einem Zählwerk, das nur vorwärts zählt. Zuerst einen Zählerreset oder Zählerwechsel erfassen.</value>
<value>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.</value>
</data>
<data name="MeterDetail_ReadingReplaced" xml:space="preserve">
<value>Zählerstand zu diesem Zeitpunkt ersetzt durch {0} {1}.</value>
@@ -1170,6 +1293,18 @@
<data name="MeterDetail_RecentReadingsCaption" xml:space="preserve">
<value>Die letzten {0} (Rohdaten, unveränderlich und revisionssicher). Zeiten in {1}.</value>
</data>
<data name="MeterDetail_RecordEvent" xml:space="preserve">
<value>Ereignis erfassen</value>
</data>
<data name="MeterDetail_RecordReset" xml:space="preserve">
<value>Zählerreset erfassen</value>
</data>
<data name="MeterDetail_RecordSwap" xml:space="preserve">
<value>Zählerwechsel erfassen</value>
</data>
<data name="MeterDetail_RecordTankLevel" xml:space="preserve">
<value>Füllstand erfassen</value>
</data>
<data name="MeterDetail_RegisterDetails" xml:space="preserve">
<value>Details zum Zählwerk</value>
</data>
@@ -1179,6 +1314,9 @@
<data name="MeterDetail_ReplaceNotice" xml:space="preserve">
<value>Für diesen Zeitpunkt gibt es bereits einen Zählerstand — beim Speichern wird sein Wert ersetzt.</value>
</data>
<data name="MeterDetail_ReplaceSwapStartNotice" xml:space="preserve">
<value>Ersetzt den beim Zählerwechsel erfassten Anfangsstand des neuen Zählers — der Verbrauch über den Wechsel bleibt korrekt.</value>
</data>
<data name="MeterDetail_Retired" xml:space="preserve">
<value>stillgelegt</value>
</data>
@@ -1191,6 +1329,15 @@
<data name="MeterDetail_Scale" xml:space="preserve">
<value>Skalierung</value>
</data>
<data name="MeterDetail_ScopeThisMeter" xml:space="preserve">
<value>Dieser Zähler</value>
</data>
<data name="MeterDetail_SerialValue" xml:space="preserve">
<value>Seriennr. {0}</value>
</data>
<data name="MeterDetail_SetUpTank" xml:space="preserve">
<value>Tank einrichten</value>
</data>
<data name="MeterDetail_SkippedTime" xml:space="preserve">
<value>Diese Uhrzeit gab es in {0} nicht — die Uhren wurden vorgestellt. Bitte eine andere Zeit wählen.</value>
</data>
@@ -1203,6 +1350,9 @@
<data name="MeterDetail_SourceType" xml:space="preserve">
<value>Quellentyp</value>
</data>
<data name="MeterDetail_SwappedOrResetHint" xml:space="preserve">
<value>Unter letztem Stand — Zählerwechsel?</value>
</data>
<data name="MeterDetail_TabConsumption" xml:space="preserve">
<value>Verbrauch ({0})</value>
</data>
@@ -1218,6 +1368,9 @@
<data name="MeterDetail_TabTariffs" xml:space="preserve">
<value>Tarife ({0})</value>
</data>
<data name="MeterDetail_TankUsesEvents" xml:space="preserve">
<value>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.</value>
</data>
<data name="MeterDetail_TariffOpenEnd" xml:space="preserve">
<value>offen</value>
</data>
@@ -1243,13 +1396,13 @@
<value>Wertpfad (z. B. ENERGY.Total; leer = einfacher Zahlenwert)</value>
</data>
<data name="MeterDetail_VirtualNoReadings" xml:space="preserve">
<value>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.</value>
<value>Ein virtueller Zähler hat keine eigenen Zählerstände — tragen Sie die Zählerstände an den Zählern ein, die er aufsummiert.</value>
</data>
<data name="MeterDetail_VirtualNotice" xml:space="preserve">
<value>Virtueller Zähler — sein Wert wird beim Abruf per Formel aus anderen Zählern berechnet; er hat also keine eigene gespeicherte Zeitreihe.</value>
<value>Virtueller Zähler — er hat keine eigenen Zählerstände; sein Wert ist die Summe der verknüpften vorgelagerten Zähler.</value>
</data>
<data name="MeterDetail_VirtualNoticeTrends" xml:space="preserve">
<value>Die Werte stehen unter Trends.</value>
<data name="MeterDetail_VirtualNoticeFlow" xml:space="preserve">
<value>In der Flussansicht ansehen.</value>
</data>
<data name="MeterDetail_VsLastMonth" xml:space="preserve">
<value>ggü. Vormonat</value>
@@ -1257,18 +1410,189 @@
<data name="MeterDetail_VsLastYear" xml:space="preserve">
<value>{0} ggü. {1} im Vorjahr</value>
</data>
<data name="MeterDetail_WillBeRejectedSuffix" xml:space="preserve">
<value> — wird abgelehnt</value>
</data>
<data name="MeterDetail_Yes" xml:space="preserve">
<value>ja</value>
</data>
<data name="MeterEvent_ActionFailed" xml:space="preserve">
<value>Das hat nicht geklappt, es wurde nichts geändert. Bitte erneut versuchen; Details stehen im Log.</value>
</data>
<data name="MeterEvent_AfterResetLabel" xml:space="preserve">
<value>Zählerstand nach dem Reset ({0})</value>
</data>
<data name="MeterEvent_BeforeResetHelp" xml:space="preserve">
<value>Der letzte Wert vor dem Neustart. Leer lassen, wenn unbekannt — der Abschnitt seit dem letzten Zählerstand wird dann nicht gezählt.</value>
</data>
<data name="MeterEvent_BeforeResetLabel" xml:space="preserve">
<value>Zählerstand vor dem Reset ({0})</value>
</data>
<data name="MeterEvent_CountsFrom" xml:space="preserve">
<value>Spätere Zählerstände zählen ab {0} {1} weiter.</value>
</data>
<data name="MeterEvent_DeliveredLabel" xml:space="preserve">
<value>Geliefert ({0})</value>
</data>
<data name="MeterEvent_DeliveredSince" xml:space="preserve">
<value>Seitdem geliefert: {0} {1}</value>
</data>
<data name="MeterEvent_FirstLevel" xml:space="preserve">
<value>Erster Füllstand für diesen Tank — der Verbrauch wird ab dem nächsten gezählt.</value>
</data>
<data name="MeterEvent_IntroDelivery" xml:space="preserve">
<value>Eine Befüllung des Tanks. Sie erhöht den Bestand; der nächste Füllstand macht aus der Differenz den Verbrauch.</value>
</data>
<data name="MeterEvent_IntroNote" xml:space="preserve">
<value>Eine Anmerkung zur Historie dieses Zählers, z. B. eine Reparatur oder ein versetzter Sensor. Sie ändert keine Werte.</value>
</data>
<data name="MeterEvent_IntroReset" xml:space="preserve">
<value>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.</value>
</data>
<data name="MeterEvent_IntroSwap" xml:space="preserve">
<value>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.</value>
</data>
<data name="MeterEvent_IntroTankLevel" xml:space="preserve">
<value>Ein Peilstab- oder Anzeigewert für den Tankinhalt. Der Verbrauch ist die Differenz zum vorherigen Füllstand plus die Lieferungen dazwischen.</value>
</data>
<data name="MeterEvent_LastLevel" xml:space="preserve">
<value>Letzter Füllstand: {0} ({1} {2}) am {3}</value>
</data>
<data name="MeterEvent_LastReadingBefore" xml:space="preserve">
<value>Letzter Zählerstand davor: {0} {1} am {2}</value>
</data>
<data name="MeterEvent_LevelLabel" xml:space="preserve">
<value>Füllstand</value>
</data>
<data name="MeterEvent_LevelRose" xml:space="preserve">
<value>Der Füllstand ist um {0} {1} stärker gestiegen als die erfassten Lieferungen — fehlt eine Lieferung? Es wird kein Verbrauch gebucht.</value>
</data>
<data name="MeterEvent_LevelVolume" xml:space="preserve">
<value>= {0} {1} im Tank</value>
</data>
<data name="MeterEvent_LiveSourcesWarning" xml:space="preserve">
<value>{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.</value>
</data>
<data name="MeterEvent_NewStartHelp" xml:space="preserve">
<value>Meist 0 oder der Wert aus dem Einbauprotokoll.</value>
</data>
<data name="MeterEvent_NewStartLabel" xml:space="preserve">
<value>Neuer Zähler — Anfangsstand ({0})</value>
</data>
<data name="MeterEvent_NoCalibrationHint" xml:space="preserve">
<value>Zentimeter brauchen die Peilstab-Kalibrierung des Tanks — einzurichten unter „Zähler bearbeiten“.</value>
</data>
<data name="MeterEvent_NoReadingBefore" xml:space="preserve">
<value>Kein früherer Zählerstand — das alte Zählwerk zählt ab dem Anfangs-Zählerstand des Zählers, {0} {1}.</value>
</data>
<data name="MeterEvent_NotANumber" xml:space="preserve">
<value>Keine Zahl</value>
</data>
<data name="MeterEvent_NoteLabel" xml:space="preserve">
<value>Notiz</value>
</data>
<data name="MeterEvent_NotesOptional" xml:space="preserve">
<value>Notiz (optional)</value>
</data>
<data name="MeterEvent_OldFinalHelp" xml:space="preserve">
<value>Aus dem Wechselprotokoll oder einem Foto des alten Zählers. Vorbelegt mit dem letzten Zählerstand — die Differenz wird beim Wechsel als Verbrauch gebucht.</value>
</data>
<data name="MeterEvent_OldFinalLabel" xml:space="preserve">
<value>Alter Zähler — Endstand ({0})</value>
</data>
<data name="MeterEvent_ProblemAlreadyRecorded" xml:space="preserve">
<value>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.</value>
</data>
<data name="MeterEvent_ProblemAmountOutOfRange" xml:space="preserve">
<value>Dieser Wert ist hier nicht möglich — eine Lieferung muss größer als null sein, ein Füllstand darf nicht negativ sein.</value>
</data>
<data name="MeterEvent_ProblemAmountRequired" xml:space="preserve">
<value>Bitte einen Wert eingeben.</value>
</data>
<data name="MeterEvent_ProblemBelowPrevious" xml:space="preserve">
<value>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.</value>
</data>
<data name="MeterEvent_ProblemImported" xml:space="preserve">
<value>Dies stammt aus einem Import — zum Entfernen den Import zurücknehmen.</value>
</data>
<data name="MeterEvent_ProblemLaterBoundary" xml:space="preserve">
<value>Ein späterer Zählerwechsel oder Zählerreset baut darauf auf — löschen Sie zuerst diesen.</value>
</data>
<data name="MeterEvent_ProblemLevelAtSameTime" xml:space="preserve">
<value>Zu genau dieser Zeit ist bereits ein Füllstand erfasst — löschen Sie ihn zuerst oder wählen Sie eine andere Minute.</value>
</data>
<data name="MeterEvent_ProblemNeedsCalibration" xml:space="preserve">
<value>Ein Füllstand in Zentimetern braucht die Kalibrierung des Tanks — unter „Zähler bearbeiten“ einrichten oder das Volumen eingeben.</value>
</data>
<data name="MeterEvent_ProblemNotForMode" xml:space="preserve">
<value>Dieses Ereignis passt nicht zu einem Zähler in diesem Messmodus.</value>
</data>
<data name="MeterEvent_ProblemNotFound" xml:space="preserve">
<value>Existiert nicht mehr — die Seite war veraltet.</value>
</data>
<data name="MeterEvent_ProblemNotManual" xml:space="preserve">
<value>Nur manuell erfasste Zählerstände lassen sich hier löschen — gemessene sind der Prüfnachweis, importierte werden mit ihrem Import entfernt.</value>
</data>
<data name="MeterEvent_ProblemNoteRequired" xml:space="preserve">
<value>Bitte die Notiz eingeben.</value>
</data>
<data name="MeterEvent_ProblemReadingAtSameTime" xml:space="preserve">
<value>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.</value>
</data>
<data name="MeterEvent_ProblemStartRejected" xml:space="preserve">
<value>Der Anfangsstand des neuen Zählwerks konnte nicht gespeichert werden, weil inzwischen ein Zählerstand hinzukam. Es wurde nichts gespeichert — bitte erneut versuchen.</value>
</data>
<data name="MeterEvent_ReadingsAfterWarning" xml:space="preserve">
<value>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.</value>
</data>
<data name="MeterEvent_SavedDelivery" xml:space="preserve">
<value>Lieferung erfasst — Verbrauch neu berechnet.</value>
</data>
<data name="MeterEvent_SavedNote" xml:space="preserve">
<value>Notiz gespeichert.</value>
</data>
<data name="MeterEvent_SavedReset" xml:space="preserve">
<value>Zählerreset erfasst — Verbrauch neu berechnet.</value>
</data>
<data name="MeterEvent_SavedSwap" xml:space="preserve">
<value>Zählerwechsel erfasst — Verbrauch neu berechnet.</value>
</data>
<data name="MeterEvent_SavedTankLevel" xml:space="preserve">
<value>Füllstand erfasst — Verbrauch neu berechnet.</value>
</data>
<data name="MeterEvent_TailBooked" xml:space="preserve">
<value>{0} {1} seit diesem Zählerstand, gebucht zu diesem Zeitpunkt.</value>
</data>
<data name="MeterEvent_Title" xml:space="preserve">
<value>{0} — {1}</value>
</data>
<data name="MeterEvent_UsedSince" xml:space="preserve">
<value>Verbrauch seit dem letzten Füllstand: {0} {1}</value>
</data>
<data name="MeterSearch_More" xml:space="preserve">
<value>{0} von {1} angezeigt — zum Eingrenzen weitertippen.</value>
</data>
<data name="MeterSearch_NoMeters" xml:space="preserve">
<value>Noch keine Zähler.</value>
</data>
<data name="MeterSearch_Placeholder" xml:space="preserve">
<value>Name, Seriennummer oder Standort</value>
</data>
<data name="Meters_Active" xml:space="preserve">
<value>Aktiv</value>
</data>
<data name="Meters_AddMeter" xml:space="preserve">
<value>Zähler hinzufügen</value>
</data>
<data name="Meters_ChangedMeanwhile" xml:space="preserve">
<value>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.</value>
</data>
<data name="Meters_CostCategories" xml:space="preserve">
<value>Kostenkategorien</value>
</data>
<data name="Meters_CostCategoriesHelp" xml:space="preserve">
<value>Das Dashboard zeigt Kosten je Kategorie; ein Zähler ohne Kategorie erscheint dort nicht.</value>
</data>
<data name="Meters_CostCategoriesInherited" xml:space="preserve">
<value>Über seine Energieart bereits enthalten in: {0}</value>
</data>
<data name="Meters_DeleteConfirm" xml:space="preserve">
<value>„{0}“ wirklich löschen? Das kann nicht rückgängig gemacht werden.</value>
</data>
@@ -1317,24 +1641,69 @@
<data name="Meters_No" xml:space="preserve">
<value>nein</value>
</data>
<data name="Meters_NoCostCategories" xml:space="preserve">
<value>Noch keine Kostenkategorien, daher erscheinen die Kosten dieses Zählers nicht im Dashboard. Anlegen unter</value>
</data>
<data name="Meters_NoSearchMatch" xml:space="preserve">
<value>Kein Zähler passt zu „{0}“.</value>
</data>
<data name="Meters_PvRole" xml:space="preserve">
<value>PV-Rolle (optional)</value>
</data>
<data name="Meters_PvRoleHelp" xml:space="preserve">
<value>Hauszähler als total_load und Netzzähler als grid_import markieren, um Eigenverbrauch, Autarkie und Ersparnis auf der Solar-Seite freizuschalten.</value>
</data>
<data name="Meters_RecomputeNotice" xml:space="preserve">
<value>Modus/Anfangs-Zählerstand geändert — der Verbrauch wird beim Speichern neu berechnet.</value>
<value>Verbrauchsrelevante Einstellungen geändert — der Verbrauch wird beim Speichern neu berechnet.</value>
</data>
<data name="Meters_RecordSwapInstead" xml:space="preserve">
<value>Stattdessen Zählerwechsel erfassen</value>
</data>
<data name="Meters_RequiredFields" xml:space="preserve">
<value>Name, Energieart und Einheit sind erforderlich.</value>
</data>
<data name="Meters_RetireSwapHint" xml:space="preserve">
<value>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.</value>
</data>
<data name="Meters_RoleNone" xml:space="preserve">
<value>— keine —</value>
</data>
<data name="Meters_SearchPlaceholder" xml:space="preserve">
<value>Suche nach Name, Seriennummer, Standort oder Art</value>
</data>
<data name="Meters_SerialNumber" xml:space="preserve">
<value>Seriennummer (optional)</value>
</data>
<data name="Meters_Sources" xml:space="preserve">
<value>Quellen</value>
</data>
<data name="Meters_TankCapacity" xml:space="preserve">
<value>Fassungsvermögen</value>
</data>
<data name="Meters_TankCapacityRequired" xml:space="preserve">
<value>Bitte das Fassungsvermögen angeben, um den Tank einzurichten.</value>
</data>
<data name="Meters_TankFixedRate" xml:space="preserve">
<value>Feste Rate ({0}/h)</value>
</data>
<data name="Meters_TankHelp" xml:space="preserve">
<value>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.</value>
</data>
<data name="Meters_TankOffset" xml:space="preserve">
<value>Versatz ({0})</value>
</data>
<data name="Meters_TankRateMode" xml:space="preserve">
<value>Brennerrate</value>
</data>
<data name="Meters_TankSection" xml:space="preserve">
<value>Tank</value>
</data>
<data name="Meters_TankVolumePerCm" xml:space="preserve">
<value>{0} pro cm</value>
</data>
<data name="Meters_TankVolumePerCmHelp" xml:space="preserve">
<value>z. B. 7000 L ÷ 150 cm = 46,67. Leer lassen, wenn Füllstände als Volumen erfasst werden.</value>
</data>
<data name="Meters_UpstreamHelp" xml:space="preserve">
<value>Dieser Zähler misst einen Teilbereich des Flusses der gewählten Zähler.</value>
</data>
@@ -1371,6 +1740,12 @@
<data name="Nav_Overview" xml:space="preserve">
<value>Übersicht</value>
</data>
<data name="Nav_SectionData" xml:space="preserve">
<value>Zähler &amp; Daten</value>
</data>
<data name="Nav_SectionEnergy" xml:space="preserve">
<value>Energie</value>
</data>
<data name="Nav_Settings" xml:space="preserve">
<value>Einstellungen</value>
</data>
@@ -1506,9 +1881,12 @@
<data name="Solar_NoGenerationLead" xml:space="preserve">
<value>Keine Erzeugungszähler gefunden. Legen Sie einen Zähler mit dem Modus</value>
</data>
<data name="Solar_NoGenerationTail" xml:space="preserve">
<data name="Solar_NoGenerationOrImport" xml:space="preserve">
<value> an oder laden Sie die Referenzdaten über</value>
</data>
<data name="Solar_NoGenerationTail" xml:space="preserve">
<value>unter</value>
</data>
<data name="Solar_Savings" xml:space="preserve">
<value>Ersparnis</value>
</data>
@@ -1519,13 +1897,13 @@
<value>{0} % der Erzeugung</value>
</data>
<data name="Solar_TagMetersLead" xml:space="preserve">
<value>Markieren Sie einen Zähler als</value>
<value>Setzen Sie die PV-Rolle Ihres Hauszählers auf</value>
</data>
<data name="Solar_TagMetersMid" xml:space="preserve">
<value>und einen als</value>
<value>und die Ihres Netzzählers auf</value>
</data>
<data name="Solar_TagMetersTail" xml:space="preserve">
<value>(in den Zähler-Metadaten), um Eigenverbrauch, Autarkie und Ersparnis freizuschalten.</value>
<value>, um Eigenverbrauch, Autarkie und Ersparnis freizuschalten — im Zählereditor unter</value>
</data>
<data name="Tariffs_AddTariff" xml:space="preserve">
<value>Tarif hinzufügen</value>
+396 -18
View File
@@ -240,6 +240,9 @@
<data name="Connectors_AddConnector" xml:space="preserve">
<value>Add connector</value>
</data>
<data name="Connectors_BackToMeter" xml:space="preserve">
<value>Back to '{0}'</value>
</data>
<data name="Connectors_BaseUrl" xml:space="preserve">
<value>Base URL (e.g. http://homeassistant.local:8123)</value>
</data>
@@ -252,6 +255,9 @@
<data name="Connectors_DeleteTitle" xml:space="preserve">
<value>Delete connector</value>
</data>
<data name="Connectors_DisabledInUse" xml:space="preserve">
<value>Disabled — these sources are not ingesting</value>
</data>
<data name="Connectors_EditTitle" xml:space="preserve">
<value>Edit {0}</value>
</data>
@@ -270,6 +276,9 @@
<data name="Connectors_ExtraTopics" xml:space="preserve">
<value>Extra topics (comma-separated, optional)</value>
</data>
<data name="Connectors_ForMeter" xml:space="preserve">
<value>Setting up a connector for a source of '{0}'. Saving it takes you back.</value>
</data>
<data name="Connectors_Host" xml:space="preserve">
<value>Host</value>
</data>
@@ -366,6 +375,15 @@
<data name="Connectors_TokenStored" xml:space="preserve">
<value>Long-lived access token (stored — type to replace)</value>
</data>
<data name="Connectors_Unused" xml:space="preserve">
<value>Not used</value>
</data>
<data name="Connectors_UsedBy" xml:space="preserve">
<value>Used by</value>
</data>
<data name="Connectors_UsedByMore" xml:space="preserve">
<value>+{0} more</value>
</data>
<data name="Connectors_UsernameEnv" xml:space="preserve">
<value>Username env-var name (optional)</value>
</data>
@@ -408,8 +426,11 @@
<data name="Consumables_NoMetersLead" xml:space="preserve">
<value>No consumable meters found. Add a meter with mode</value>
</data>
<data name="Consumables_NoMetersOrImport" xml:space="preserve">
<value>, or load the reference data from</value>
</data>
<data name="Consumables_NoMetersTail" xml:space="preserve">
<value>and a tank, or load the reference data from</value>
<value>and set up its tank in</value>
</data>
<data name="Consumables_PageTitle" xml:space="preserve">
<value>Consumables</value>
@@ -417,9 +438,15 @@
<data name="Consumables_PerDay" xml:space="preserve">
<value>({0} {1}/day)</value>
</data>
<data name="Consumables_RecordDelivery" xml:space="preserve">
<value>Record delivery</value>
</data>
<data name="Consumables_TankLevel" xml:space="preserve">
<value>Tank level</value>
</data>
<data name="Consumables_TankNotConfigured" xml:space="preserve">
<value>{0} has no tank set up yet — its level, fill and forecast need the tank's capacity and dipstick calibration.</value>
</data>
<data name="Consumables_UnitUsed" xml:space="preserve">
<value>{0} used</value>
</data>
@@ -438,8 +465,20 @@
<data name="Dashboard_LatestMonthWithData" xml:space="preserve">
<value>Latest month with data</value>
</data>
<data name="Dashboard_NoCostData" xml:space="preserve">
<value>No cost data yet — import a sheet or add tariffs.</value>
<data name="Dashboard_SetupNoCategories" xml:space="preserve">
<value>Costs are reported per cost category, and there is none yet:</value>
</data>
<data name="Dashboard_SetupNoCostsThisYear" xml:space="preserve">
<value>No costs this year yet. They appear once a meter in a cost category records consumption with a tariff in effect.</value>
</data>
<data name="Dashboard_SetupNoMembers" xml:space="preserve">
<value>No meter counts toward a cost category yet. Pick categories when editing a meter, or assign them per category:</value>
</data>
<data name="Dashboard_SetupNoMeters" xml:space="preserve">
<value>No meters yet. Costs start with a meter — add one, or import existing data:</value>
</data>
<data name="Dashboard_SetupNoTariffs" xml:space="preserve">
<value>No tariffs yet — consumption needs a price before it has a cost:</value>
</data>
<data name="Dashboard_ThisMonth" xml:space="preserve">
<value>This month</value>
@@ -528,6 +567,9 @@
<data name="EnergyView_NoChainUpstream" xml:space="preserve">
<value>upstream meter(s)</value>
</data>
<data name="EnergyView_NoDataInRange" xml:space="preserve">
<value>No consumption recorded for these meters in the selected range yet.</value>
</data>
<data name="EnergyView_NoMetersIntro" xml:space="preserve">
<value>No meters for this energy type yet. Add meters in</value>
</data>
@@ -612,6 +654,9 @@
<data name="Enum_ReadingFlags_MeterSwap" xml:space="preserve">
<value>Meter swap</value>
</data>
<data name="Enum_ReadingFlags_MonthLabel" xml:space="preserve">
<value>Month-end value</value>
</data>
<data name="Enum_ReadingQuality_Estimated" xml:space="preserve">
<value>Estimated</value>
</data>
@@ -879,6 +924,9 @@
<data name="Import_ColumnSource" xml:space="preserve">
<value>Source</value>
</data>
<data name="Import_ColumnWritesTo" xml:space="preserve">
<value>Wrote to</value>
</data>
<data name="Import_DryRunReferenceSheet" xml:space="preserve">
<value>Dry-run a reference sheet</value>
</data>
@@ -957,6 +1005,9 @@
<data name="Layout_DarkMode" xml:space="preserve">
<value>Dark mode</value>
</data>
<data name="Layout_FindMeter" xml:space="preserve">
<value>Find a meter</value>
</data>
<data name="Layout_Language" xml:space="preserve">
<value>Language</value>
</data>
@@ -975,6 +1026,9 @@
<data name="MeterDetail_AboutTheSame" xml:space="preserve">
<value>about the same</value>
</data>
<data name="MeterDetail_AddFirstReading" xml:space="preserve">
<value>Add first reading</value>
</data>
<data name="MeterDetail_AddReading" xml:space="preserve">
<value>Add reading</value>
</data>
@@ -984,6 +1038,9 @@
<data name="MeterDetail_AddSource" xml:space="preserve">
<value>Add source</value>
</data>
<data name="MeterDetail_AnotherConnector" xml:space="preserve">
<value>Set up another connector</value>
</data>
<data name="MeterDetail_AttributeLabel" xml:space="preserve">
<value>Attribute (optional; blank = state)</value>
</data>
@@ -1002,12 +1059,18 @@
<data name="MeterDetail_Component" xml:space="preserve">
<value>Component</value>
</data>
<data name="MeterDetail_ConnectSource" xml:space="preserve">
<value>Connect a live source</value>
</data>
<data name="MeterDetail_Connector" xml:space="preserve">
<value>Connector</value>
</data>
<data name="MeterDetail_ConnectorDisabled" xml:space="preserve">
<value>'{0}' is disabled, so this source would never ingest. Enable it first.</value>
</data>
<data name="MeterDetail_ConnectorOnlyDisabled" xml:space="preserve">
<value>'{0}' is disabled, so it cannot serve this source —</value>
</data>
<data name="MeterDetail_ConnectorTypeMismatch" xml:space="preserve">
<value>'{0}' is a {1} connector; a {2} source needs {3}.</value>
</data>
@@ -1021,7 +1084,25 @@
<value>create one</value>
</data>
<data name="MeterDetail_DecreaseWarning" xml:space="preserve">
<value>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.</value>
<value>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.</value>
</data>
<data name="MeterDetail_DeleteBoundaryConfirm" xml:space="preserve">
<value>Delete the {0} of {1}? The new register's start reading recorded with it is removed too, and consumption is recomputed.</value>
</data>
<data name="MeterDetail_DeleteEvent" xml:space="preserve">
<value>Delete event</value>
</data>
<data name="MeterDetail_DeleteEventConfirm" xml:space="preserve">
<value>Delete the {0} of {1}? Consumption is recomputed.</value>
</data>
<data name="MeterDetail_DeleteReading" xml:space="preserve">
<value>Delete this hand-entered reading</value>
</data>
<data name="MeterDetail_DeleteReadingConfirm" xml:space="preserve">
<value>Delete the reading {0} {1} of {2}? Consumption is recomputed without it.</value>
</data>
<data name="MeterDetail_DeleteReadingTitle" xml:space="preserve">
<value>Delete reading</value>
</data>
<data name="MeterDetail_DeleteSourceConfirm" xml:space="preserve">
<value>Delete this {0} source?</value>
@@ -1029,15 +1110,36 @@
<data name="MeterDetail_DeleteSourceTitle" xml:space="preserve">
<value>Delete source</value>
</data>
<data name="MeterDetail_EditMeter" xml:space="preserve">
<value>Edit meter</value>
</data>
<data name="MeterDetail_EditSource" xml:space="preserve">
<value>Edit source</value>
</data>
<data name="MeterDetail_EnableConnectorLink" xml:space="preserve">
<value>enable it</value>
</data>
<data name="MeterDetail_EnergyTypeFlow" xml:space="preserve">
<value>Open the {0} flow</value>
</data>
<data name="MeterDetail_EnterValue" xml:space="preserve">
<value>Enter a value</value>
</data>
<data name="MeterDetail_EntityIdLabel" xml:space="preserve">
<value>Entity id (e.g. sensor.house_power)</value>
</data>
<data name="MeterDetail_EventDeleted" xml:space="preserve">
<value>Event deleted — consumption recomputed.</value>
</data>
<data name="MeterDetail_EventsHintNote" xml:space="preserve">
<value>Notes annotate this meter's history; they change no figures.</value>
</data>
<data name="MeterDetail_EventsHintRegister" xml:space="preserve">
<value>Record a meter swap or a counter reset here — the history stays continuous on this meter.</value>
</data>
<data name="MeterDetail_EventsHintTank" xml:space="preserve">
<value>Tank levels and deliveries drive this tank's consumption.</value>
</data>
<data name="MeterDetail_Flags" xml:space="preserve">
<value>Flags</value>
</data>
@@ -1047,6 +1149,18 @@
<data name="MeterDetail_FutureTime" xml:space="preserve">
<value>That time is in the future.</value>
</data>
<data name="MeterDetail_GetStarted" xml:space="preserve">
<value>Nothing recorded for this meter yet — start with a first value.</value>
</data>
<data name="MeterDetail_GoToEvents" xml:space="preserve">
<value>Go to events</value>
</data>
<data name="MeterDetail_Imported" xml:space="preserve">
<value>Imported</value>
</data>
<data name="MeterDetail_ImportedEventHint" xml:space="preserve">
<value>Came from an import — revert that import to remove it.</value>
</data>
<data name="MeterDetail_Kind" xml:space="preserve">
<value>Kind</value>
</data>
@@ -1071,6 +1185,9 @@
<data name="MeterDetail_LocalTimeIn" xml:space="preserve">
<value>Local time in {0}.</value>
</data>
<data name="MeterDetail_ManageTariffs" xml:space="preserve">
<value>Manage tariffs</value>
</data>
<data name="MeterDetail_MeterGone" xml:space="preserve">
<value>This meter no longer exists.</value>
</data>
@@ -1093,7 +1210,7 @@
<value>No normalized consumption yet.</value>
</data>
<data name="MeterDetail_NoEvents" xml:space="preserve">
<value>No events (swaps, deliveries, corrections).</value>
<value>No events recorded yet.</value>
</data>
<data name="MeterDetail_NoRawReadings" xml:space="preserve">
<value>No raw readings.</value>
@@ -1104,6 +1221,9 @@
<data name="MeterDetail_NoSources" xml:space="preserve">
<value>No ingest sources bound to this meter. Add one to pull from MQTT/Tasmota or Home Assistant.</value>
</data>
<data name="MeterDetail_NoTankConfigured" xml:space="preserve">
<value>No tank set up for this meter yet — its level, fill and forecast need the tank's capacity and dipstick calibration.</value>
</data>
<data name="MeterDetail_NoTariffs" xml:space="preserve">
<value>No applicable tariffs.</value>
</data>
@@ -1149,11 +1269,14 @@
<data name="MeterDetail_Quality" xml:space="preserve">
<value>Quality</value>
</data>
<data name="MeterDetail_ReadingDeleted" xml:space="preserve">
<value>Reading deleted — consumption recomputed.</value>
</data>
<data name="MeterDetail_ReadingLabel" xml:space="preserve">
<value>Reading ({0})</value>
</data>
<data name="MeterDetail_ReadingRejected" xml:space="preserve">
<value>Rejected — below the previous reading on a register that only counts up. Record a counter reset or meter swap first.</value>
<value>Rejected — below the previous reading on a register that only counts up. If the meter was swapped or reset, record that first.</value>
</data>
<data name="MeterDetail_ReadingReplaced" xml:space="preserve">
<value>Replaced the reading at that time with {0} {1}.</value>
@@ -1170,6 +1293,18 @@
<data name="MeterDetail_RecentReadingsCaption" xml:space="preserve">
<value>Most recent {0} (raw, immutable audit truth). Times in {1}.</value>
</data>
<data name="MeterDetail_RecordEvent" xml:space="preserve">
<value>Record event</value>
</data>
<data name="MeterDetail_RecordReset" xml:space="preserve">
<value>Record counter reset</value>
</data>
<data name="MeterDetail_RecordSwap" xml:space="preserve">
<value>Record meter swap</value>
</data>
<data name="MeterDetail_RecordTankLevel" xml:space="preserve">
<value>Record tank level</value>
</data>
<data name="MeterDetail_RegisterDetails" xml:space="preserve">
<value>Meter register details</value>
</data>
@@ -1179,6 +1314,9 @@
<data name="MeterDetail_ReplaceNotice" xml:space="preserve">
<value>This meter already has a reading at that time — saving replaces its value.</value>
</data>
<data name="MeterDetail_ReplaceSwapStartNotice" xml:space="preserve">
<value>Replaces the new meter's start value recorded with the swap — consumption across the swap stays correct.</value>
</data>
<data name="MeterDetail_Retired" xml:space="preserve">
<value>retired</value>
</data>
@@ -1191,6 +1329,15 @@
<data name="MeterDetail_Scale" xml:space="preserve">
<value>Scale</value>
</data>
<data name="MeterDetail_ScopeThisMeter" xml:space="preserve">
<value>This meter</value>
</data>
<data name="MeterDetail_SerialValue" xml:space="preserve">
<value>S/N {0}</value>
</data>
<data name="MeterDetail_SetUpTank" xml:space="preserve">
<value>Set up tank</value>
</data>
<data name="MeterDetail_SkippedTime" xml:space="preserve">
<value>That clock time never happened in {0} — the clocks moved forward. Pick another time.</value>
</data>
@@ -1203,6 +1350,9 @@
<data name="MeterDetail_SourceType" xml:space="preserve">
<value>Source type</value>
</data>
<data name="MeterDetail_SwappedOrResetHint" xml:space="preserve">
<value>Below last reading — swapped or reset?</value>
</data>
<data name="MeterDetail_TabConsumption" xml:space="preserve">
<value>Consumption ({0})</value>
</data>
@@ -1218,6 +1368,9 @@
<data name="MeterDetail_TabTariffs" xml:space="preserve">
<value>Tariffs ({0})</value>
</data>
<data name="MeterDetail_TankUsesEvents" xml:space="preserve">
<value>A tank's consumption comes from tank levels and deliveries, which are recorded as events — a reading entered here would change nothing.</value>
</data>
<data name="MeterDetail_TariffOpenEnd" xml:space="preserve">
<value>open</value>
</data>
@@ -1243,13 +1396,13 @@
<value>Value path (e.g. ENERGY.Total; blank = bare scalar)</value>
</data>
<data name="MeterDetail_VirtualNoReadings" xml:space="preserve">
<value>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.</value>
<value>A virtual meter has no readings of its own — enter readings on the meters it adds up.</value>
</data>
<data name="MeterDetail_VirtualNotice" xml:space="preserve">
<value>Virtual meter — its value is an expression over other meters, evaluated when read, so it has no stored series of its own.</value>
<value>Virtual meter — it has no readings of its own; its value is the sum of the upstream meters linked to it.</value>
</data>
<data name="MeterDetail_VirtualNoticeTrends" xml:space="preserve">
<value>See Trends for its figures.</value>
<data name="MeterDetail_VirtualNoticeFlow" xml:space="preserve">
<value>See it in the flow view.</value>
</data>
<data name="MeterDetail_VsLastMonth" xml:space="preserve">
<value>vs last month</value>
@@ -1257,18 +1410,189 @@
<data name="MeterDetail_VsLastYear" xml:space="preserve">
<value>{0} vs {1} last year</value>
</data>
<data name="MeterDetail_WillBeRejectedSuffix" xml:space="preserve">
<value> — will be rejected</value>
</data>
<data name="MeterDetail_Yes" xml:space="preserve">
<value>yes</value>
</data>
<data name="MeterEvent_ActionFailed" xml:space="preserve">
<value>That did not go through, and nothing was changed. Try again; the details are in the log.</value>
</data>
<data name="MeterEvent_AfterResetLabel" xml:space="preserve">
<value>Register after the reset ({0})</value>
</data>
<data name="MeterEvent_BeforeResetHelp" xml:space="preserve">
<value>Its last value before it started over. Leave empty if unknown — the stretch since the last reading is then not counted.</value>
</data>
<data name="MeterEvent_BeforeResetLabel" xml:space="preserve">
<value>Register before the reset ({0})</value>
</data>
<data name="MeterEvent_CountsFrom" xml:space="preserve">
<value>Later readings count on from {0} {1}.</value>
</data>
<data name="MeterEvent_DeliveredLabel" xml:space="preserve">
<value>Delivered ({0})</value>
</data>
<data name="MeterEvent_DeliveredSince" xml:space="preserve">
<value>Delivered since then: {0} {1}</value>
</data>
<data name="MeterEvent_FirstLevel" xml:space="preserve">
<value>First level for this tank — consumption is counted from the next one.</value>
</data>
<data name="MeterEvent_IntroDelivery" xml:space="preserve">
<value>A refill of the tank. It raises the level; the next tank level turns the difference into consumption.</value>
</data>
<data name="MeterEvent_IntroNote" xml:space="preserve">
<value>A remark on this meter's history, e.g. a repair or a moved sensor. It changes no figures.</value>
</data>
<data name="MeterEvent_IntroReset" xml:space="preserve">
<value>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.</value>
</data>
<data name="MeterEvent_IntroSwap" xml:space="preserve">
<value>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.</value>
</data>
<data name="MeterEvent_IntroTankLevel" xml:space="preserve">
<value>A dipstick or gauge reading of what is in the tank. Consumption is the difference to the previous level, plus the deliveries in between.</value>
</data>
<data name="MeterEvent_LastLevel" xml:space="preserve">
<value>Last level: {0} ({1} {2}) on {3}</value>
</data>
<data name="MeterEvent_LastReadingBefore" xml:space="preserve">
<value>Last reading before: {0} {1} on {2}</value>
</data>
<data name="MeterEvent_LevelLabel" xml:space="preserve">
<value>Tank level</value>
</data>
<data name="MeterEvent_LevelRose" xml:space="preserve">
<value>The level rose {0} {1} more than the recorded deliveries — is a delivery missing? It is booked as no consumption.</value>
</data>
<data name="MeterEvent_LevelVolume" xml:space="preserve">
<value>= {0} {1} in the tank</value>
</data>
<data name="MeterEvent_LiveSourcesWarning" xml:space="preserve">
<value>{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.</value>
</data>
<data name="MeterEvent_NewStartHelp" xml:space="preserve">
<value>Usually 0, or the value on the installation record.</value>
</data>
<data name="MeterEvent_NewStartLabel" xml:space="preserve">
<value>New meter — start reading ({0})</value>
</data>
<data name="MeterEvent_NoCalibrationHint" xml:space="preserve">
<value>Centimetres need the tank's dipstick calibration — set it up under Edit meter.</value>
</data>
<data name="MeterEvent_NoReadingBefore" xml:space="preserve">
<value>No earlier reading — the old register counts from the meter's initial baseline, {0} {1}.</value>
</data>
<data name="MeterEvent_NotANumber" xml:space="preserve">
<value>Not a number</value>
</data>
<data name="MeterEvent_NoteLabel" xml:space="preserve">
<value>Note</value>
</data>
<data name="MeterEvent_NotesOptional" xml:space="preserve">
<value>Note (optional)</value>
</data>
<data name="MeterEvent_OldFinalHelp" xml:space="preserve">
<value>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.</value>
</data>
<data name="MeterEvent_OldFinalLabel" xml:space="preserve">
<value>Old meter — final reading ({0})</value>
</data>
<data name="MeterEvent_ProblemAlreadyRecorded" xml:space="preserve">
<value>A swap or reset is already recorded between the same two readings — delete it first, or pick a time after the next reading.</value>
</data>
<data name="MeterEvent_ProblemAmountOutOfRange" xml:space="preserve">
<value>That value is not possible here — a delivery must be above zero, and a level cannot be negative.</value>
</data>
<data name="MeterEvent_ProblemAmountRequired" xml:space="preserve">
<value>Enter an amount.</value>
</data>
<data name="MeterEvent_ProblemBelowPrevious" xml:space="preserve">
<value>The old register's final reading is below where it last stood (its last reading, or the meter's initial baseline) — check both numbers.</value>
</data>
<data name="MeterEvent_ProblemImported" xml:space="preserve">
<value>This came from an import — revert that import to remove it.</value>
</data>
<data name="MeterEvent_ProblemLaterBoundary" xml:space="preserve">
<value>A later meter swap or counter reset was recorded against this — delete that one first.</value>
</data>
<data name="MeterEvent_ProblemLevelAtSameTime" xml:space="preserve">
<value>A tank level is already recorded at exactly this time — delete that one first, or pick another minute.</value>
</data>
<data name="MeterEvent_ProblemNeedsCalibration" xml:space="preserve">
<value>A level in centimetres needs the tank's calibration — set it up under Edit meter, or enter the volume.</value>
</data>
<data name="MeterEvent_ProblemNotForMode" xml:space="preserve">
<value>This event does not apply to a meter in this measurement mode.</value>
</data>
<data name="MeterEvent_ProblemNotFound" xml:space="preserve">
<value>It no longer exists — the page was out of date.</value>
</data>
<data name="MeterEvent_ProblemNotManual" xml:space="preserve">
<value>Only hand-entered readings can be deleted here — measured readings are the audit record, and imported ones go with their import.</value>
</data>
<data name="MeterEvent_ProblemNoteRequired" xml:space="preserve">
<value>Enter the note.</value>
</data>
<data name="MeterEvent_ProblemReadingAtSameTime" xml:space="preserve">
<value>There is already a reading at exactly this time — pick a minute before or after it, depending on which meter it belongs to.</value>
</data>
<data name="MeterEvent_ProblemStartRejected" xml:space="preserve">
<value>The new register's start reading could not be stored because a reading arrived meanwhile. Nothing was saved — try again.</value>
</data>
<data name="MeterEvent_ReadingsAfterWarning" xml:space="preserve">
<value>{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.</value>
</data>
<data name="MeterEvent_SavedDelivery" xml:space="preserve">
<value>Delivery recorded — consumption recomputed.</value>
</data>
<data name="MeterEvent_SavedNote" xml:space="preserve">
<value>Note saved.</value>
</data>
<data name="MeterEvent_SavedReset" xml:space="preserve">
<value>Counter reset recorded — consumption recomputed.</value>
</data>
<data name="MeterEvent_SavedSwap" xml:space="preserve">
<value>Meter swap recorded — consumption recomputed.</value>
</data>
<data name="MeterEvent_SavedTankLevel" xml:space="preserve">
<value>Tank level recorded — consumption recomputed.</value>
</data>
<data name="MeterEvent_TailBooked" xml:space="preserve">
<value>{0} {1} since that reading, booked at this time.</value>
</data>
<data name="MeterEvent_Title" xml:space="preserve">
<value>{0} — {1}</value>
</data>
<data name="MeterEvent_UsedSince" xml:space="preserve">
<value>Used since the last level: {0} {1}</value>
</data>
<data name="MeterSearch_More" xml:space="preserve">
<value>Showing {0} of {1} — keep typing to narrow down.</value>
</data>
<data name="MeterSearch_NoMeters" xml:space="preserve">
<value>No meters yet.</value>
</data>
<data name="MeterSearch_Placeholder" xml:space="preserve">
<value>Name, serial number or location</value>
</data>
<data name="Meters_Active" xml:space="preserve">
<value>Active</value>
</data>
<data name="Meters_AddMeter" xml:space="preserve">
<value>Add meter</value>
</data>
<data name="Meters_ChangedMeanwhile" xml:space="preserve">
<value>Something changed while this dialog was open (a meter or category was deleted). Nothing was saved — check and save again.</value>
</data>
<data name="Meters_CostCategories" xml:space="preserve">
<value>Cost categories</value>
</data>
<data name="Meters_CostCategoriesHelp" xml:space="preserve">
<value>The dashboard reports cost per category; a meter in none has no cost there.</value>
</data>
<data name="Meters_CostCategoriesInherited" xml:space="preserve">
<value>Already counted through its energy type in: {0}</value>
</data>
<data name="Meters_DeleteConfirm" xml:space="preserve">
<value>Delete '{0}'? This cannot be undone.</value>
</data>
@@ -1317,24 +1641,69 @@
<data name="Meters_No" xml:space="preserve">
<value>no</value>
</data>
<data name="Meters_NoCostCategories" xml:space="preserve">
<value>No cost categories yet, so this meter's cost will not show on the dashboard. Create one in</value>
</data>
<data name="Meters_NoSearchMatch" xml:space="preserve">
<value>No meter matches “{0}”.</value>
</data>
<data name="Meters_PvRole" xml:space="preserve">
<value>PV role (optional)</value>
</data>
<data name="Meters_PvRoleHelp" xml:space="preserve">
<value>Tag the house meter total_load and the grid meter grid_import to unlock self-consumption, autarky and savings on the Solar page.</value>
</data>
<data name="Meters_RecomputeNotice" xml:space="preserve">
<value>Mode/baseline changed — consumption will be recomputed on save.</value>
<value>Settings that affect consumption changed — it will be recomputed on save.</value>
</data>
<data name="Meters_RecordSwapInstead" xml:space="preserve">
<value>Record meter swap instead</value>
</data>
<data name="Meters_RequiredFields" xml:space="preserve">
<value>Name, energy type and unit are required.</value>
</data>
<data name="Meters_RetireSwapHint" xml:space="preserve">
<value>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.</value>
</data>
<data name="Meters_RoleNone" xml:space="preserve">
<value>— none —</value>
</data>
<data name="Meters_SearchPlaceholder" xml:space="preserve">
<value>Search by name, serial number, location or type</value>
</data>
<data name="Meters_SerialNumber" xml:space="preserve">
<value>Serial number (optional)</value>
</data>
<data name="Meters_Sources" xml:space="preserve">
<value>Sources</value>
</data>
<data name="Meters_TankCapacity" xml:space="preserve">
<value>Capacity</value>
</data>
<data name="Meters_TankCapacityRequired" xml:space="preserve">
<value>Enter the tank's capacity to set up its tank.</value>
</data>
<data name="Meters_TankFixedRate" xml:space="preserve">
<value>Fixed rate ({0}/h)</value>
</data>
<data name="Meters_TankHelp" xml:space="preserve">
<value>Capacity sets the fill level; the calibration turns dipstick centimetres into volume. Levels and deliveries are then recorded on the meter's Events tab.</value>
</data>
<data name="Meters_TankOffset" xml:space="preserve">
<value>Offset ({0})</value>
</data>
<data name="Meters_TankRateMode" xml:space="preserve">
<value>Burner rate</value>
</data>
<data name="Meters_TankSection" xml:space="preserve">
<value>Tank</value>
</data>
<data name="Meters_TankVolumePerCm" xml:space="preserve">
<value>{0} per cm</value>
</data>
<data name="Meters_TankVolumePerCmHelp" xml:space="preserve">
<value>e.g. 7000 L ÷ 150 cm = 46.67. Leave empty if levels are entered as volume.</value>
</data>
<data name="Meters_UpstreamHelp" xml:space="preserve">
<value>This meter measures a subsection of the selected meter(s)' flow.</value>
</data>
@@ -1371,6 +1740,12 @@
<data name="Nav_Overview" xml:space="preserve">
<value>Overview</value>
</data>
<data name="Nav_SectionData" xml:space="preserve">
<value>Meters &amp; data</value>
</data>
<data name="Nav_SectionEnergy" xml:space="preserve">
<value>Energy</value>
</data>
<data name="Nav_Settings" xml:space="preserve">
<value>Settings</value>
</data>
@@ -1506,8 +1881,11 @@
<data name="Solar_NoGenerationLead" xml:space="preserve">
<value>No generation meters found. Add a meter with mode</value>
</data>
<data name="Solar_NoGenerationOrImport" xml:space="preserve">
<value>, or load the reference data from</value>
</data>
<data name="Solar_NoGenerationTail" xml:space="preserve">
<value>or load the reference data from</value>
<value>in</value>
</data>
<data name="Solar_Savings" xml:space="preserve">
<value>Savings (Ersparnis)</value>
@@ -1519,13 +1897,13 @@
<value>{0}% of generation</value>
</data>
<data name="Solar_TagMetersLead" xml:space="preserve">
<value>Tag a meter</value>
<value>Set the PV role of your house meter to</value>
</data>
<data name="Solar_TagMetersMid" xml:space="preserve">
<value>and one</value>
<value>and of your grid meter to</value>
</data>
<data name="Solar_TagMetersTail" xml:space="preserve">
<value>(in meter metadata) to unlock self-consumption, autarky and savings.</value>
<value> to unlock self-consumption, autarky and savings — in the meter editor under</value>
</data>
<data name="Tariffs_AddTariff" xml:space="preserve">
<value>Add tariff</value>
+148
View File
@@ -0,0 +1,148 @@
using System.Globalization;
using MeterVault.Core.Domain;
namespace MeterVault.App;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// <c>/meters/{id}?tab=events&amp;action=swap</c> 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.
/// </remarks>
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";
/// <summary>Query keys that preset the source dialog: the source to edit, its type, its connector.</summary>
public const string ParamSource = "source";
public const string ParamSourceType = "type";
public const string ParamConnector = "connector";
/// <summary>Tab keys in the order the meter page renders its panels.</summary>
public static readonly IReadOnlyList<string> Tabs = [TabReadings, TabConsumption, TabEvents, TabTariffs, TabSources];
public static string Detail(int meterId, string? tab = null, string? action = null)
{
var query = new List<string>(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)}";
}
/// <summary>
/// The Sources tab with the source dialog open: a new source, or <paramref name="sourceId"/> 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.
/// </summary>
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;
}
/// <summary>
/// The connector page with a new connector of <paramref name="endpointType"/> open, for a source that
/// has none yet. Saving it leads back to the source dialog with the new connector picked.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public static string NewConnector(int meterId, int? sourceId, SourceType sourceType, EndpointType endpointType) =>
$"/admin/connectors?new={endpointType}{ReturnQuery(meterId, sourceId, sourceType)}";
/// <summary>The connector page with an existing connector open — to enable it — and the same way back.</summary>
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}";
/// <summary>The meter page's Events tab with the dialog for <paramref name="type"/> open.</summary>
public static string Event(int meterId, MeterEventType type) => Detail(meterId, TabEvents, ActionFor(type));
/// <summary>
/// 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.
/// </summary>
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",
};
/// <summary>The event an action names, or null if it names something else (or nothing).</summary>
public static MeterEventType? EventFor(string? action)
{
foreach (var type in Enum.GetValues<MeterEventType>())
{
if (string.Equals(ActionFor(type), action, StringComparison.OrdinalIgnoreCase))
{
return type;
}
}
return null;
}
/// <summary>The panel index for a tab key; unknown or missing keys open the first tab.</summary>
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;
}
}
+13
View File
@@ -0,0 +1,13 @@
namespace MeterVault.App;
/// <summary>
/// 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.
/// </summary>
public sealed class NavState
{
public event Action? EnergyTypesChanged;
public void NotifyEnergyTypesChanged() => EnergyTypesChanged?.Invoke();
}
+30 -3
View File
@@ -25,6 +25,8 @@ try
builder.Services.Configure<MeterVaultOptions>(
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<MeterVaultOptions>(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<MeterVault.App.NavState>();
builder.Services.AddScoped<MeterVault.App.DraftStore>();
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
@@ -147,9 +151,14 @@ finally
static async Task MigrateDatabaseAsync(WebApplication app)
{
var options = app.Configuration
.GetSection(MeterVaultOptions.SectionName)
.Get<MeterVaultOptions>() ?? new MeterVaultOptions();
var options = app.Services.GetRequiredService<Microsoft.Extensions.Options.IOptions<MeterVaultOptions>>().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<MeterVaultDbContext>();
await db.Database.MigrateAsync().ConfigureAwait(false);
await DatabaseSeeder.SeedAsync(db).ConfigureAwait(false);
var zoneKnownToDatabase = await db.Database
.SqlQuery<bool>($"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<MeterVault.Infrastructure.Normalization.NormalizationUpgrade>()
.RunAsync().ConfigureAwait(false);
if (rebuilt > 0)
{
Log.Information("Recomputed consumption for {Meters} meter(s) after a normalization change", rebuilt);
}
}
/// <summary>Exposed for WebApplicationFactory-based integration tests.</summary>
+5 -3
View File
@@ -46,9 +46,11 @@ public sealed class ReadingEntry
/// <summary>Seeds the buffer with a meter's last reading, marked pristine.</summary>
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;
}
+7
View File
@@ -53,6 +53,13 @@ public enum ReadingFlags
CounterReset = 1,
MeterSwap = 2,
Anomaly = 4,
/// <summary>
/// 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.
/// </summary>
MonthLabel = 8,
}
/// <summary>Discrete meter lifecycle/correction events (SDD meter_event.event_type).</summary>
+44
View File
@@ -0,0 +1,44 @@
namespace MeterVault.Core.Domain;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// <see cref="MeterEventType.Correction"/> is deliberately never offered: no normalizer reads it, so
/// recording one would look like a fix while leaving every derived number untouched.
/// </remarks>
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];
/// <summary>The event types worth recording for a meter in <paramref name="mode"/>, most common first.</summary>
public static IReadOnlyList<MeterEventType> 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);
/// <summary>A swap or reset: an instant where a register legitimately starts over.</summary>
public static bool IsRegisterBoundary(MeterEventType type) =>
type is MeterEventType.MeterSwap or MeterEventType.CounterReset;
/// <summary>
/// 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.
/// </summary>
public static bool TakesReadings(MeterMode mode) => mode is not (MeterMode.ConsumableBalance or MeterMode.Virtual);
/// <summary>Whether the register may only count up, so a lower value needs a swap or reset to explain it.</summary>
public static bool IsMonotonic(MeterMode mode) =>
mode is MeterMode.CumulativeCounter or MeterMode.GenerationCounter or MeterMode.RuntimeCounter;
}
+30
View File
@@ -0,0 +1,30 @@
namespace MeterVault.Core.Domain;
/// <summary>
/// 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.
/// </summary>
public static class SourceRouting
{
/// <summary>
/// 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.
/// </summary>
public static EndpointType? RequiredEndpoint(SourceType sourceType) => sourceType switch
{
SourceType.HomeAssistant => EndpointType.HomeAssistant,
SourceType.Mqtt or SourceType.Tasmota => EndpointType.MqttBroker,
_ => null,
};
/// <summary>Whether a connector of <paramref name="endpointType"/> can serve a source of <paramref name="sourceType"/>.</summary>
public static bool Serves(EndpointType endpointType, SourceType sourceType) =>
RequiredEndpoint(sourceType) == endpointType;
/// <summary>The source type a new source on a connector of this kind starts as.</summary>
public static SourceType DefaultSourceFor(EndpointType endpointType) => endpointType switch
{
EndpointType.HomeAssistant => SourceType.HomeAssistant,
_ => SourceType.Mqtt,
};
}
+176 -70
View File
@@ -1,80 +1,154 @@
using MeterVault.Core.Domain;
namespace MeterVault.Core.Normalization;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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
/// <em>two or more complete calendar months</em>. 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
/// <see cref="Domain.ReadingQuality.Estimated"/>. 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.
/// <para>
/// 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 <see cref="ReadingQuality.Estimated"/>; the parts always sum to the
/// original, so nothing is created or lost.
/// </para>
/// <para>
/// Imported monthly tables are the exception that keeps the golden fixtures reconciling (SDD §13). A
/// row labelled "Mai 2026" carries the register at the <em>end</em> 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 <see cref="ReadingFlags.MonthLabel"/>. <see cref="EffectiveTime"/>
/// reads such a <see cref="IsMonthLabel">month label</see> 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
public static class GapAttribution
{
/// <summary>
/// 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 (<see cref="ReadingFlags.MonthLabel"/>). 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).
/// </summary>
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);
}
/// <summary>
/// Divides <paramref name="amount"/> 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 <see cref="IsMonthLabel">month label</see>
/// the local midnight that ends the labelled month.
/// </summary>
/// <remarks>
/// Each segment is stamped at its <em>end</em>, 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.
/// </remarks>
public static IReadOnlyList<GapSegment> Split(DateTimeOffset start, DateTimeOffset end, double amount)
public static DateTimeOffset EffectiveTime(Reading reading, TimeZoneInfo zone)
{
if (end <= start)
{
return [new GapSegment(end, amount)];
ArgumentNullException.ThrowIfNull(reading);
ArgumentNullException.ThrowIfNull(zone);
return IsMonthLabel(reading)
? LocalMidnight(LabelledMonth(reading).AddMonths(1), zone)
: reading.Time;
}
var total = end - start;
/// <summary>
/// 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.
/// </summary>
public static DateTimeOffset StampTime(Reading reading, TimeZoneInfo zone)
{
ArgumentNullException.ThrowIfNull(reading);
ArgumentNullException.ThrowIfNull(zone);
if (!IsMonthLabel(reading))
{
return reading.Time;
}
var monthStart = LabelMonthStart(reading, zone);
return reading.Time >= monthStart && reading.Time < EffectiveTime(reading, zone) ? reading.Time : monthStart;
}
/// <summary>The local midnight that starts the month a label names.</summary>
public static DateTimeOffset LabelMonthStart(Reading reading, TimeZoneInfo zone)
{
ArgumentNullException.ThrowIfNull(reading);
ArgumentNullException.ThrowIfNull(zone);
return LocalMidnight(LabelledMonth(reading), zone);
}
/// <summary>
/// The instant a local calendar day starts in <paramref name="zone"/>, 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.
/// </summary>
public static DateTimeOffset LocalMidnight(DateOnly date, TimeZoneInfo zone)
{
ArgumentNullException.ThrowIfNull(zone);
return LocalMidnight(date.ToDateTime(TimeOnly.MinValue), zone);
}
/// <summary>
/// Divides <paramref name="amount"/>, accrued over <c>[from, to)</c>, across the local calendar
/// months it touches, in proportion to the time spent in each.
/// </summary>
/// <param name="closingStamp">
/// Where the closing reading's own row goes (<see cref="StampTime"/>). 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.
/// </param>
public static IReadOnlyList<GapSegment> 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<GapSegment>();
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)
/// <summary>The month a label names — its UTC month, which is what the importer wrote.</summary>
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);
}
/// <summary>The last second of the month, or the segment's midpoint when the segment is shorter than that.</summary>
private static DateTimeOffset InsideSegment(DateTimeOffset start, DateTimeOffset end, DateTimeOffset monthEnd)
{
whole++;
cursor = cursor.AddMonths(1);
var lastSecond = (end < monthEnd ? end : monthEnd).AddSeconds(-1);
return lastSecond > start ? lastSecond : start + ((end - start) / 2);
}
return whole;
}
private static DateTimeOffset MonthStart(DateTimeOffset instant)
private static DateTimeOffset MonthStartContaining(DateTimeOffset instant, TimeZoneInfo zone)
{
var utc = instant.ToUniversalTime();
return new DateTimeOffset(utc.Year, utc.Month, 1, 0, 0, 0, TimeSpan.Zero);
var local = TimeZoneInfo.ConvertTime(instant, zone);
return LocalMidnight(new DateTime(local.Year, local.Month, 1), zone);
}
private static DateTimeOffset NextMonthStart(DateTimeOffset instant) => MonthStart(instant).AddMonths(1);
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);
}
/// <summary>One month's share of a spread gap: the instant it closes and the amount attributed.</summary>
/// <summary>
/// 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.
/// </summary>
private static DateTimeOffset LocalMidnight(DateTime date, TimeZoneInfo zone)
{
var day = DateTime.SpecifyKind(date.Date, DateTimeKind.Unspecified);
var wall = day;
while (zone.IsInvalidTime(wall))
{
wall = wall.AddMinutes(30);
}
// 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();
// 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);
}
return instant;
}
}
/// <summary>One month's share of an interval: the instant it is stamped at and the amount attributed.</summary>
public sealed record GapSegment(DateTimeOffset Time, double Amount);
@@ -16,6 +16,12 @@ public sealed class NormalizationContext
public IReadOnlyList<MeterEvent> Events { get; init; } = [];
/// <summary>
/// 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.
/// </summary>
public TimeZoneInfo TimeZone { get; init; } = TimeZoneInfo.Utc;
/// <summary>Referenced meters' consumption series, keyed by meter id (virtual meters only).</summary>
public IReadOnlyDictionary<int, IReadOnlyList<Consumption>> ReferencedSeries { get; init; }
= new Dictionary<int, IReadOnlyList<Consumption>>();
+28 -1
View File
@@ -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));
}
/// <summary>
/// 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.
/// </summary>
private static List<Consumption> Coalesce(IEnumerable<Consumption> 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)];
}
}
@@ -10,14 +10,21 @@ namespace MeterVault.Core.Normalization.Normalizers;
/// spreadsheet's month-one full register, e.g. Haus 411, Auto 3755);</item>
/// <item>meter swap → an explicit <c>Amount</c> override if given (how the water swap …861→2
/// reconciles to 12), otherwise <c>(oldFinal prev) + (curr newInitial)</c>;</item>
/// <item>counter reset → baseline restarts at <c>NewValue</c> (default 0);</item>
/// <item>counter reset → baseline restarts at <c>NewValue</c> (default 0); a <c>PrevValue</c>, 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;</item>
/// <item>unexplained decrease → 0 with an anomaly flagged (never a silent negative), rebaselined
/// to the current value;</item>
/// <item>a plain increase spanning two or more whole calendar months → apportioned across them
/// and marked estimated (<see cref="GapAttribution"/>), so an unread stretch does not land wholly
/// in its closing month. A monthly cadence never triggers this.</item>
/// <item>a plain increase over an interval that crosses a local month boundary → divided across
/// those months by elapsed time and marked estimated (<see cref="GapAttribution"/>), 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.</item>
/// </list>
/// </summary>
/// <remarks>
/// "Ascending" is the order of <see cref="ReadingTimeline"/>: an imported month row describes the
/// register at the end of its month, so it comes after live readings taken during that month.
/// </remarks>
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,15 +74,16 @@ 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))
foreach (var segment in segments)
{
yield return new Consumption
{
@@ -86,41 +91,15 @@ public abstract class CounterNormalizerBase : IMeterNormalizer
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
{
yield return new Consumption
{
MeterId = context.Meter.MeterId,
Time = reading.Time,
Amount = 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<MeterEvent> 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;
}
}
@@ -4,7 +4,8 @@ namespace MeterVault.Core.Normalization.Normalizers;
/// <summary>
/// The source already reports increments (SDD §5.2 <c>direct_delta</c>): 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 (<see cref="GapAttribution.StampTime"/>).
/// </summary>
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,
@@ -0,0 +1,62 @@
using MeterVault.Core.Domain;
namespace MeterVault.Core.Normalization.Normalizers;
/// <summary>
/// The register math shared by every normalizer that reads a monotonic register: a
/// <see cref="MeterEventType.MeterSwap"/> or <see cref="MeterEventType.CounterReset"/> 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.
/// </summary>
internal static class RegisterBoundary
{
/// <summary>The swap/reset events of a meter, oldest first — the only events that move a register.</summary>
public static List<MeterEvent> Of(IEnumerable<MeterEvent> events) =>
events
.Where(e => e.EventType is MeterEventType.MeterSwap or MeterEventType.CounterReset)
.OrderBy(e => e.Time)
.ToList();
/// <summary>
/// The first boundary inside the reading interval <c>(afterExclusive, upToInclusive]</c>. 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.
/// </summary>
public static MeterEvent? Find(List<MeterEvent> boundaries, DateTimeOffset? afterExclusive, DateTimeOffset upToInclusive) =>
Find(boundaries, afterExclusive, upToInclusive, e => e.Time);
/// <summary>
/// As <see cref="Find(List{MeterEvent}, DateTimeOffset?, DateTimeOffset)"/>, placing each boundary on the
/// timeline at <paramref name="timeOf"/> — for a series walked in effective time rather than stamps.
/// </summary>
public static MeterEvent? Find(
List<MeterEvent> boundaries, DateTimeOffset? afterExclusive, DateTimeOffset upToInclusive, Func<MeterEvent, DateTimeOffset> 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;
}
/// <summary>
/// How far the register moved across a boundary: the old register from the previous reading up to
/// its final value (<see cref="MeterEvent.PrevValue"/>, defaulting to the previous reading, i.e. no
/// tail), plus the new register from its start value (<see cref="MeterEvent.NewValue"/>, default 0)
/// up to the current reading.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public static double Advance(MeterEvent boundary, double previous, double current) =>
((boundary.PrevValue ?? previous) - previous) + (current - Math.Min(boundary.NewValue ?? 0, current));
}
@@ -9,6 +9,15 @@ namespace MeterVault.Core.Normalization.Normalizers;
/// the tank level-Δ (<see cref="ConsumableBalanceNormalizer"/>); a runtime meter's Δhours feeds
/// the empirical L/h analytic and is not double-counted as litres.
/// </summary>
/// <remarks>
/// An hour counter is a register like any other, so a replaced burner or a reset counter is a
/// <see cref="MeterEventType.MeterSwap"/> / <see cref="MeterEventType.CounterReset"/> handled with the
/// same register math as <see cref="CounterNormalizerBase"/>: 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 <see cref="ReadingTimeline"/> 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.
/// </remarks>
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,
+71
View File
@@ -0,0 +1,71 @@
using MeterVault.Core.Domain;
namespace MeterVault.Core.Normalization;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// <para>
/// The order is effective time (<see cref="GapAttribution.EffectiveTime"/>): 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
public sealed class ReadingTimeline
{
private readonly Dictionary<DateTimeOffset, DateTimeOffset> _labelMonthStarts;
private ReadingTimeline(IReadOnlyList<TimelineReading> readings, Dictionary<DateTimeOffset, DateTimeOffset> labelMonthStarts)
{
Readings = readings;
_labelMonthStarts = labelMonthStarts;
}
/// <summary>The readings, oldest first by effective time, then by stamp.</summary>
public IReadOnlyList<TimelineReading> Readings { get; }
public static ReadingTimeline Build(IEnumerable<Reading> 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<DateTimeOffset, DateTimeOffset>();
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);
}
/// <summary>
/// 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.
/// </summary>
public DateTimeOffset BoundaryTime(MeterEvent boundary)
{
ArgumentNullException.ThrowIfNull(boundary);
return _labelMonthStarts.TryGetValue(boundary.Time, out var monthStart) ? monthStart.AddTicks(1) : boundary.Time;
}
}
/// <summary>A reading and the instant it describes.</summary>
public readonly record struct TimelineReading(Reading Reading, DateTimeOffset Effective);
+18 -7
View File
@@ -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.
/// </summary>
public sealed class CostService(IDbContextFactory<MeterVaultDbContext> contextFactory)
public sealed class CostService(
IDbContextFactory<MeterVaultDbContext> contextFactory, IOptions<MeterVaultOptions>? options = null)
{
private readonly IDbContextFactory<MeterVaultDbContext> _contextFactory = contextFactory;
/// <summary>
/// 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.
/// </summary>
private readonly string _timeZone = (options?.Value ?? new MeterVaultOptions()).TimeZone;
private readonly TimeZoneInfo _zone = InstanceTimeZone.Resolve((options?.Value ?? new MeterVaultOptions()).TimeZone);
public async Task<IReadOnlyList<MeterCostBucket>> GetMeterCostsAsync(
int meterId, DateTimeOffset from, DateTimeOffset to, CostBucket bucket = CostBucket.Month,
CancellationToken cancellationToken = default)
@@ -31,7 +41,7 @@ public sealed class CostService(IDbContextFactory<MeterVaultDbContext> 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<MeterCostBucket>();
foreach (var period in series.Keys.OrderBy(k => k))
@@ -85,8 +95,9 @@ public sealed class CostService(IDbContextFactory<MeterVaultDbContext> 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<MeterVaultDbContext> contextFa
}
private static async Task<Dictionary<DateOnly, (double Consumption, double Generation)>> 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<MeterVaultDbContext> 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<ConsumptionRow>(command).ConfigureAwait(false);
var result = new Dictionary<DateOnly, (double, double)>();
@@ -5,6 +5,9 @@ namespace MeterVault.Infrastructure.Dashboard;
/// <summary>One recorded delivery into a consumable store.</summary>
public sealed record DeliveryRow(DateTimeOffset Time, double Amount, string? Unit);
/// <summary>A consumable-balance meter with no tank yet, so it has no level, fill or forecast to show.</summary>
public sealed record UnconfiguredConsumable(int MeterId, string Name);
/// <summary>One month of consumable draw.</summary>
public sealed record ConsumableMonth(DateOnly Period, double Consumption);
@@ -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 <see cref="MeterMode.RuntimeCounter"/> meters. DbContext
/// factory keeps it Blazor-circuit safe.
/// </summary>
public sealed class ConsumableService(IDbContextFactory<MeterVaultDbContext> contextFactory, CostService costService)
public sealed class ConsumableService(
IDbContextFactory<MeterVaultDbContext> contextFactory, CostService costService, IOptions<MeterVaultOptions>? options = null)
{
private readonly IDbContextFactory<MeterVaultDbContext> _contextFactory = contextFactory;
private readonly CostService _costService = costService;
/// <summary>
/// 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.
/// </summary>
private readonly string _timeZone = (options?.Value ?? new MeterVaultOptions()).TimeZone;
private readonly TimeZoneInfo _zone = InstanceTimeZone.Resolve((options?.Value ?? new MeterVaultOptions()).TimeZone);
public async Task<IReadOnlyList<ConsumableSummary>> GetConsumablesAsync(
DateOnly from, DateOnly to, CancellationToken cancellationToken = default)
{
@@ -44,12 +54,28 @@ public sealed class ConsumableService(IDbContextFactory<MeterVaultDbContext> con
return summaries;
}
/// <summary>
/// Consumable-balance meters that have no tank row. <see cref="GetConsumablesAsync"/> 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.
/// </summary>
public async Task<IReadOnlyList<UnconfiguredConsumable>> 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<ConsumableSummary> 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<MeterVaultDbContext> 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<MeterVaultDbContext> con
.SumAsync(c => (double?)c.Amount, cancellationToken).ConfigureAwait(false) ?? 0;
private static async Task<IReadOnlyList<ConsumableMonth>> 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<MeterVaultDbContext> 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);
}
@@ -17,6 +17,35 @@ public sealed record DashboardSummary(DateOnly AsOf, CostKpi Month, CostKpi Year
/// <summary>One slice of the cost breakdown / "what costs most" view.</summary>
public sealed record CategorySlice(string Name, string? ColorHex, double Cost);
/// <summary>The first thing missing before the dashboard can show a cost, in the order they are set up.</summary>
public enum CostSetupGap
{
None,
NoMeters,
NoCategories,
NoMembers,
NoTariffs,
}
/// <summary>
/// What exists of the chain a cost figure needs: a meter, a category that counts it, and a price.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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;
}
/// <summary>A point on a monthly cost/consumption trend.</summary>
public sealed record TrendPoint(DateOnly Period, double Cost);
@@ -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.
/// </summary>
public sealed class DashboardService(IDbContextFactory<MeterVaultDbContext> contextFactory, CostService costService)
public sealed class DashboardService(
IDbContextFactory<MeterVaultDbContext> contextFactory, CostService costService, IOptions<MeterVaultOptions>? options = null)
{
private readonly IDbContextFactory<MeterVaultDbContext> _contextFactory = contextFactory;
private readonly CostService _costService = costService;
/// <summary>
/// The instance timezone: periods are local months, so their bounds are local midnights — the same
/// months consumption is divided and bucketed in.
/// </summary>
private readonly TimeZoneInfo _zone = InstanceTimeZone.Resolve((options?.Value ?? new MeterVaultOptions()).TimeZone);
public async Task<DashboardSummary> 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<MeterVaultDbContext> cont
return new DashboardSummary(asOf, month, year, latest);
}
/// <summary>
/// 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.
/// </summary>
public async Task<CostSetup> 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<IReadOnlyList<CategorySlice>> GetCategoryBreakdownAsync(
DateOnly from, DateOnly to, CancellationToken cancellationToken = default)
{
@@ -124,12 +151,13 @@ public sealed class DashboardService(IDbContextFactory<MeterVaultDbContext> 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<List<int>> 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);
}
+9 -4
View File
@@ -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.
/// </summary>
public sealed class FlowService(IDbContextFactory<MeterVaultDbContext> contextFactory)
public sealed class FlowService(
IDbContextFactory<MeterVaultDbContext> contextFactory, IOptions<MeterVaultOptions>? options = null)
{
private const double Epsilon = 0.01;
private readonly IDbContextFactory<MeterVaultDbContext> _contextFactory = contextFactory;
/// <summary>The instance timezone a requested date range starts and ends in.</summary>
private readonly TimeZoneInfo _zone = InstanceTimeZone.Resolve((options?.Value ?? new MeterVaultOptions()).TimeZone);
public async Task<FlowGraph> 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<MeterVaultDbContext> 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<MeterVaultDbContext> 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);
}
@@ -55,8 +55,8 @@ public sealed record MeterPeriodView(
previous <= 1e-9 ? null : (current - previous) / previous;
}
/// <summary>A meter lifecycle/correction event row.</summary>
public sealed record EventRow(DateTimeOffset Time, MeterEventType Type, double? Amount, double? PrevValue, double? NewValue, string? Unit, string? Notes);
/// <summary>A meter lifecycle/correction event row. <see cref="ImportBatchId"/> marks one only its batch can remove.</summary>
public sealed record EventRow(int Id, DateTimeOffset Time, MeterEventType Type, double? Amount, double? PrevValue, double? NewValue, string? Unit, string? Notes, int? ImportBatchId);
/// <summary>A tariff applicable to the meter (own / energy-type / global scope), for the timeline.</summary>
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<ConsumptionDetailRow> RecentConsumption,
IReadOnlyList<EventRow> Events,
IReadOnlyList<TariffRow> Tariffs,
int SourceCount);
int SourceCount,
short EnergyTypeId,
bool HasTank);
@@ -59,8 +59,8 @@ public sealed class MeterDetailService(IDbContextFactory<MeterVaultDbContext> 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<MeterVaultDbContext> 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);
}
}
+20 -11
View File
@@ -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;
/// <see cref="MeterMode.GenerationCounter"/> meter; self-consumption / autarky / savings are derived
/// from the meters tagged <see cref="MeterRoles.TotalLoad"/> and <see cref="MeterRoles.GridImport"/>
/// — 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.
/// </summary>
public sealed class SolarService(IDbContextFactory<MeterVaultDbContext> contextFactory)
public sealed class SolarService(
IDbContextFactory<MeterVaultDbContext> contextFactory, IOptions<MeterVaultOptions>? options = null)
{
private readonly IDbContextFactory<MeterVaultDbContext> _contextFactory = contextFactory;
/// <summary>
/// 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.
/// </summary>
private readonly string _timeZone = (options?.Value ?? new MeterVaultOptions()).TimeZone;
private readonly TimeZoneInfo _zone = InstanceTimeZone.Resolve((options?.Value ?? new MeterVaultOptions()).TimeZone);
public async Task<SolarSummary> 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<MeterVaultDbContext> 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<int, IReadOnlyDictionary<DateOnly, double>>();
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<MeterVaultDbContext> contextF
}
private static async Task<IReadOnlyDictionary<DateOnly, double>> 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);
}
@@ -28,10 +28,12 @@ public static class DependencyInjection
services.AddSingleton<INormalizationEngine>(_ => NormalizationEngine.CreateDefault());
services.AddScoped<NormalizationService>();
services.AddScoped<NormalizationUpgrade>();
services.AddScoped<CsvImporter>();
services.AddScoped<ImportService>();
services.AddScoped<ReferenceDataImporter>();
services.AddScoped<IngestionService>();
services.AddScoped<MeterEventService>();
services.AddScoped<MqttMessageRouter>();
// HttpClient + HA tester are available even with live ingestion off, so the admin
// "Test connection" works without the background workers running.
+28 -11
View File
@@ -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<string> row, DateTimeOffset time,
private static void StageColumn(ColumnMapping column, IReadOnlyList<string> row, DateTimeOffset time, bool monthLabel,
int rowIndex, StagedImport staged, Dictionary<int, double> 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<string> row, string cell,
DateTimeOffset time, int rowIndex, StagedImport staged, Dictionary<int, double> previousByMeter, bool detectSwaps)
DateTimeOffset time, bool monthLabel, int rowIndex, StagedImport staged, Dictionary<int, double> 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<string> row, MappingProfile profile, out DateTimeOffset time)
/// <param name="monthLabel">
/// 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 (<see cref="ReadingFlags.MonthLabel"/>);
/// 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.
/// </param>
private static bool TryGetDate(IReadOnlyList<string> 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)
{
@@ -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.
/// </param>
/// <param name="flags">
/// Annotations to add to the row, e.g. <see cref="ReadingFlags.MeterSwap"/> on the new register's
/// start value written together with a swap. Added to an existing row's flags, never cleared.
/// </param>
public async Task<IngestionOutcome> 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<IngestionOutcome> 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<bool> 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(
@@ -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;
/// <summary>What the user entered for a meter event, before it is checked and stored.</summary>
public sealed record MeterEventDraft(MeterEventType Type, DateTimeOffset Time)
{
/// <summary>Delivered volume, or the tank level in <see cref="Unit"/>.</summary>
public double? Amount { get; init; }
/// <summary>Swap/reset: the old register's final value. Null books no tail.</summary>
public double? PrevValue { get; init; }
/// <summary>Swap/reset: the new register's start value. Null means 0.</summary>
public double? NewValue { get; init; }
/// <summary>Tank level only: <c>cm</c> for a dipstick reading, otherwise the tank's volume unit.</summary>
public string? Unit { get; init; }
public string? Notes { get; init; }
}
/// <summary>Why an event could not be recorded or removed. <see cref="None"/> means it was.</summary>
public enum MeterEventProblem
{
None,
UnknownMeter,
NotRecordableForMode,
AmountRequired,
AmountOutOfRange,
NoteRequired,
LevelNeedsCalibration,
LevelAtSameTime,
OldRegisterBelowPreviousReading,
ReadingAtSameTime,
BoundaryAlreadyRecorded,
StartReadingRejected,
NotFound,
Imported,
NotManual,
LaterBoundaryDependsOnIt,
}
/// <summary>A reading's instant and value.</summary>
public sealed record ReadingPoint(DateTimeOffset Time, double Value);
/// <summary>A recorded tank level, with its volume after calibration.</summary>
public sealed record TankLevelPoint(DateTimeOffset Time, double Amount, string? Unit, double Volume);
/// <summary>
/// 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.
/// </summary>
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)
{
/// <summary>
/// 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.
/// </summary>
public double RegisterBefore => Previous?.Value ?? InitialBaseline;
/// <summary>How much of the old register a swap/reset books: its final value less <see cref="RegisterBefore"/>.</summary>
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;
/// <summary>Draw since the last level: that level plus deliveries since, less the new volume.</summary>
public double? UsedSinceLastLevel(double volume) =>
LastLevel is { } last ? last.Volume + DeliveredSinceLastLevel - volume : null;
}
/// <summary>An event recording outcome.</summary>
public sealed record MeterEventResult(MeterEventProblem Problem, int? EventId = null)
{
public bool Succeeded => Problem == MeterEventProblem.None;
}
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// <para>
/// 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 <c>(previousReading, reading]</c>,
/// 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 <em>is</em> the new register, so the
/// next manual entry is prefilled and checked against the right number.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
public sealed class MeterEventService(
MeterVaultDbContext db, IngestionService ingestion, NormalizationService normalization)
{
/// <summary>Enough to warn about a backdated event; counting further is just a slower query.</summary>
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;
/// <summary>Reads the surroundings of <paramref name="time"/> for a meter, or null if the meter is gone.</summary>
public async Task<MeterEventContext?> 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);
}
/// <summary>
/// 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.
/// </summary>
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;
}
}
/// <summary>Records an event (and, for a swap/reset, the new register's start reading) and recomputes the meter.</summary>
public async Task<MeterEventResult> 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);
}
/// <summary>
/// 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.
/// </summary>
/// <remarks>Imported events belong to their batch and are removed by reverting it.</remarks>
public async Task<MeterEventResult> 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);
}
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// Only <see cref="ReadingQuality.Manual"/> rows: a measured reading is the audit truth a source
/// reported, and an imported one is removed by reverting its batch.
/// </remarks>
public async Task<MeterEventResult> 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);
}
/// <summary>
/// Whether a swap/reset lies in the interval that begins at the reading at <paramref name="readingTime"/>,
/// i.e. in <c>(readingTime, next reading]</c>. 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.
/// </summary>
private async Task<bool> 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);
}
/// <summary>
/// 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.
/// </summary>
private async Task<Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction?> 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);
}
@@ -0,0 +1,83 @@
using MeterVault.Core.Domain;
using MeterVault.Core.Normalization;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace MeterVault.Infrastructure.Ingestion;
/// <summary>
/// The readings either side of an instant, in the order the normalizer walks them
/// (<see cref="ReadingTimeline"/>), and the swaps or resets between them.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
internal static class RegisterNeighbours
{
/// <summary>
/// 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.
/// </summary>
private static readonly TimeSpan LabelReach = TimeSpan.FromDays(33);
public static async Task<Neighbours> 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<Reading> 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)))]);
}
/// <summary>The neighbours of an instant, and every swap or reset near them placed on the same timeline.</summary>
public sealed record Neighbours(
TimelineReading? Previous, TimelineReading? Next, IReadOnlyList<(MeterEvent Event, DateTimeOffset Time)> Boundaries)
{
/// <summary>Whether a swap or reset sits in <c>(Previous, upToInclusive]</c> — the interval it would explain.</summary>
public bool BoundaryAfterPreviousUpTo(DateTimeOffset? upToInclusive) =>
Boundaries.Any(b => (Previous is not { } p || b.Time > p.Effective) && (upToInclusive is null || b.Time <= upToInclusive));
}
}
@@ -31,7 +31,11 @@ public static class MeterConfigFactory
};
}
private static CalibrationCurve? ParseCalibration(string? json)
/// <summary>
/// Reads a tank's stored calibration (<c>{"volumePerUnit": 46.667, "offset": 0}</c>), or null when
/// there is none or it is unreadable — a level is then taken as already being a volume.
/// </summary>
public static CalibrationCurve? ParseCalibration(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
@@ -56,6 +60,12 @@ public static class MeterConfigFactory
}
}
/// <summary>The stored form of a calibration, in the shape <see cref="ParseCalibration"/> reads back; null clears it.</summary>
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))
@@ -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).
/// </summary>
public sealed class NormalizationService(MeterVaultDbContext db, INormalizationEngine engine)
/// <remarks>
/// 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.
/// </remarks>
public sealed class NormalizationService(
MeterVaultDbContext db, INormalizationEngine engine, IOptions<MeterVaultOptions>? options = null)
{
private readonly MeterVaultDbContext _db = db;
private readonly INormalizationEngine _engine = engine;
private readonly TimeZoneInfo _zone = InstanceTimeZone.Resolve(options?.Value.TimeZone);
/// <summary>The zone months are divided in: the configured one, or UTC when it is missing or unknown.</summary>
public TimeZoneInfo TimeZone => _zone;
/// <summary>
/// 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)
@@ -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;
/// <summary>
/// Rebuilds every meter's stored consumption once after the normalization rules — or the timezone they
/// divide months in — change.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
public sealed class NormalizationUpgrade(
MeterVaultDbContext db, NormalizationService normalization, ILogger<NormalizationUpgrade> logger)
{
/// <summary>The <c>app_setting</c> key holding the revision stored consumption was computed with.</summary>
public const string SettingKey = "normalization_revision";
/// <summary>The <c>app_setting</c> key holding the timezone id stored consumption was divided in.</summary>
public const string ZoneSettingKey = "normalization_zone";
/// <summary>The <c>app_setting</c> key listing meters whose rebuild failed and is retried at the next start.</summary>
public const string PendingSettingKey = "normalization_pending";
/// <summary>
/// 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.
/// </summary>
public const int CurrentRevision = 2;
private const int ProgressEvery = 100;
/// <summary>
/// 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.
/// </summary>
private static readonly TimeSpan StatementTimeout = TimeSpan.FromMinutes(15);
private readonly MeterVaultDbContext _db = db;
private readonly NormalizationService _normalization = normalization;
private readonly ILogger<NormalizationUpgrade> _logger = logger;
/// <summary>
/// Rebuilds all meters if stored consumption predates <see cref="CurrentRevision"/> or another
/// timezone, otherwise only meters left over from a failed rebuild. Returns how many were rebuilt.
/// </summary>
public async Task<int> 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<int> RebuildAsync(CancellationToken cancellationToken)
{
var storedRevision = await ReadAsync<int?>(SettingKey, cancellationToken).ConfigureAwait(false);
var storedZone = await ReadAsync<string>(ZoneSettingKey, cancellationToken).ConfigureAwait(false);
var pending = await ReadAsync<int[]>(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<int> 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<int>();
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;
}
/// <summary>
/// Flags the rows of earlier imports that came from monthly tables as <see cref="ReadingFlags.MonthLabel"/>,
/// which the importer only records since revision 2. Returns false when that could not be done.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
private async Task<bool> 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<int>(
$"""
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;
}
}
/// <summary>
/// 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 <c>reading</c> 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. <c>meter_id</c> is the compression's segment key and <c>time</c> 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.
/// </summary>
private async Task<int> 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<T?> ReadAsync<T>(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<T>(setting.Value);
}
catch (JsonException)
{
// Unreadable counts as absent: the worst outcome is one rebuild too many.
return default;
}
}
/// <summary>Stages a setting as JSON — the column is jsonb.</summary>
private async Task WriteAsync<T>(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;
}
}
}
@@ -0,0 +1,40 @@
using MeterVault.Core.Normalization;
namespace MeterVault.Infrastructure.Options;
/// <summary>
/// The instance timezone (<see cref="MeterVaultOptions.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.
/// </summary>
public static class InstanceTimeZone
{
/// <summary>The configured zone, or UTC when <paramref name="id"/> is empty or not a known zone.</summary>
public static TimeZoneInfo Resolve(string? id)
{
if (string.IsNullOrWhiteSpace(id))
{
return TimeZoneInfo.Utc;
}
return TimeZoneInfo.TryFindSystemTimeZoneById(id, out var zone) ? zone : TimeZoneInfo.Utc;
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>The instant a local date starts — the lower bound a range of that date begins at.</summary>
public static DateTimeOffset StartOf(DateOnly date, TimeZoneInfo zone) => GapAttribution.LocalMidnight(date, zone);
}
@@ -35,10 +35,9 @@ public sealed class MeterVaultOptions
/// <summary>
/// Allow an update to be triggered from the UI/API. <b>Off by default, and deliberately.</b> 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.
/// </summary>
public bool AllowInAppUpdate { get; set; }
@@ -58,7 +57,8 @@ public sealed class MeterVaultOptions
/// <summary>
/// API keys accepted on the <c>X-Api-Key</c> header for the REST API (SDD §9). Provide via env
/// (e.g. <c>MeterVault__ApiKeys__0=...</c>). Empty means the API is open (dev only).
/// (e.g. <c>MeterVault__ApiKeys__0=...</c>). Empty closes the REST API unless
/// <see cref="AllowAnonymousApi"/> opens it explicitly.
/// </summary>
public IList<string> ApiKeys { get; set; } = [];
@@ -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()
{
+402 -91
View File
@@ -5,101 +5,114 @@ using static MeterVault.Core.Tests.TestData;
namespace MeterVault.Core.Tests;
/// <summary>
/// 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).
/// </summary>
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()
{
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)));
}
[Fact]
public void A_skipped_month_is_split()
{
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)));
}
[Fact]
public void Splitting_preserves_the_total_and_keeps_the_closing_timestamp()
{
var start = Month(2026, 5);
var end = new DateTimeOffset(2026, 7, 18, 15, 33, 0, TimeSpan.Zero);
var segments = GapAttribution.Split(start, end, 714.5);
// 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);
}
[Fact]
public void Each_month_gets_a_share_proportional_to_the_time_it_covers()
{
// Exactly two whole months: an even split, to the cent.
var segments = GapAttribution.Split(Month(2023, 1), Month(2023, 3), 620);
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);
}
[Fact]
public void A_gap_in_a_counter_series_is_spread_and_marked_estimated()
public void An_interval_inside_one_month_is_one_row_at_its_reading_with_its_own_quality()
{
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
],
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();
// 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);
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 An_ordinary_monthly_series_produces_one_measured_row_per_reading()
public void Months_are_local_so_a_reading_just_after_local_midnight_does_not_take_the_month_with_it()
{
// 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 A_reading_exactly_at_local_midnight_on_the_first_books_wholly_to_the_month_before()
{
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 result = _engine.Normalize(ctx).ToList();
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 An_ordinary_imported_monthly_series_produces_one_unchanged_row_per_reading()
{
// 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 })
{
// 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" },
@@ -110,6 +123,7 @@ public sealed class GapAttributionTests
Reading(1, Month(2022, 11), 1153),
Reading(1, Month(2022, 12), 1968),
],
TimeZone = zone,
};
var result = _engine.Normalize(ctx).ToList();
@@ -117,13 +131,280 @@ public sealed class GapAttributionTests
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 The_observed_solar_gap_is_apportioned_across_the_months_it_covers()
public void A_month_label_is_an_imported_row_the_importer_flagged_as_a_month()
{
// 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.
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);
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_live_reading_after_the_last_imported_row_counts_from_the_end_of_that_rows_month()
{
// 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();
+46
View File
@@ -0,0 +1,46 @@
using MeterVault.Core.Domain;
namespace MeterVault.Core.Tests;
/// <summary>
/// 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.
/// </summary>
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<MeterMode>())
{
Assert.DoesNotContain(MeterEventType.Correction, MeterEventRules.RecordableFor(mode));
}
}
}
+31
View File
@@ -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()
{
+46
View File
@@ -0,0 +1,46 @@
using MeterVault.Core.Domain;
namespace MeterVault.Core.Tests;
/// <summary>
/// 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.
/// </summary>
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<EndpointType>(), 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<EndpointType>())
{
Assert.True(SourceRouting.Serves(endpoint, SourceRouting.DefaultSourceFor(endpoint)));
}
}
}
+16 -1
View File
@@ -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));
/// <summary>
/// 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.
/// </summary>
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,
};
/// <summary>An imported reading from a day-dated row ("01.08.2026"): an instant, whatever its clock time.</summary>
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,
};
+33
View File
@@ -0,0 +1,33 @@
using MeterVault.Infrastructure.Dashboard;
namespace MeterVault.Integration.Tests;
/// <summary>
/// The dashboard's empty state names the first missing step of the cost setup. Pure, so no Docker.
/// </summary>
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);
}
}
@@ -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&amp;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
@@ -0,0 +1,50 @@
using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Import;
namespace MeterVault.Integration.Tests.Import;
/// <summary>
/// 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.
/// </summary>
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);
}
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
[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<List<double>> 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<int> 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();
}
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
[Collection("Timescale")]
public sealed class MonthAttributionTests(TimescaleFixture fx)
{
private static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
/// <summary>A Berlin wall-clock time as the UTC instant the database stores.</summary>
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<NormalizationUpgrade>.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<NormalizationUpgrade>.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<NormalizationUpgrade>.Instance);
private static async Task<int[]> PendingAsync(MeterVaultDbContext db)
{
var setting = await db.AppSettings.AsNoTracking().FirstOrDefaultAsync(s => s.Key == NormalizationUpgrade.PendingSettingKey);
return setting is null ? [] : System.Text.Json.JsonSerializer.Deserialize<int[]>(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<Dictionary<DateOnly, double>> 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<int> 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();
}
}
@@ -0,0 +1,36 @@
using MeterVault.Infrastructure.Options;
namespace MeterVault.Integration.Tests;
/// <summary>
/// The configured zone id reaches .NET and PostgreSQL alike. Pure, so no Docker.
/// </summary>
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"));
}
}
@@ -0,0 +1,103 @@
using MeterVault.App;
using MeterVault.Core.Domain;
namespace MeterVault.Integration.Tests;
/// <summary>
/// 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.
/// </summary>
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<MeterEventType>())
{
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));
}
@@ -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()
{
@@ -6,48 +6,68 @@ using static MeterVault.Integration.Tests.Reconciliation.ReconciliationSupport;
namespace MeterVault.Integration.Tests.Reconciliation;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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);
}
}
}