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