Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a08e9f781f | ||
|
|
5a6f34a467 | ||
|
|
ec51419f64 | ||
|
|
8940ef25c3 | ||
|
|
c0f52dbb6f | ||
|
|
aacdc28d70 | ||
|
|
0319e5527f | ||
|
|
bfa0b537ee | ||
|
|
af786c7b28 | ||
|
|
1f575c9da2 | ||
|
|
9eb3f7d53c | ||
|
|
cf7e0396f0 | ||
|
|
8fe5f4411b | ||
|
|
cedd60ab45 | ||
|
|
ad896db051 | ||
|
|
95c51842e8 | ||
|
|
62d102c335 | ||
|
|
e23df37a3f |
@@ -6,39 +6,101 @@ 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**.
|
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). `/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). Remaining refinement: full de-DE UI-string localization (data parsing is already de-DE) — noted at its commit. Set `MeterVault__SeedReferenceData=true` (compose: `METERVAULT_SEED=true`) for a one-command populated demo.
|
**Status: implemented (M0–M7) + the dashboard/analysis rework (next release 0.4.0).**
|
||||||
|
- **Size:** five projects, ~2,490 tests (Core 1,733, Integration 753 incl. 2 opt-in performance facts), working Docker deploy.
|
||||||
|
- **Docs:**
|
||||||
|
- `docs/SDD.md` is the design reference. It marks in place every section the system now deviates from (list: note D-58). Its milestone map (§12) matches the git history (M0…M7).
|
||||||
|
- The rework's work order is `docs/DASHBOARD_ANALYSIS_CHANGE_BRIEF.md`. Its decisions are D-01…D-58 and amendments A-01…A-39 in `docs/ANALYSIS_IMPLEMENTATION_NOTE.md`. **Read that note before touching analysis, costing, rollups, virtual meters or the analysis pages.**
|
||||||
|
- Outcome and acceptance evidence: `docs/ANALYSIS_REPORT.md`. User-facing changes: `docs/RELEASE_NOTES.md`.
|
||||||
|
- **Pages:**
|
||||||
|
- Overview `/`, Analysis `/trends`, Meters `/meters`, the meter hub `/meters/{id}`, energy type `/energy/{id}`, Solar `/solar`, Tanks & consumables `/consumables`, import `/import` + `/import/wizard`, Configuration `/admin/*`. See *Analysis & navigation*.
|
||||||
|
- Their read models live in `Infrastructure/Dashboard`: `DashboardService.GetOverviewAsync`, `SolarService`, `ConsumableService` (+ pure `TankLevels`), `MeterDetailService` (the paged record tabs), `FlowService`. All of them sit on the two shared readers.
|
||||||
|
- PV, grid and load meters are found by mode and by *effective* role (`MeterRoleRules.Effective`, A-07). The role is stored as the `role` token in `Meter.Meta` (`MeterRoles`/`MeterMeta`). It is saved only through `MeterRoleAssignment`, which keeps a role unique per type among meters in service and names the meter it moved from. Nothing is found by name.
|
||||||
|
- **Admin write-CRUD** (SDD §8.7): MudBlazor inline-dialog pages for energy type *definitions*, meters, a meter's ingest sources (meter Sources tab), tariffs, cost categories + members, and connectors (`ingestion_endpoint`; secrets as an env-var reference or typed in and encrypted at rest).
|
||||||
|
- The shared `Shared/MeterEditor.razor`:
|
||||||
|
- recomputes the meter when mode, baseline, install date, unit, role or tank change (`RecomputeNeeded`);
|
||||||
|
- owns the totals override, tank setup and the meter's own cost-category memberships (type-level ones are only named);
|
||||||
|
- for virtual meters, holds the calculation editor (`Shared/MeterEditing/`, `App/MeterEditing/`, preview through `MeterDraftAnalysis`).
|
||||||
|
- Deleting a meter or type goes through `EntityDeletion`, which also removes its scoped tariffs. The delete dialog names the virtual meters that read the meter (`VirtualMeterService`, D-33).
|
||||||
|
- `/admin/settings` is read-only: the effective config (env-driven, not DB-stored) plus the analysis data state: revision, zone, meters pending a rebuild, and raw retention "Not enforced".
|
||||||
|
- **Manual readings:** the header's "Add reading" (also on the Readings tab and as a quick entry) opens `MeterPage/ManualReadingDialog.razor`.
|
||||||
|
- It is touch-first: prefilled with the meter's last register value and the current local time, with an on-screen keypad for phone entry at the meter and a live parsed-value + delta-since-last readout. The decrease guard is surfaced before saving.
|
||||||
|
- Its verdict comes from its own queries for the entered time (latest reading, the previous reading on the normalizer's timeline, a swap/reset that explains a decrease, the reading at T with its flags), never from a page of rows (D-50).
|
||||||
|
- It saves through `IngestionService.IngestByMeterAsync(quality: Manual)`, so the reading is stamped `ReadingQuality.Manual` and renormalizes inline like any other ingest.
|
||||||
|
- The dialog deliberately reserves fixed space for its verdict line: anything that reflows moves the keys out from under the user's thumb mid-entry.
|
||||||
|
- **Home Assistant reading:** an HA connector (`BaseUrl` + `TokenEnv`) plus an HA source (entity id) drives `HomeAssistantWorker`'s REST poll.
|
||||||
|
- 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 endpoint is served once.
|
||||||
|
- `HaWebSocketProtocol` is the pure, unit-tested handshake/parse logic. `HaConnectionTester` powers the connector "Test connection" button.
|
||||||
|
- **Meter topology & flow:** a `MeterLink` is a directed `from→to` edge: the downstream meter is a *subsection* of the upstream one, and several parents are allowed.
|
||||||
|
- Links are **topology only** and never define or change a virtual meter's calculation (D-25).
|
||||||
|
- Edit them under Energy type → Flow → "Manage connections". `MeterLinkService` checks for cycles, other types, duplicates and legacy virtual meters inside a transaction with `LOCK TABLE meter_link`; it is built inside the dialog, not registered in DI. A physical meter's upstream field in the editor also edits them.
|
||||||
|
- The Flow tab draws a hand-rolled SVG **Sankey** (`SankeyChart.razor`; ApexCharts has no Sankey type) from `FlowService.FromResultAsync`, over the same reader result as the page:
|
||||||
|
- Each node has its canonical period value. An edge carries the downstream meter's value, split proportionally across parents (marked estimated) and capped at the parent. The unaccounted remainder becomes "Other".
|
||||||
|
- A pure-sum virtual meter is drawn with its calculation inputs, marked calculated. Other virtual meters and meters in another unit appear only in the flow table.
|
||||||
|
- A node without data is named as such, never shown as a fake 0.
|
||||||
|
- **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 and the meters/categories each batch wrote to.
|
||||||
|
- **`instant_rate`:** `InstantRateNormalizer` integrates the rate 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. There is no `Correction`: nothing reads it.
|
||||||
|
- `Validate` gives the dialog the same verdict the save reaches. Every record or 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: it 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, the "Record event" menu, Edit.
|
||||||
|
- Link into it with `MeterLinks` (`/meters/{id}?tab=…&action=…`). The action is consumed once after the interactive render and dropped from the address.
|
||||||
|
- The app-bar "Find a meter" dialog opens a meter's Analysis tab with the current period, plus the same quick entry.
|
||||||
|
- `NavState.NotifyEnergyTypesChanged()` / `NotifyMetersChanged()` tell the per-circuit nav to reload. Raise them from anything that creates or deletes types, meters or tanks.
|
||||||
|
- **Connector detour:** a source that lacks a usable connector detours through `/admin/connectors?new=…|edit=…&meter=…` (`MeterLinks.Source`/`NewConnector`/`EditConnector`). It 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, and only Cancel discards it. The way back is a meter id, never a URL, so it cannot redirect off-site.
|
||||||
|
- The Sources tab links each connector to its editor, the source dialog has "Edit connector", and the connector list shows which meters use each connector.
|
||||||
|
- The Overview shows small setup notes from `DashboardService.GetCostSetupAsync` → `CostSetup.FirstGap` (no meters/tariffs next to the cost card, no categories/members under the composition). They are never a prerequisite for seeing quantities.
|
||||||
|
- **UI language:** English + German end to end. See *Localization*.
|
||||||
|
- **Demo:** set `MeterVault__SeedReferenceData=true` (compose: `METERVAULT_SEED=true`) for a one-command populated demo: meters 1–5 Strom (Haus, Netz, Auto, Solar 1, Solar 2), 6 Wasser, 7 Öltank + 8 Brenner (Heizöl), 9 Summe Solar (virtual `m4 + m5`, generation, not costed).
|
||||||
|
|
||||||
## Source of truth
|
## Source of truth
|
||||||
|
|
||||||
`docs/SDD.md` is the authoritative spec and build brief — read it before implementing anything. Key protocol from §0 that governs all work here:
|
`docs/SDD.md` is the authoritative spec and build brief — read it before implementing anything. Key protocol from §0 that governs all work here:
|
||||||
|
|
||||||
- **Build strictly in milestone order (§12, M0→M7).** Each milestone is independently runnable and testable; do not start Mn+1 until Mn's tests pass.
|
- **Build strictly in milestone order (§12, M0→M7).** Each milestone is independently runnable and testable; do not start Mn+1 until Mn's tests pass.
|
||||||
- **The four CSVs in `sampledata/` are golden fixtures.** Every parsing / consumption / cost rule must reconcile against them (§13). **If a computed number disagrees with the spreadsheet, the spreadsheet wins** unless the discrepancy is a deliberately documented correctness fix.
|
- **The four CSVs in `sampledata/` are golden fixtures.** Every parsing / consumption / cost rule must reconcile against them (§13). **If a computed number disagrees with the spreadsheet, the spreadsheet wins** unless the discrepancy is a deliberately documented correctness fix. The seeded bill is pinned to the sheet's `Jahreskosten` (`SeededBillTests`, D-44).
|
||||||
- When a design decision is ambiguous, check §14 (open questions): if listed, take the stated default and flag it; if not listed, ask before guessing.
|
- When a design decision is ambiguous, check §14 (open questions): if listed, take the stated default and flag it; if not listed, ask before guessing. For analysis, costing and page behaviour, check the implementation note (D-nn/A-nn) first; a new decision gets a new `A-nn` there.
|
||||||
- Keep the **domain layer free of infrastructure concerns** (the domain model and DB schema are UI-agnostic by design).
|
- Keep the **domain layer free of infrastructure concerns** (the domain model and DB schema are UI-agnostic by design). Pure analysis rules belong in `Core/Analysis`, reads in `Infrastructure/Analysis`/`Costing`, and presentation in `App`.
|
||||||
|
|
||||||
## Committed tech stack (do not re-litigate; see SDD §4.1)
|
## Committed tech stack (do not re-litigate; see SDD §4.1)
|
||||||
|
|
||||||
.NET (current LTS — .NET 10, .NET 8 acceptable), C# · ASP.NET Core + **Blazor Server** · **MudBlazor** components · **ApexCharts** (Blazor-ApexCharts) · **MQTTnet** · **PostgreSQL + TimescaleDB** · **EF Core (Npgsql)** for schema/CRUD + **Dapper** for hot-path time-series reads · `BackgroundService` hosted services for ingestion/aggregation · **xUnit + Testcontainers** (Timescale image) · Docker Compose + GHCR.
|
.NET (current LTS — .NET 10, .NET 8 acceptable), C# · ASP.NET Core + **Blazor Server** · **MudBlazor** components · **ApexCharts** (Blazor-ApexCharts) · **MQTTnet** · **PostgreSQL + TimescaleDB** · **EF Core (Npgsql)** for schema/CRUD + **Dapper** for hot-path time-series reads · `BackgroundService` hosted services for ingestion · **xUnit + Testcontainers** (Timescale image) · Docker Compose + GHCR. No bUnit/Playwright: browser checks are manual/CDP-scripted.
|
||||||
|
|
||||||
## Project layout
|
## Project layout
|
||||||
|
|
||||||
```
|
```
|
||||||
/src/Core domain entities + enums; pure Normalization engine (mode strategies,
|
/src/Core domain entities + enums; pure Normalization engine (mode strategies); Parsing (German
|
||||||
expression evaluator); Parsing (German dialect); Costing (TariffResolver)
|
dialect); Costing (legacy TariffResolver)
|
||||||
/src/Infrastructure MeterVaultDbContext + migrations (relational + raw-SQL Timescale);
|
/src/Core/Analysis pure analysis rules: Time (PeriodResolver, BucketPlanner, ComparisonResolver, Change),
|
||||||
Import (CsvImporter, profiles, ImportService), Ingestion (MQTT/HA workers,
|
Coverage (runs, evaluator, matched coverage, provenance), Rollups, Quantities (units,
|
||||||
IngestionService), Normalization service, Costing/Dashboard/Backup services
|
normalized quantity, roles, tariff units), Totals (policy, category cover), Virtual
|
||||||
/src/App ASP.NET Core host: Blazor Server UI (Components/), REST API (Api/), hosted
|
(formula parser, validator, dependency graph, evaluator, legacy derivation),
|
||||||
workers, Program.cs (Serilog, migrate+seed on startup, /healthz)
|
Costing (CostCalculator, TariffBook, CostAmount)
|
||||||
/tests/Core.Tests unit (no Docker): parsers, normalizers, swap→12, tariff resolver
|
/src/Infrastructure MeterVaultDbContext + migrations (relational + raw-SQL Timescale); Import (CsvImporter,
|
||||||
/tests/Integration.Tests Testcontainers (Timescale): reconciliation vs the 4 fixtures,
|
profiles, ImportService); Ingestion (MQTT/HA workers, IngestionService, MeterEventService);
|
||||||
import commit/revert, ingestion, cost, CAgg refresh, API, export, render
|
Normalization (NormalizationService + AnalysisDataWriter, NormalizationUpgrade);
|
||||||
|
Analysis (AnalysisReader, AnalysisCatalog, AnalysisQueries, VirtualDefinitionUpgrade,
|
||||||
|
MeterDraftAnalysis); Costing (CostReader, BillRun; CostService = legacy API adapter);
|
||||||
|
Dashboard (page read models); Backup (JSON export/import)
|
||||||
|
/src/App ASP.NET Core host: Program.cs (Serilog, migrate+seed+upgrades on startup, /healthz),
|
||||||
|
REST API (Api/), Components/ (Pages/, Shared/, Shared/Analysis/), Analysis/ (URL contract,
|
||||||
|
chart/table/attention models, CSV export), AnalysisPage/, Energy/, MeterDetails/,
|
||||||
|
MeterEditing/, TariffEditing/, Theme/, Localization/, link helpers (MeterLinks,
|
||||||
|
AnalysisLinks, TariffLinks), InstanceClock, InstanceCurrency
|
||||||
|
/tests/Core.Tests unit (no Docker): parsers, normalizers, swap→12, Analysis/ (periods, DST, coverage,
|
||||||
|
totals, virtual formulas, cost calculator)
|
||||||
|
/tests/Integration.Tests Testcontainers (Timescale): reconciliation vs the 4 fixtures, import commit/revert,
|
||||||
|
ingestion, rollups, reader, cost engine, seeded bill, API contracts, export, render;
|
||||||
|
pure UI-model tests (Analysis/, MeterPage/, Overview/, Editor/, Specialized/);
|
||||||
|
Performance/ (trait Category=Performance, opt-in)
|
||||||
/deploy Dockerfile, docker-compose.yml (app + timescaledb), build-and-push.ps1, unraid-template.xml
|
/deploy Dockerfile, docker-compose.yml (app + timescaledb), build-and-push.ps1, unraid-template.xml
|
||||||
```
|
```
|
||||||
|
|
||||||
Central package versions live in `Directory.Packages.props`; shared build/style in
|
Central package versions live in `Directory.Packages.props`; shared build/style in
|
||||||
`Directory.Build.props` + `.editorconfig`. Snake_case table/column mapping via
|
`Directory.Build.props` + `.editorconfig` (`TreatWarningsAsErrors`). Snake_case table/column mapping via
|
||||||
`UseSnakeCaseNamingConvention`. EF migrations are exempt from code-style enforcement (see `.editorconfig`).
|
`UseSnakeCaseNamingConvention`. EF migrations are exempt from code-style enforcement (see `.editorconfig`).
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
@@ -48,14 +110,19 @@ dotnet build # build the solution
|
|||||||
dotnet test # all tests (Integration.Tests needs Docker for Testcontainers)
|
dotnet test # all tests (Integration.Tests needs Docker for Testcontainers)
|
||||||
dotnet test tests/Core.Tests # unit tests only (no Docker needed)
|
dotnet test tests/Core.Tests # unit tests only (no Docker needed)
|
||||||
dotnet test tests/Integration.Tests --filter "FullyQualifiedName~Reconciliation" # one class/area
|
dotnet test tests/Integration.Tests --filter "FullyQualifiedName~Reconciliation" # one class/area
|
||||||
|
dotnet test tests/Integration.Tests --filter "FullyQualifiedName~StringResource" # both resx files complete
|
||||||
|
$env:METERVAULT_PERF='1'; dotnet test tests/Integration.Tests -c Release --filter "FullyQualifiedName~Performance.ReaderTimingTests" # ~10 min
|
||||||
dotnet ef migrations add <Name> -p src/Infrastructure -s src/App -o Persistence/Migrations
|
dotnet ef migrations add <Name> -p src/Infrastructure -s src/App -o Persistence/Migrations
|
||||||
dotnet run --project src/App # run app + workers locally (needs a Timescale DB)
|
dotnet run --project src/App # run app + workers locally (needs a Timescale DB)
|
||||||
docker compose -f deploy/docker-compose.yml up # app + TimescaleDB together
|
docker compose -f deploy/docker-compose.yml up # app + TimescaleDB together
|
||||||
```
|
```
|
||||||
|
|
||||||
**Timescale-in-EF gotchas** (already handled — follow the pattern): hypertable/CAgg DDL lives in
|
**Timescale-in-EF gotchas** (already handled — follow the pattern): hypertable DDL lives in raw-SQL migrations, one
|
||||||
raw-SQL migrations; continuous-aggregate creation + policies use `migrationBuilder.Sql(..., suppressTransaction: true)`, one statement each; CAgg policy `end_offset` must be ≥ one bucket. Tests
|
statement each with `migrationBuilder.Sql(..., suppressTransaction: true)` where Timescale needs it. The old continuous
|
||||||
pause the compression job (historical fixture data would otherwise deadlock imports).
|
aggregates (and their "`end_offset` ≥ one bucket" rule) only matter for the historical migrations: the `AnalysisRollups`
|
||||||
|
migration dropped them (D-17) and purged stored consumption of virtual meters. Tests pause the compression job (historical
|
||||||
|
fixture data would otherwise deadlock imports). An UPDATE of rows in compressed chunks needs TimescaleDB's decompression
|
||||||
|
cap lifted for that statement (see `NormalizationUpgrade`).
|
||||||
|
|
||||||
## Core architecture (the part that spans multiple files)
|
## Core architecture (the part that spans multiple files)
|
||||||
|
|
||||||
@@ -64,24 +131,161 @@ pause the compression job (historical fixture data would otherwise deadlock impo
|
|||||||
```
|
```
|
||||||
sources (Tasmota/HA/MQTT/manual/CSV)
|
sources (Tasmota/HA/MQTT/manual/CSV)
|
||||||
→ Ingestion workers write raw `reading` rows (immutable audit truth)
|
→ Ingestion workers write raw `reading` rows (immutable audit truth)
|
||||||
→ Normalization derives append-only `consumption` (deltas in base unit)
|
→ NormalizationService.RecomputeMeterAsync derives append-only `consumption` (deltas in base unit, each row
|
||||||
→ TimescaleDB continuous aggregates roll consumption to hourly/daily/monthly/yearly
|
with its source interval) and, in the same transaction and by diff, the per-meter rollups by local day
|
||||||
→ Cost engine joins aggregates with time-ranged `tariff`
|
and month (`consumption_rollup`, `consumption_rollup_month`), coverage runs (`meter_coverage`) and
|
||||||
→ Blazor dashboard + REST API read aggregates + cost views
|
`meter_rollup_state` (revision, zone, normalized unit, kind) — AnalysisDataWriter, D-10 – D-16
|
||||||
|
→ AnalysisReader: quantities of physical meters (rollups + ≤ 2 edge days of `consumption`), virtual meters
|
||||||
|
(evaluated on read from their sources) and per-type measures, for one resolved period and bucket plan
|
||||||
|
→ CostReader: the bill (BillRun → Core CostCalculator), month by month from time-ranged `tariff`
|
||||||
|
→ Blazor pages, REST API (/api/v1) and CSV export (/export/analysis.csv) read only those two readers
|
||||||
```
|
```
|
||||||
|
|
||||||
**Invariants that shape everything:**
|
**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.
|
- **Raw `reading` is immutable audit truth.** Everything derived (consumption, rollups, cost, balances, forecasts) is computed on top and must be reproducible. Never mutate readings to fix a derived number. Every write path recomputes the meter inline: live ingestion (`IngestionService.RenormalizeAsync`), import and revert, events, manual readings and deletions, meter edits, the events API. Without it, readings never become consumption and the rollups go stale. There is no cache and no refresh job.
|
||||||
- **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.
|
- **Consumption is attributed to the months it accrued in** (`GapAttribution`, SDD §7.1).
|
||||||
- **`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.
|
- **Division:** a plain increase whose interval crosses a *local* month boundary (instance timezone) is divided at those boundaries by elapsed time. Each share is stamped inside its month (the closing reading keeps its own row when it lies in that month) and marked `Estimated`. Only cumulative/generation counters divide. Tank, runtime, direct-delta and instant-rate intervals are booked whole, so months they straddle read `Unresolved` (A-16). Swaps, resets and decreases are never divided.
|
||||||
- **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.
|
- **Month labels:** imported monthly-table rows are month-end snapshots. The **importer** marks them `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`).
|
||||||
|
- **One ordering:** `EffectiveTime` reads a label as the end of its month. **`ReadingTimeline`** (Core) orders by effective time, then stamp. `CounterNormalizerBase` and `RuntimeCounterNormalizer` use it, and so do the ingestion decrease guard, `MeterEventService.GetContextAsync` and the manual-entry dialog (via `RegisterNeighbours`), so none of them disagrees about which reading is "previous".
|
||||||
|
- **Consequences:** consecutive rows span exactly one month and book unchanged, so 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.
|
||||||
|
- **Boundaries:** 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`). A non-label row closing exactly at a local midnight is stamped 1 s earlier, inside the day it closes (D-11).
|
||||||
|
- **Guards:** `NormalizationEngine` coalesces 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.
|
||||||
|
- **Everything buckets in the configured `MeterVault__TimeZone`**: normalization, rollups, `AnalysisReader`, `CostReader`, the export. Requested dates become instants through `PeriodResolver` / `InstanceTimeZone.StartOf` (local midnight, never UTC midnight). A hard-coded zone or a 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.
|
||||||
|
- **Startup upgrades, in order; none may crash startup:**
|
||||||
|
1. Migrations, then the seed.
|
||||||
|
2. `VirtualDefinitionUpgrade` (D-28): an expression-less virtual meter whose same-type links imply an unambiguous sum gets it stored. Idempotent and logged; anything else is "needs configuration".
|
||||||
|
3. `NormalizationUpgrade`. `app_setting` records `normalization_revision` (now **3**) and `normalization_zone`.
|
||||||
|
- Before revision 2 it first flags the month rows of older 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). It lifts the decompression cap for that one UPDATE. If flagging fails, nothing is rebuilt or recorded.
|
||||||
|
- Then it rebuilds consumption + rollups + coverage + state of **every** meter when the revision or zone differs. Otherwise it rebuilds only meters whose `meter_rollup_state` is missing or outdated, plus `normalization_pending`. Virtual meters are purged, since they store nothing. Each meter runs in its own transaction.
|
||||||
|
- A meter whose oldest consumption predates its oldest reading or event is skipped and logged instead of truncating history. A failing meter is logged, kept pending and retried at the next start.
|
||||||
|
- Until its rebuild runs, a meter reads as `Pending` ("analysis being prepared"), never "no data". **Bump `CurrentRevision` whenever the engine books existing readings differently.** The rebuild runs before the web server listens; roughly 0.1 s per monthly meter and ~1.4 s per meter with a year of hourly data (it grows with the reading count).
|
||||||
|
- **Dashboards and charts read rollups only — never `reading`.** This is what makes 1000 meters × 50 years feasible (§5.5). `consumption` + rollups are the analytical history. `reading` is read by the paged Readings tab, the manual-entry checks, and — only for a meter with a live source, only over the last 90 days — the rhythm query behind D-18 (A-40). The freshness *mark* is `meter_rollup_state.last_reading_at`, written by the recompute behind every write path, so an import-only meter's mark is exact without touching the raw chunks. **Raw retention is not enforced** (D-57, a documented blocker): every recompute rebuilds a meter from its readings, so dropping old readings would destroy history. `/admin/settings` and the Readings tab say so.
|
||||||
|
- **`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` (a formula over other meters, **evaluated on read, never stored**: D-27, SDD §14.1). New ingestion/normalization logic dispatches on mode. `NormalizedQuantity` (D-20) gives each meter's analysis (kind, unit):
|
||||||
|
- runtime: `h`, or the tank unit with a fixed rate;
|
||||||
|
- instant rate: the rate unit without `/h` (W → Wh);
|
||||||
|
- tank: the tank unit;
|
||||||
|
- virtual: its declared result unit.
|
||||||
|
`Units` is the only unit normalizer (m3 = m³). Raw units appear only on the Readings tab.
|
||||||
|
- **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/net are **virtual meters** with user-defined formulas, not special-cased code. Formulas are quantities only: prices are not part of expressions, and a virtual meter's cost follows its `costRule` (D-39). Tariffs are time-ranged (price history), scoped global / per-type / per-meter.
|
||||||
|
|
||||||
**Timescale vs EF split (SDD §5.3):** EF Core migrations own the relational tables. **Timescale-specific DDL — `create_hypertable`, compression policies, continuous aggregates, retention — is not expressible via EF's model builder and must live in raw-SQL migrations.** `reading` and `consumption` are hypertables.
|
**Timescale vs EF split (SDD §5.3):** EF Core migrations own the relational tables. The rollup, coverage and state tables are plain tables with a cascading FK to `meter` (D-12). **Timescale-specific DDL — `create_hypertable`, compression policies — is not expressible via EF's model builder and must live in raw-SQL migrations.** `reading` and `consumption` are hypertables.
|
||||||
|
|
||||||
**Time & DST (SDD §10):** store UTC everywhere; bucket and display in the instance timezone (default `Europe/Berlin`). "Daily cost" boundaries are local-midnight — use `time_bucket(..., 'Europe/Berlin')`.
|
**Time & DST (SDD §10):**
|
||||||
|
- Store UTC everywhere. Bucket and display in the configured instance timezone (`MeterVault__TimeZone`, default `Europe/Berlin`); never hard-code Berlin.
|
||||||
|
- A period resolves once per request into a local inclusive date range (display) and a **half-open** UTC range `[from, to)` (every query). `to` is the local midnight after the end date, or the captured "now" for to-date periods.
|
||||||
|
- Days are local midnights (a DST day has 23 or 25 hours), weeks start Monday, and months and years are local.
|
||||||
|
- "Now" comes from the registered `TimeProvider`: pages read it once through `InstanceClock` (`Now`, `Today`). Services never read the clock (D-01). Tests use `FixedTimeProvider`.
|
||||||
|
- Rows whose interval closes after now are never actuals. They are reported as "recorded after now" (D-04, A-04, A-05, A-14, A-20).
|
||||||
|
|
||||||
**Secrets (SDD §6.4):** broker/HA tokens are **never** stored in DB plaintext. `ingestion_endpoint.config` holds a *reference* (env var name / Docker secret path) resolved at runtime.
|
## Analysis layer (Core/Analysis + Infrastructure/Analysis + Infrastructure/Costing)
|
||||||
|
|
||||||
|
- **Two readers are the only read path.** Pages, `/api/v1`, the CSV export, Solar, Consumables, Flow and the Overview all read figures through `AnalysisReader` (quantities) and `CostReader` (money). Never add a figure that queries `consumption` or `reading` directly.
|
||||||
|
- **`AnalysisReader`:** one request = scope + resolved period + `BucketPlan` + comparison.
|
||||||
|
- It loads `AnalysisCatalog` once: meters, tanks, links, rollup states, validated virtual definitions and the totals classification.
|
||||||
|
- It expands virtual dependencies in memory, then reads each table once for all physical meters involved (`AnalysisQueries`, Dapper): month rollups for month/year buckets, day rollups for day/week buckets, at most two partial edge days from `consumption`, coverage, freshness.
|
||||||
|
- The 400-point and 6-series limits are checked before any SQL runs.
|
||||||
|
- **`CostReader`:** runs `BillRun` → Core `CostCalculator` with a `TariffBook`: one catalog, tariff and manual-cost load, plus one reader pass for every meter any figure prices.
|
||||||
|
- `CostService` is only the adapter behind `/api/v1/consumption|cost`. `DashboardService.GetMonthlyTrendAsync`/`GetCategoryBreakdownAsync`/`GetCategoryDifferenceAsync` and `FlowService.GetFlowAsync(DateOnly…)` are legacy entry points used only by tests.
|
||||||
|
- **Status, never a silent 0 (D-14):**
|
||||||
|
- Every bucket has a `BucketStatus`:
|
||||||
|
- `Available`: covered; a zero is a true zero.
|
||||||
|
- `Partial`: only part of the bucket is covered.
|
||||||
|
- `Missing`: nothing covers it.
|
||||||
|
- `Unresolved`: data only at a coarser resolution than the bucket.
|
||||||
|
- `Invalid`: a calculation failed.
|
||||||
|
- `Pending`: the meter is being rebuilt.
|
||||||
|
- It is derived from coverage runs and their resolution class (≤ 1 h, ≤ 1 day, ≤ 7 days, ≤ 1 month, coarser; A-02, A-03), never from the amount.
|
||||||
|
- A bucket no run covers is `Unresolved`, not `Missing`, when the meter measures more coarsely than that bucket and is simply not read yet — a tank dipped once a year books nothing until the next dipstick (`CoverageEvaluator.AwaitsMeasurement`, A-41). Such data also stops `auto` at month buckets (`ResolutionClassifier.PlanningResolution`), and a comparison line on a page with several figures names the one it is about (`ComparisonSummary.Subject`).
|
||||||
|
- Separate dimensions: `Provenance` flags (measured, manual, imported, estimated, derived, opening balance), `ValueIssue` (why a value is not plain) and freshness (stale live source vs historical import, D-18).
|
||||||
|
- Outside `[InstalledAt, RetiredAt]` a meter is a known zero (D-24).
|
||||||
|
- A first reading with unknown start is an opening balance: partial, excluded from comparisons, with "Set install date" offered (A-01).
|
||||||
|
- **Virtual meters (D-25 – D-33, A-08, A-12, A-15):**
|
||||||
|
- The definition is the `Meter.Meta` keys `expression` (`m<id>` references), `referencedMeterIds` (derived and rewritten on save), `resultKind` (consumption, generation, net, indicator), `resultUnit` and `costRule` (none, sourceCosts, ownQuantity), written through `VirtualDefinitionJson`.
|
||||||
|
- `FormulaParser` builds an AST: `+ − * /`, parentheses, numbers; at most 2,000 characters and depth 64. An unknown identifier is an error, never 0.
|
||||||
|
- `VirtualValidator` + `DependencyGraph` check on save and on read: syntax, unknown/self references, loops (with their path), kind/unit rules. `+`/`−` need the same kind and unit or a declared net; meter × or ÷ meter is an indicator, which is non-additive and never totalled or costed.
|
||||||
|
- `VirtualEvaluator`, fed per-day source coverage from `CoverageEvaluator` (A-12):
|
||||||
|
- strict: a missing source → `Missing` with the source named; an observed zero is valid;
|
||||||
|
- a non-finite result or a loop → `Invalid` with the reason and dependency path;
|
||||||
|
- the period total is the formula over the joint coverage; non-linear formulas are marked non-additive (ratio of totals);
|
||||||
|
- results carry every source's series (the page's "source contributions").
|
||||||
|
- Legacy meters without an expression are read as their implied link sum with status `Legacy` until `VirtualDefinitionUpgrade` stores it (`LegacyVirtualDerivation`).
|
||||||
|
- The editor previews unsaved definitions through `MeterDraftAnalysis`. Export/import remaps meter ids inside definitions and carries `meter_link` (D-32).
|
||||||
|
- **Totals (D-22, D-23):** `TotalsPolicy`/`TotalsGraph` classify each type's meters into the measures Use, GridImport, Export, Generation and Runtime.
|
||||||
|
- **Use** is the `total_load` meter, else the consumption roots.
|
||||||
|
- Links out of supply meters (grid, generation, generation-kind virtual) are supply edges. Other links make the target a **breakdown** of its parent.
|
||||||
|
- Measures are never added across units. Virtual meters are *analysis views*: never added on top of their sources.
|
||||||
|
- `Meta.totals = auto|always|never` overrides this. `always` lets a virtual meter replace its sources in totals and bill, and is refused (naming the meter) when an ancestor or dependent already counts.
|
||||||
|
- Seeded result: Strom use = Haus, breakdown = Auto, grid import = Netz, generation = Solar 1 + Solar 2, Summe Solar analysis-only.
|
||||||
|
- **The bill (D-34 – D-43, A-15 – A-19, A-21, A-22, A-26):**
|
||||||
|
- **What is billed:** per type, `grid_import` meters if any, else the use meters; separately priced subsections at their own price, taken out of the parent (D-35, A-19). The feed-in credit applies only to `grid_export` meters. Generation, runtime and virtual views are never billed. Months with use but no grid meter in service are unavailable (A-17).
|
||||||
|
- **Pricing:** the price of the **15th of each local month** (D-36); every bucket is cut into local months, so the bucket size never changes a total. An interval longer than a month is priced whole only when its months share a price (A-16).
|
||||||
|
- **Tariff units** must fit the meter's normalized unit and the instance currency, else `UnitMismatch` (D-37). Bonus, Discount and Tax are **not applied** (D-57).
|
||||||
|
- **Missing prices:** `NotPriced` (no tariff at any date: an attention item, never a partial total) vs `PriceGap` (a hole in a priced history) vs a valid explicit **zero**. A bucket with nothing booked is unknown ("No data"), never "Priced" (A-26).
|
||||||
|
- **Standing charges** accrue per local day over the scope's service period, **once per scope**. Type and global charges are their own rows; meter fees stay on their meter (D-40, A-18).
|
||||||
|
- **Manual costs** are booked once, in full, on their `PeriodStart` day (D-41).
|
||||||
|
- **Categories** price the non-overlapping cover of their members (`CategoryCover`). The disjoint categories, Uncategorized and the standing-charge rows form the **composition**, which reconciles to the bill. Overlapping categories are "views", never summed; the donut appears only for non-negative slices (D-42). A category whose members price nothing says so (A-22).
|
||||||
|
- **Virtual costs** follow the named `costRule` (D-39, A-15): `sourceCosts` for pure sums, `ownQuantity` for linear formulas, `none` otherwise and for generation sums (so Summe Solar is not costed).
|
||||||
|
- **Changes (D-06 – D-09, A-10, A-13, A-23):**
|
||||||
|
- `ComparisonResolver` shifts in calendar units (MTD/YTD at the same elapsed wall time) and pairs buckets by index (`PairBuckets`). `MatchedCoverage` measures a change only over what both periods cover.
|
||||||
|
- `Change.Between` always gives the absolute difference; the percentage is not applicable for a baseline ≤ 0.
|
||||||
|
- A cost change uses one rule everywhere, `OverviewComparison.Between`: totals if both are complete, else the paired buckets complete on both sides, else "not comparable".
|
||||||
|
- Projections are separate, labelled and suppressed on thin coverage.
|
||||||
|
|
||||||
|
## Analysis & navigation (App)
|
||||||
|
|
||||||
|
- **URL state is the page state** (`AnalysisQuery`, D-46/D-47):
|
||||||
|
- Keys: `scope=portfolio|type|category|meter|meters`, `id`/`ids` (≤ 6), `metric=consumption|generation|export|runtime|net|cost|balance`, `period=mtd|last-month|ytd|prev-year|12m|24m|all|custom` with `from`/`to` (local, inclusive), `bucket=auto|day|week|month|year` (≤ 400 points), `compare=none|prev-period|prev-year|year:YYYY`.
|
||||||
|
- Defaults (`AnalysisDefaults`): Overview `mtd`, history pages `12m` (12 buckets ending with the current partial month), always `prev-year` (A-13). Defaults are **never written**.
|
||||||
|
- An invalid token falls back to the default with a notice. `all` spans availability (D-19).
|
||||||
|
- Page-specific keys: `tab`, `action`, the energy page's `view=total|meters`, the Overview's `chart=`.
|
||||||
|
- Toolbar and tab changes **replace** the history entry (`AnalysisNavigation.Replace`); drill-downs **push** (`AnalysisNavigation.UriFor`, `DrillInto`, `MeterDrill`, D-51, A-24, A-25).
|
||||||
|
- Build links only with `MeterLinks`, `AnalysisLinks`, `TariffLinks` and `AnalysisNavigation`. They carry the period against the *target* page's defaults.
|
||||||
|
- **Tabs by key, never by index:** meter tabs `analysis|readings|normalized|events|tariffs|sources|calculation` (`MeterLinks.VisibleTabs`/`ResolveTab`/`PanelIndex`). A virtual meter has no Readings or Normalized tab and has Calculation instead of Sources. Legacy `tab=consumption` → normalized. Energy tabs `overview|history|flow|meters` (`AnalysisLinks.ResolveEnergyTab`/`EnergyTabIndex`). One-shot `action=` stays separate from the analysis reload.
|
||||||
|
- **Page pattern:**
|
||||||
|
1. `AnalysisQuery.Parse(Nav.Uri, Defaults)`. If equal to the last query, stop: a tab change or action drop is not a new analysis.
|
||||||
|
2. `AnalysisPeriods.ResolveAsync(query, Clock.Now)`.
|
||||||
|
3. Read through a small loader: `MeterAnalysisLoader`, `AnalysisPageLoader`, `EnergyAnalysisLoader`, `DashboardService.GetOverviewAsync`, `SolarService`/`ConsumableService.GetAsync`.
|
||||||
|
4. Build the chart/table inputs **inside the load**, since they format eagerly and re-key the chart.
|
||||||
|
5. Commit one value through `LoadSequencer.RunAsync` into a `LoadState<T>`. Superseded loads are cancelled and never committed.
|
||||||
|
6. Render with `LoadPanel`: initial placeholder, refreshing (old value dimmed), `PanelError` with Retry.
|
||||||
|
- The initial load happens in `OnParametersSet`, because render tests read prerendered HTML. Subscribe to `Nav.LocationChanged` for query-only changes, and dispose.
|
||||||
|
- **Shared components** (`Components/Shared/Analysis`): `PageHeader`, `AnalysisBreadcrumbs` (Overview → type → meter, carrying the period), `PeriodToolbar` (presets, custom dates with one Apply, bucket with the refused-size hint, comparison incl. calendar years, metric, Reset, "Export CSV"), `AnalysisChart`, `AnalysisTable`, `MetricCard`, `ChangeChip`, `ValueStatus`, `EmptyPeriodState` (available dates + "Go to latest data"), `PendingState`, `PanelError`, `LoadPanel`, `RefreshIndicator`, `ProjectionNote`, `ComparisonSummary`, `AttentionList`, `SeriesContributions`.
|
||||||
|
- `AnalysisChart` (ApexCharts) has one axis per unit, nullable points, no smoothing or joining across gaps, and a real zero line for signed data. It follows the theme.
|
||||||
|
- `AnalysisTable` is the accessible equivalent of every chart and scrolls in its own region. Its rows read **newest first** with the total above them, as every dated list here does (A-42); the chart beside it and the CSV export stay chronological. "Largest changes by meter" also lists the meters it cannot rank, with their values and their status (`MeterChanges.Of`, A-43).
|
||||||
|
- Their rules are pure classes in `App/Analysis`: `FigureText` (the status words beside every figure, culprit meters by name), `ChangeDisplay` (metric polarity: more generation is good), `AnalysisChartModel`/`AnalysisChartOptions`, `AnalysisTableModel`, `AttentionItems` (D-53: one targeted action each, e.g. `TariffLinks.For(MissingPrice)` → `/admin/tariffs?scope=&id=&component=&from=&action=new`), `FormulaText`.
|
||||||
|
- **Missing ≠ zero ≠ not priced** on every page (brief §4.3, A-28):
|
||||||
|
- A true zero is a number and an outlined bar on the baseline. An unknown bucket is a gap marked "–" in the chart and "—" plus its reason in the table. Qualified values (partial, estimated) are marked "*".
|
||||||
|
- An empty chart says *why*: no data, only coarser data (naming the resolution, with a button for that interval), or no price.
|
||||||
|
- The empty-state test is `BucketStatus.Missing`, never a value of 0.
|
||||||
|
- **CSV export** `GET /export/analysis.csv` (`AnalysisExportEndpoints`, D-55): same keys as the pages, one row per bucket and series, local ISO bounds with offset (end exclusive), invariant numbers, empty cells for unknown values, `status`/`provenance`/`cost`/`cost_status`/`currency`/`comparison_value`. A formula-looking text cell is prefixed with `'`. Bad requests get a 400 with a reason. It is a UI endpoint, so no API key.
|
||||||
|
- **Theme:** `ThemeState` (scoped) persists light/dark in the `mv-theme` cookie. `App.razor` reads the cookie so prerender and the language switch keep the mode; the default is dark. Charts use a transparent background and palette colours, and are re-keyed on `ThemeState.Changed`. The sidebar's expanded groups use cookie `mv-nav` (`NavGroups`). Both are written through `BrowserPreferences` → `metervault.js` `setPreference`, which whitelists exactly those two names.
|
||||||
|
- **Currency:** `InstanceCurrency` (`MeterVault__Currency`, default EUR) and `Format.Money`/`MoneySigned`/`CurrencySymbol` for every amount. `Format.Euro` is gone. Never write € in code or resx; user-entered tariff units are data.
|
||||||
|
- **Formatting:** `Format.Quantity` (unit, "—" when unknown), `Format.PeriodRange`, `Format.BucketLabel` (year only across years; real dates for partial units), `Format.ChangeText`, `Format.MonthYear`.
|
||||||
|
- **Tests that pin this:**
|
||||||
|
- URL contract and links: `AnalysisQueryTests`, `AppLinkTests`, `AnalysisNavigationTests`, `LocalTimeEntryTests.Tab_keys_resolve_by_key_and_mode`.
|
||||||
|
- Loads: `LoadSequencerTests`.
|
||||||
|
- Chart, table and attention models: `AnalysisChartModelTests`, `AnalysisTableModelTests`, `AttentionItemsTests`.
|
||||||
|
- Rendered HTML: `AnalysisComponentRenderTests` (framework `HtmlRenderer`), `DashboardRenderTests`, `OverviewPageTests`, `AdminPagesRenderTests`, `MeterSourcesRenderTests`.
|
||||||
|
- Page logic and loaders: `MeterPageLogicTests`, `MeterAnalysisLoaderTests`, `AnalysisPageLoaderTests`, `EnergyPageTests`, `EnergyTypePageTests`, `OverviewDataTests`, `CostConsistencyTests`.
|
||||||
|
- Reader and costs: `AnalysisReaderTests`, `AnalysisDataTests`, `CostReaderTests`, `SeededBillTests`.
|
||||||
|
- Contracts and export: `ApiContractTests`, `AnalysisExportEndpointTests`.
|
||||||
|
- Shell: `ShellPreferenceTests`.
|
||||||
|
- Pure Core: `PeriodResolverTests`, `ComparisonResolverTests`, `BucketPlannerTests` (DST/leap/New York on a frozen clock), `TotalsPolicyTests`, `VirtualEvaluatorTests`, `CostCalculator*Tests`.
|
||||||
|
- Timings (opt-in): `Performance/ReaderTimingTests`.
|
||||||
|
|
||||||
|
**In-app update (`UpdateRunner`):** the Overview shows a banner (in its `PageHeader`, apart from analytical status) when a newer tag exists (`UpdateCheckService`, cached, never blocks a render). Triggering an update is **off by default**; `MeterVault__AllowInAppUpdate` is the *only* gate — no API key, by explicit owner decision. With it on, anything that can reach the app can trigger a rebuild+restart as root (realistically a DoS, since the build comes from the owner's own repo; RCE if that repo is compromised). The REST endpoint additionally requires an `X-MeterVault-Update` header — a CSRF guard, not auth, so a foreign page cannot drive it via a LAN browser. Launches detached via `systemd-run` because the update restarts the service. Treat any change here as security-critical; `UpdateRunnerTests` pins that the flag defaults off and that API keys alone don't enable it.
|
||||||
|
|
||||||
|
**Localization (SDD §12, M7 — en + de):**
|
||||||
|
- **Resources:** UI strings live in `src/App/Localization/Strings.resx` (neutral = English) and `Strings.de.resx`. MSBuild generates a **strongly-typed** `Strings` class from the neutral resx (see the `EmbeddedResource` block in `MeterVault.App.csproj`), aliased as `S` in `_Imports.razor`. Components write `@S.Common_Save`, never a string key, so a stale key is a build error. `Loc.F(S.Key, args)` formats the `{0}` ones.
|
||||||
|
- **Adding a string means editing both resx files.** `StringResourceTests` fails the build on a missing or blank translation, a placeholder mismatch, an orphan, or a key nothing references: resource fallback would otherwise hide a half-translated release.
|
||||||
|
- **Enums:** domain and analysis enums stay bare identifiers (they are persisted as text and appear in the REST API and CSV). `DisplayNames.Display()` (partials `DisplayNames.Analysis.cs`, `DisplayNames.Problems.cs`) is the single place that decides how each value is *spoken*. `EnumDisplayNameTests` fails if a value in `LocalizedEnums` has no wording.
|
||||||
|
- **MudBlazor's own labels** are translated by `MeterVaultMudLocalizer`.
|
||||||
|
- **User data** from the database (meter, energy-type and category names) is **never** translated.
|
||||||
|
- **Language choice:** per request. The cookie the `/culture/set` endpoint writes, else `Accept-Language`, else `MeterVault__Locale` (default `en`). Switching must be a **full reload** (`forceLoad`): a Blazor Server circuit is fixed to the culture of the request that opened it.
|
||||||
|
- **Formatting:** `Format.*` formats against `CurrentCulture`, so numbers and month labels follow the reader. The CSV importer's de-DE parsing is unrelated and unchanged, because that dialect belongs to the files, not the reader.
|
||||||
|
|
||||||
|
**Secrets (SDD §6.4):** broker/HA tokens are **never** stored in DB plaintext. Two forms, chosen per connector in the admin UI: a *reference* (`token_env`/`password_env` naming an env var or Docker secret path) resolved at runtime, or *encrypted at rest* (`token_enc`/`password_enc`) via `SecretProtector` over the ASP.NET Core data-protection key ring. Exactly one survives a save; `EndpointSecret.Resolve` is the single resolution path (encrypted wins). The key ring lives outside the app directory (`MeterVault__DataProtectionKeyPath`, default `/var/lib/metervault/keys`) because the LXC updater republishes `/opt/metervault`. `ExportService` drops `*_enc` values — they are bound to the originating key ring.
|
||||||
|
|
||||||
## Reference-data behaviours the code must reproduce (from `sampledata/`)
|
## Reference-data behaviours the code must reproduce (from `sampledata/`)
|
||||||
|
|
||||||
@@ -91,7 +295,7 @@ These CSVs are the German-dialect *Energiebilanz* spreadsheet export and define
|
|||||||
- **Two date formats:** `Monat YYYY` (German month names, monthly tables) and `DD.MM.YYYY` (event rows).
|
- **Two date formats:** `Monat YYYY` (German month names, monthly tables) and `DD.MM.YYYY` (event rows).
|
||||||
- **Skip inline summary rows** (`Total`, `Heute`, `Seitbeginn Tage`, `Seit YYYY`) and **all-zero future placeholder rows** (e.g. Dec 2026) — do not ingest them. **Negatives are valid** (savings, grid balance).
|
- **Skip inline summary rows** (`Total`, `Heute`, `Seitbeginn Tage`, `Seit YYYY`) and **all-zero future placeholder rows** (e.g. Dec 2026) — do not ingest them. **Negatives are valid** (savings, grid balance).
|
||||||
- **Water register swaps mid-series** (…861 → 2 → 15): consumption must stay continuous across the boundary via a `meter_swap` event.
|
- **Water register swaps mid-series** (…861 → 2 → 15): consumption must stay continuous across the boundary via a `meter_swap` event.
|
||||||
- **Electricity has 5 meters** (Haus, Netz, Auto, Solar 1, Solar 2) plus derived columns. Verified relations to reproduce: `Netz Einsparung = Haus − Netz`, `Ersparnis = Netz Einsparung × €/kWh`, `Kosten = Verbrauchskosten − Ersparnis` — but implement these as **user-definable virtual-meter expressions**, not hardcoded formulas.
|
- **Electricity has 5 meters** (Haus, Netz, Auto, Solar 1, Solar 2) plus derived columns. Verified relations to reproduce: `Netz Einsparung = Haus − Netz` (a virtual meter `m1 − m2`, checked through `VirtualEvaluator`), `Ersparnis = Netz Einsparung × €/kWh`, `Kosten = Verbrauchskosten − Ersparnis`. Implement these as **user-definable virtual meters and cost rules**, not hardcoded formulas; prices are never inside an expression. The sheet's `Kosten` is Netz × price, which is why the bill bills grid import (D-34).
|
||||||
- **Heating oil is the versatility stress test:** a consumable/tank model where consumption is derivable two ways — tank-level Δ, or burner runtime × rate (rate `fixed` from nozzle spec, or `empirical` = Δlevel ÷ Δhours). Early rows (1997–2004) carry deliveries only (no burner hours yet). Includes cm→litre dipstick calibration and forecast-to-empty.
|
- **Heating oil is the versatility stress test:** a consumable/tank model where consumption is derivable two ways — tank-level Δ, or burner runtime × rate (rate `fixed` from nozzle spec, or `empirical` = Δlevel ÷ Δhours). Early rows (1997–2004) carry deliveries only (no burner hours yet). Includes cm→litre dipstick calibration and forecast-to-empty.
|
||||||
|
|
||||||
## Git
|
## Git
|
||||||
|
|||||||
@@ -15,6 +15,16 @@
|
|||||||
<InvariantGlobalization>false</InvariantGlobalization>
|
<InvariantGlobalization>false</InvariantGlobalization>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<!-- VERSION on master is the single source of release truth: editing it tags and publishes
|
||||||
|
(.gitea/workflows/version-tag.yml). Stamp it into the assemblies too, so a running instance
|
||||||
|
can say which version it is and compare itself against the newest tag. Without this the app
|
||||||
|
reports 1.0.0 forever and an update banner would be meaningless. -->
|
||||||
|
<PropertyGroup>
|
||||||
|
<MeterVaultVersionFile>$(MSBuildThisFileDirectory)VERSION</MeterVaultVersionFile>
|
||||||
|
<MeterVaultVersion Condition="Exists('$(MeterVaultVersionFile)')">$([System.IO.File]::ReadAllText('$(MeterVaultVersionFile)').Trim().TrimStart('v'))</MeterVaultVersion>
|
||||||
|
<Version Condition="'$(MeterVaultVersion)' != ''">$(MeterVaultVersion)</Version>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
<!-- Test projects: mirror under tests/, name *.Tests, never packed, relaxed warnings. -->
|
<!-- Test projects: mirror under tests/, name *.Tests, never packed, relaxed warnings. -->
|
||||||
<PropertyGroup Condition="$(MSBuildProjectName.EndsWith('Tests'))">
|
<PropertyGroup Condition="$(MSBuildProjectName.EndsWith('Tests'))">
|
||||||
<IsPackable>false</IsPackable>
|
<IsPackable>false</IsPackable>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
<PackageVersion Include="MQTTnet" Version="5.2.0.1603" />
|
<PackageVersion Include="MQTTnet" Version="5.2.0.1603" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.9" />
|
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.9" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.9" />
|
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.9" />
|
||||||
|
<PackageVersion Include="Microsoft.AspNetCore.DataProtection.Abstractions" Version="10.0.9" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup Label="App / UI">
|
<ItemGroup Label="App / UI">
|
||||||
|
|||||||
@@ -16,27 +16,92 @@ full design.
|
|||||||
- **Immutable raw readings** on a TimescaleDB hypertable; a normalized, append-only **consumption**
|
- **Immutable raw readings** on a TimescaleDB hypertable; a normalized, append-only **consumption**
|
||||||
layer on top — reproducible, auditable.
|
layer on top — reproducible, auditable.
|
||||||
- **Seven measurement modes** (cumulative/generation registers, burner runtime, tank/consumable,
|
- **Seven measurement modes** (cumulative/generation registers, burner runtime, tank/consumable,
|
||||||
direct delta, instant rate, virtual). Handles meter swaps, counter resets, tank dip-sticks with
|
direct delta, instant rate, virtual). Handles meter swaps, counter resets and tank dip-sticks with
|
||||||
calibration, and **virtual meters** defined by an expression (PV self-consumption, savings, net).
|
calibration.
|
||||||
|
- **Virtual meters** with a validated formula over other meters: sum, difference or free formula,
|
||||||
|
e.g. `Solar 1 + Solar 2`, or self-consumption as `Haus − Netz`. They are analysed exactly like
|
||||||
|
physical meters: history, comparisons, source contributions, and costs where a cost rule applies.
|
||||||
|
They are computed from their sources on every read, so they never go stale. A missing source month
|
||||||
|
reads "no data", never a silent zero.
|
||||||
- **Tariff engine** with time-ranged price history (unit/base/feed-in), scoped global / per type /
|
- **Tariff engine** with time-ranged price history (unit/base/feed-in), scoped global / per type /
|
||||||
per meter; **cost categories** decoupled from energy types; meterless manual costs.
|
per meter. The bill counts each energy type's grid import (or its household use), not every meter
|
||||||
- **Continuous aggregates** (daily/monthly/yearly, local timezone) so dashboards never scan raw.
|
that happens to exist. Standing charges are counted once per scope. **Cost categories** are
|
||||||
- **Dashboard**: cost KPIs with period-over-period deltas, "what costs most", a "what cost more/
|
decoupled from energy types. Meterless manual costs are supported. The currency is configurable.
|
||||||
less" difference view, trends, a **PV/Solar panel** (generation, self-consumption, autarky %,
|
- **Rollups by local day and month**, written together with the consumption, so dashboards never
|
||||||
savings), an **oil/consumable panel** (tank gauge, deliveries, burner runtime, effective L/h,
|
scan raw readings. The Settings page shows how far each meter's analysis data is built.
|
||||||
forecast-to-empty) and a **per-meter detail view** (raw readings, consumption, sources, tariff
|
- **One period everywhere**: every page shares a period toolbar (month to date, last month, year
|
||||||
timeline, events), one-click reference-data load, CSV dry-run.
|
to date, previous year, last 12/24 months, all history, custom dates), a bucket size (day, week,
|
||||||
- **Per-energy-type flow pages** (Electricity, Water, …): a **Sankey diagram** of the meter chain —
|
month or year) and a comparison (previous period, previous year, or any calendar year). The
|
||||||
a downstream meter is a *subsection* of an upstream one (main → car, pool, garden, …), arrow
|
selection lives in the URL, so reload, Back and shared links keep it.
|
||||||
thickness ∝ amount, with an auto-computed "Other/unmetered" remainder. Meters can have several
|
- **Pages:**
|
||||||
upstreams (a merge, e.g. grid + solar → house).
|
- **Overview** of the selected period: cost with its composition, one card per energy type,
|
||||||
- **Admin UI**: full create/edit/delete for energy types, meters (with consumption recompute on
|
history chart, "what changed", and attention items with a direct fix.
|
||||||
mode/baseline change, and cycle-safe upstream-meter wiring), ingest sources, tariffs, cost
|
- **Analysis** page to explore the portfolio, an energy type, a cost category, one meter or up to
|
||||||
categories, and MQTT/Home-Assistant connectors; a "Test connection" for Home Assistant;
|
six meters side by side, by quantity or cost.
|
||||||
effective-settings view.
|
- **Energy type** pages with Overview, History, a **Sankey flow** of the meter chain, and a meter
|
||||||
|
list. A downstream meter is a *subsection* of an upstream one (main → car, pool, garden, …), and
|
||||||
|
the unmetered remainder is shown as "Other".
|
||||||
|
- A **meter hub** with tabs for Analysis, Readings, Normalized data, Events, Tariffs and Sources
|
||||||
|
(Calculation for virtual meters). The record tabs are paged over the full history.
|
||||||
|
- **Solar** (generation, self-consumption, feed-in, autarky, savings, with setup help for missing
|
||||||
|
meter roles) and **Tanks & consumables** (last dipstick, estimate now, deliveries, burner
|
||||||
|
runtime, forecast).
|
||||||
|
- **Honest numbers**: a true zero, missing data, data that is only monthly, and a missing price
|
||||||
|
are shown differently everywhere, in cards, charts, tables and the **CSV export** of any view.
|
||||||
|
- **Admin UI**: full create/edit/delete for energy types, meters (with a calculation editor and
|
||||||
|
live preview for virtual meters, friendly meter roles, and a recompute when a change needs one),
|
||||||
|
ingest sources, tariffs (unit check; "Add tariff" links from a missing price open the editor
|
||||||
|
prefilled), cost categories, and MQTT/Home-Assistant connectors. Includes a "Test connection" for
|
||||||
|
Home Assistant and an effective-settings view.
|
||||||
|
- English and German UI, light and dark theme, usable down to phone width.
|
||||||
- **REST API + OpenAPI/Swagger**, API-key auth, reverse-proxy trust (Authelia/Traefik).
|
- **REST API + OpenAPI/Swagger**, API-key auth, reverse-proxy trust (Authelia/Traefik).
|
||||||
- **JSON config export/import** for portability; Docker Compose + multi-arch image.
|
- **JSON config export/import** for portability; Docker Compose + multi-arch image.
|
||||||
|
|
||||||
|
## Upgrading to 0.4.0
|
||||||
|
|
||||||
|
0.4.0 reworks the dashboards and how analysis and costs are computed. Read
|
||||||
|
[`docs/RELEASE_NOTES.md`](docs/RELEASE_NOTES.md) first: some figures change on purpose. Back up the
|
||||||
|
database (`pg_dump`), then start the new image once and let it finish:
|
||||||
|
|
||||||
|
- **The first start rebuilds every meter's analysis data** (normalization revision 3): consumption,
|
||||||
|
the new day/month rollups and coverage. This happens before the web server listens, so the app is
|
||||||
|
unreachable while it runs and Compose may report the container unhealthy. Let it finish rather
|
||||||
|
than killing it. The time grows with the number of raw readings: about 0.1 s per monthly meter,
|
||||||
|
~0.6 s per meter with ten years of daily readings, ~1.4 s per meter with a year of hourly
|
||||||
|
readings. A 1,000-meter test dataset (1.3 M readings) took about six minutes. Progress is logged,
|
||||||
|
and a meter that fails is retried at the next start.
|
||||||
|
- **Virtual meters without a formula** get the sum their links imply stored as an explicit formula.
|
||||||
|
The log names the meters converted and those that still need configuration.
|
||||||
|
- **The bill changes**: the grid meter is billed instead of every meter of a type, feed-in is
|
||||||
|
credited only on a grid-export meter, a missing tariff is "not priced" instead of 0, and standing
|
||||||
|
charges count once per scope. The seeded demo now matches the spreadsheet's yearly costs.
|
||||||
|
- **The REST API only adds fields**: see the release notes for `costStatus`, `costAvailability`,
|
||||||
|
`costRule`, `notCosted`, `missingPrices`, `status` and `latestMonth`.
|
||||||
|
- **Rolling back** to 0.3.0: the old version ignores the new tables and never read the dropped
|
||||||
|
continuous aggregates. Consumption stays as 0.4.0 booked it until each meter next ingests a
|
||||||
|
reading. Stored virtual-meter formulas remain; 0.3.0 ignores them and sums links again.
|
||||||
|
|
||||||
|
## 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)
|
## Quick start (Docker)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -66,18 +131,69 @@ Configuration is via environment variables (`Section__Key` double-underscore map
|
|||||||
| Variable | Purpose |
|
| Variable | Purpose |
|
||||||
|----------|---------|
|
|----------|---------|
|
||||||
| `ConnectionStrings__Default` | PostgreSQL/Timescale connection string |
|
| `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__Currency` | Currency code of every amount (default `EUR`). Tariffs in another currency are reported as not fitting, never converted. |
|
||||||
|
| `MeterVault__RawRetentionDays` | Shown on the Settings page but **not enforced**: raw readings are kept, because every recompute rebuilds a meter from them. |
|
||||||
| `MeterVault__ApiKeys__0` | An API key accepted on the `X-Api-Key` header |
|
| `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) |
|
| `MeterVault__AllowAnonymousApi` | `true` to open the REST API without a key (trusted LAN only) |
|
||||||
| `MeterVault__ReverseProxyTrust` | `true` to honour `X-Forwarded-User` behind an auth proxy |
|
| `MeterVault__ReverseProxyTrust` | `true` to honour `X-Forwarded-User` behind an auth proxy |
|
||||||
| `MeterVault__EnableLiveIngestion` | `false` to disable the MQTT/HA workers |
|
| `MeterVault__EnableLiveIngestion` | `false` to disable the MQTT/HA workers |
|
||||||
| `MeterVault__SeedReferenceData` | `true` to load the bundled demo dataset on first start (idempotent) |
|
| `MeterVault__SeedReferenceData` | `true` to load the bundled demo dataset on first start (idempotent) |
|
||||||
|
| `MeterVault__DataProtectionKeyPath` | Where the key ring for UI-entered connector secrets lives (default `/var/lib/metervault/keys`) |
|
||||||
|
| `MeterVault__UpdateCheckEnabled` | `false` to stop the dashboard checking for a newer release |
|
||||||
|
| `MeterVault__AllowInAppUpdate` | `true` to allow updates triggered from the UI/API — no key required, so anything that can reach MeterVault can trigger one; see below |
|
||||||
|
| `MeterVault__UpdateCheckUrl` | Tag listing consulted by that check (repoint at a fork; blank also disables it) |
|
||||||
|
|
||||||
The REST API is **closed by default**: with no `ApiKeys` configured and `AllowAnonymousApi` off, it
|
The REST API is **closed by default**: with no `ApiKeys` configured and `AllowAnonymousApi` off, it
|
||||||
returns 401. Set at least one API key (or open it explicitly for a trusted network).
|
returns 401. Set at least one API key (or open it explicitly for a trusted network).
|
||||||
|
|
||||||
Secrets (broker/HA tokens) are **never** stored in the database — endpoint configs hold the *name*
|
> **The web UI has no authentication.** There is no login: anything that can reach the port can read
|
||||||
of an environment variable, resolved at runtime.
|
> and change everything, including connectors and their stored secrets. Put it behind a reverse proxy
|
||||||
|
> with auth (Authelia, Traefik forward-auth, …) — `MeterVault__ReverseProxyTrust` then honours the
|
||||||
|
> user header — or keep it on a trusted network.
|
||||||
|
|
||||||
|
The dashboard compares the running build against the newest tag in the source repository and shows a
|
||||||
|
banner when it is behind. That is a plain GET of a public tag list — nothing about the instance is
|
||||||
|
sent — cached for six hours, and it never blocks or fails a page render. Turn it off with
|
||||||
|
`MeterVault__UpdateCheckEnabled=false`.
|
||||||
|
|
||||||
|
### Updating from the UI (opt-in)
|
||||||
|
|
||||||
|
`MeterVault__AllowInAppUpdate=true` adds an **Update now** button to that banner, and a
|
||||||
|
`POST /api/v1/system/update` endpoint for scripting it from Home Assistant or `curl`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://metervault:8760/api/v1/system/update -H "X-MeterVault-Update: 1"
|
||||||
|
```
|
||||||
|
|
||||||
|
Both pull the latest source, rebuild, and restart the service — a few minutes during which MeterVault
|
||||||
|
is unavailable. Readings are untouched; ingestion resumes on restart. LXC only: containers are
|
||||||
|
replaced by pulling a new image, and the endpoint reports that rather than pretending.
|
||||||
|
|
||||||
|
> **That flag is the whole gate — there is no key and no prompt.** With it on, anything that can
|
||||||
|
> reach MeterVault can trigger a rebuild and restart. Realistically that is a repeatable denial of
|
||||||
|
> service (minutes of downtime and a busy CPU per request), not code injection, because the build
|
||||||
|
> comes from your own repository — but it becomes remote code execution if that repository is ever
|
||||||
|
> compromised. It defaults off. Enable it only on a network you trust, or behind an authenticating
|
||||||
|
> proxy.
|
||||||
|
>
|
||||||
|
> The `X-MeterVault-Update` header is **not** authentication: it stops a *different website* driving
|
||||||
|
> the endpoint through the browser of someone on your network, which a plain HTML form could
|
||||||
|
> otherwise do. The UI button does not need it — it runs over the Blazor circuit, which a foreign
|
||||||
|
> page cannot reach. Every triggered update is logged as a warning, since with no key there is no
|
||||||
|
> caller to attribute it to.
|
||||||
|
|
||||||
|
Secrets (broker/HA tokens) are **never** stored in the database as plaintext. Each connector picks
|
||||||
|
one of two forms: the *name* of an environment variable, resolved at runtime, or the secret typed
|
||||||
|
into the admin UI and encrypted at rest under the data-protection key ring. Either way a `pg_dump`
|
||||||
|
or JSON export carries nothing usable.
|
||||||
|
|
||||||
|
Keep the key ring on persistent storage outside the app directory — the default
|
||||||
|
`/var/lib/metervault/keys` survives an LXC update, and the Compose file mounts a named volume for it.
|
||||||
|
Lose it and every UI-entered secret must be re-entered. The key ring is on disk, so this protects
|
||||||
|
against leaked database content, not against an attacker who already has the host; that is the same
|
||||||
|
trust boundary an environment variable has.
|
||||||
|
|
||||||
## Pushing readings (Home Assistant)
|
## Pushing readings (Home Assistant)
|
||||||
|
|
||||||
|
|||||||
@@ -30,15 +30,22 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
ConnectionStrings__Default: "Host=db;Port=5432;Database=metervault;Username=metervault;Password=${METERVAULT_DB_PASSWORD:-metervault}"
|
ConnectionStrings__Default: "Host=db;Port=5432;Database=metervault;Username=metervault;Password=${METERVAULT_DB_PASSWORD:-metervault}"
|
||||||
ASPNETCORE_ENVIRONMENT: Production
|
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__TimeZone: ${METERVAULT_TIMEZONE:-Europe/Berlin}
|
||||||
MeterVault__Currency: ${METERVAULT_CURRENCY:-EUR}
|
MeterVault__Currency: ${METERVAULT_CURRENCY:-EUR}
|
||||||
MeterVault__Locale: ${METERVAULT_LOCALE:-en}
|
MeterVault__Locale: ${METERVAULT_LOCALE:-en}
|
||||||
# Set true for a populated demo: loads the bundled Energiebilanz dataset on first start
|
# Set true for a populated demo: loads the bundled Energiebilanz dataset on first start
|
||||||
# (idempotent). Leave false for a clean instance.
|
# (idempotent). Leave false for a clean instance.
|
||||||
MeterVault__SeedReferenceData: ${METERVAULT_SEED:-false}
|
MeterVault__SeedReferenceData: ${METERVAULT_SEED:-false}
|
||||||
|
# Key ring for connector secrets typed into the admin UI. On the named volume below so it
|
||||||
|
# survives image updates — lose it and every stored token must be re-entered.
|
||||||
|
MeterVault__DataProtectionKeyPath: /var/lib/metervault/keys
|
||||||
# REST API is closed by default. Set a key to enable it (or AllowAnonymousApi on a trusted LAN):
|
# REST API is closed by default. Set a key to enable it (or AllowAnonymousApi on a trusted LAN):
|
||||||
# MeterVault__ApiKeys__0: your-secret-key
|
# MeterVault__ApiKeys__0: your-secret-key
|
||||||
# MeterVault__AllowAnonymousApi: "true"
|
# MeterVault__AllowAnonymousApi: "true"
|
||||||
|
volumes:
|
||||||
|
- metervault_keys:/var/lib/metervault/keys
|
||||||
ports:
|
ports:
|
||||||
- "${METERVAULT_PORT:-8760}:8760"
|
- "${METERVAULT_PORT:-8760}:8760"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -51,3 +58,4 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
metervault_db:
|
metervault_db:
|
||||||
|
metervault_keys:
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
: "${INSTALL_DIR:=/opt/metervault}"
|
: "${INSTALL_DIR:=/opt/metervault}"
|
||||||
: "${SOURCE_DIR:=/opt/metervault-src}"
|
: "${SOURCE_DIR:=/opt/metervault-src}"
|
||||||
: "${ENV_FILE:=/etc/metervault/environment}"
|
: "${ENV_FILE:=/etc/metervault/environment}"
|
||||||
|
: "${KEYRING_DIR:=/var/lib/metervault/keys}"
|
||||||
: "${DB_NAME:=metervault}"
|
: "${DB_NAME:=metervault}"
|
||||||
: "${DB_USER:=metervault}"
|
: "${DB_USER:=metervault}"
|
||||||
|
|
||||||
@@ -203,6 +204,13 @@ EOF
|
|||||||
chmod 600 "${ENV_FILE}"
|
chmod 600 "${ENV_FILE}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Key ring for connector secrets typed into the admin UI (SDD §6.4). The app creates this itself if
|
||||||
|
# missing, but with the default umask — created here instead so it is 0700 from the start, and so it
|
||||||
|
# is visibly outside /opt/metervault, which the updater republishes on every run.
|
||||||
|
write_keyring_dir() {
|
||||||
|
install -d -m 0700 "${KEYRING_DIR}"
|
||||||
|
}
|
||||||
|
|
||||||
write_systemd() {
|
write_systemd() {
|
||||||
cat <<'EOF' >/etc/systemd/system/metervault.service
|
cat <<'EOF' >/etc/systemd/system/metervault.service
|
||||||
[Unit]
|
[Unit]
|
||||||
@@ -246,6 +254,7 @@ main() {
|
|||||||
install_dotnet_sdk
|
install_dotnet_sdk
|
||||||
build_metervault
|
build_metervault
|
||||||
write_env
|
write_env
|
||||||
|
write_keyring_dir
|
||||||
write_systemd
|
write_systemd
|
||||||
|
|
||||||
systemctl daemon-reload 2>/dev/null || true
|
systemctl daemon-reload 2>/dev/null || true
|
||||||
|
|||||||
@@ -27,6 +27,21 @@ fi
|
|||||||
|
|
||||||
export DOTNET_CLI_TELEMETRY_OPTOUT=1 DOTNET_NOLOGO=1
|
export DOTNET_CLI_TELEMETRY_OPTOUT=1 DOTNET_NOLOGO=1
|
||||||
|
|
||||||
|
# Anything between the stop and the restart can fail under `set -e`: a dotnet publish OOM-killed in a
|
||||||
|
# small container, a Gitea outage mid-fetch, a transient compile error on master. systemd's
|
||||||
|
# Restart=always does not cover a unit stopped on purpose, so without this the service simply stays
|
||||||
|
# down until someone notices. Bring the old build back up and say what happened — a failed update
|
||||||
|
# should cost the new version, not the running one.
|
||||||
|
restore_service_on_failure() {
|
||||||
|
local code=$?
|
||||||
|
if [[ ${code} -ne 0 ]]; then
|
||||||
|
echo "Update failed (exit ${code}). Restarting the previous build…" >&2
|
||||||
|
systemctl start metervault || echo "Could not restart metervault — check 'systemctl status metervault'." >&2
|
||||||
|
fi
|
||||||
|
exit "${code}"
|
||||||
|
}
|
||||||
|
trap restore_service_on_failure EXIT
|
||||||
|
|
||||||
echo "Stopping metervault…"
|
echo "Stopping metervault…"
|
||||||
systemctl stop metervault || true
|
systemctl stop metervault || true
|
||||||
|
|
||||||
|
|||||||
@@ -18,9 +18,13 @@
|
|||||||
|
|
||||||
<Config Name="Database connection" Target="ConnectionStrings__Default" Default="Host=timescaledb;Port=5432;Database=metervault;Username=metervault;Password=changeme" Mode="" Description="PostgreSQL/TimescaleDB connection string" Type="Variable" Display="always" Required="true">Host=timescaledb;Port=5432;Database=metervault;Username=metervault;Password=changeme</Config>
|
<Config Name="Database connection" Target="ConnectionStrings__Default" Default="Host=timescaledb;Port=5432;Database=metervault;Username=metervault;Password=changeme" Mode="" Description="PostgreSQL/TimescaleDB connection string" Type="Variable" Display="always" Required="true">Host=timescaledb;Port=5432;Database=metervault;Username=metervault;Password=changeme</Config>
|
||||||
|
|
||||||
<Config Name="Time zone" Target="MeterVault__TimeZone" Default="Europe/Berlin" Mode="" Description="IANA timezone for bucketing/display" Type="Variable" Display="always" Required="false">Europe/Berlin</Config>
|
<Config Name="Time zone" Target="MeterVault__TimeZone" Default="Europe/Berlin" Mode="" Description="IANA timezone for bucketing, display and month attribution. Changing it re-derives stored consumption at the next start." Type="Variable" Display="always" Required="false">Europe/Berlin</Config>
|
||||||
|
|
||||||
<Config Name="API key" Target="MeterVault__ApiKeys__0" Default="" Mode="" Description="API key for the REST API (X-Api-Key header). Leave blank to leave the API open." Type="Variable" Display="always" Required="false" Mask="true"/>
|
<Config Name="Language" Target="MeterVault__Locale" Default="en" Mode="" Description="Default UI language: en or de. Each visitor can switch it in the app bar." Type="Variable" Display="always" Required="false">en</Config>
|
||||||
|
|
||||||
|
<Config Name="API key" Target="MeterVault__ApiKeys__0" Default="" Mode="" Description="API key for the REST API (X-Api-Key header). Leave blank and the API stays closed (401)." Type="Variable" Display="always" Required="false" Mask="true"/>
|
||||||
|
|
||||||
<Config Name="Reverse-proxy trust" Target="MeterVault__ReverseProxyTrust" Default="false" Mode="" Description="Honour X-Forwarded-User from a trusted auth proxy" Type="Variable" Display="advanced" Required="false">false</Config>
|
<Config Name="Reverse-proxy trust" Target="MeterVault__ReverseProxyTrust" Default="false" Mode="" Description="Honour X-Forwarded-User from a trusted auth proxy" Type="Variable" Display="advanced" Required="false">false</Config>
|
||||||
|
|
||||||
|
<Config Name="Secret key ring" Target="/var/lib/metervault/keys" Default="/mnt/user/appdata/metervault/keys" Mode="rw" Description="Encryption keys for connector secrets entered in the web UI. Must persist: without this mapping every stored token is lost when the container is recreated." Type="Path" Display="always" Required="true">/mnt/user/appdata/metervault/keys</Config>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@@ -0,0 +1,820 @@
|
|||||||
|
# Analysis rework: implementation note
|
||||||
|
|
||||||
|
Companion to [DASHBOARD_ANALYSIS_CHANGE_BRIEF.md](DASHBOARD_ANALYSIS_CHANGE_BRIEF.md). This is the Phase 1
|
||||||
|
deliverable that resolves the brief's open choices. Every decision has an ID (D-nn) so code, tests and the final
|
||||||
|
report can refer to it. Written against `c0f52db`; revised after an adversarial design review.
|
||||||
|
|
||||||
|
The note was kept current through Phase 5:
|
||||||
|
- §11: amendments from the Phase 1 module review.
|
||||||
|
- §12: amendments from the acceptance review.
|
||||||
|
- §13: decisions recorded with the final documentation.
|
||||||
|
- §14: amendments from the performance measurement.
|
||||||
|
- §15: amendments from the first reports of a live instance.
|
||||||
|
- §9 and §10: extended with what the implementation measured and changed.
|
||||||
|
|
||||||
|
The outcome is in [ANALYSIS_REPORT.md](ANALYSIS_REPORT.md), the user-facing changes in
|
||||||
|
[RELEASE_NOTES.md](RELEASE_NOTES.md).
|
||||||
|
|
||||||
|
## 1. What the code review established
|
||||||
|
|
||||||
|
The brief's findings A01–A14 are confirmed by the source, with these refinements:
|
||||||
|
|
||||||
|
- **Nothing evaluates a virtual meter.** `VirtualNormalizer` and `ExpressionEvaluator` run only in tests.
|
||||||
|
`NormalizationService` skips virtual meters. The only value in production is `FlowService`'s sum of
|
||||||
|
incoming links, which ignores any stored formula. SDD §14.1 is not implemented in either direction.
|
||||||
|
- **The expression evaluator is unsafe for user input.** It has no AST, unknown identifiers evaluate to 0, and
|
||||||
|
recursion is unbounded.
|
||||||
|
- **Consumption rows store only the interval end.** There is no unit column, and `Estimated` covers four
|
||||||
|
different cases. Coverage cannot be recovered from sums; it has to be captured during normalization.
|
||||||
|
- **Month division only exists for cumulative and generation counters.** Tanks, runtime, direct-delta and
|
||||||
|
instant-rate modes book a whole interval at its end. The same is true of swaps, resets, decreases and first
|
||||||
|
readings.
|
||||||
|
- **The continuous aggregates cannot be used:** they are Berlin-only, materialized-only on TimescaleDB ≥ 2.13,
|
||||||
|
and never backfilled. No reader uses them, yet they are refreshed hourly.
|
||||||
|
- **Raw retention is not implemented.** Turning it on would destroy history, because every recompute rebuilds a
|
||||||
|
meter from the readings that remain.
|
||||||
|
- **The seeded costs are wrong in a way the spreadsheet proves.** The sheet bills `Kosten = Netz × price`, but
|
||||||
|
the seed prices Haus + Netz + Auto. The seed is also missing the water price rise to 7.00 €/m³ in 2026.
|
||||||
|
- **The tooling gaps are real.** There is no clock abstraction, no bUnit or Playwright, and the API responses
|
||||||
|
have no contract tests.
|
||||||
|
|
||||||
|
## 2. Periods and comparisons
|
||||||
|
|
||||||
|
- **D-01 Clock.** `TimeProvider` is registered. Pages and API endpoints read "now" once per request and resolve
|
||||||
|
a period with the pure resolver, then pass the resolved period down. Services never read the clock. Where one
|
||||||
|
genuinely needs "now" (freshness, forecast), it takes a trailing optional `TimeProvider? time = null`. Tests use
|
||||||
|
a small `FixedTimeProvider`.
|
||||||
|
- **D-02 Presets and URL tokens.**
|
||||||
|
- `period=mtd|last-month|ytd|prev-year|12m|24m|all|custom`, with `from`/`to` (yyyy-MM-dd) used only for
|
||||||
|
`custom`.
|
||||||
|
- Overview default: `mtd`. History pages default: `12m`, which is 12 calendar buckets ending with the current
|
||||||
|
partial month.
|
||||||
|
- `all` spans the availability metadata (D-19), not a fixed century.
|
||||||
|
- A page default applies only when no period key is present. An invalid token falls back to the default and
|
||||||
|
shows a notice.
|
||||||
|
- **D-03 Bounds.**
|
||||||
|
- A period resolves once, in the instance zone, into two forms: a local inclusive date range for display, and
|
||||||
|
a half-open UTC range `[from, to)` for queries.
|
||||||
|
- `to` is the local midnight after the end date, or the captured "now" for to-date periods.
|
||||||
|
- Quantities, costs, comparisons and exports all use the same bounds.
|
||||||
|
- **D-04 Now and the future.**
|
||||||
|
- Actual figures stop at "now". A row counts as recorded after now when its source interval ends after now.
|
||||||
|
Examples: a current-month label row, a future-stamped Tasmota row.
|
||||||
|
- Such rows are excluded from actuals and reported in a separately labelled "recorded after now" block (rows,
|
||||||
|
amount, dates), with an attention item.
|
||||||
|
- A range entirely in the future reports "not yet occurred".
|
||||||
|
- **D-05 Buckets.**
|
||||||
|
- Buckets are `day|week|month|year|auto`. Weeks start on Monday in local time.
|
||||||
|
- `auto` picks one bucket for the whole chart: the coarsest resolution any plotted series needs, at most 400
|
||||||
|
points.
|
||||||
|
- An explicit bucket above 400 points is refused with a coarser suggestion.
|
||||||
|
- Bucket bounds are local midnights, clipped to the period.
|
||||||
|
- **D-06 Comparisons.**
|
||||||
|
- `compare=none|prev-period|prev-year|year:YYYY`. `year:YYYY` needs a year-aligned range.
|
||||||
|
- Shifting uses local calendar units, never durations:
|
||||||
|
- Whole years shift by years, and whole months by months. Anything else shifts by days.
|
||||||
|
- `12m`/`24m` compare with the N months before. `all` has no comparison.
|
||||||
|
- The cut-off maps as local date plus wall time:
|
||||||
|
- A nonexistent time takes the first valid instant after the gap.
|
||||||
|
- An ambiguous time takes the occurrence with "now"'s offset if one matches, otherwise the first occurrence.
|
||||||
|
- A day that does not exist in the target month (31st, 29 Feb) cuts at that month's end.
|
||||||
|
- **D-07 Matched coverage.**
|
||||||
|
- A change figure is "confident" only over the range both periods actually cover. The current period's covered
|
||||||
|
range is shifted, intersected with the comparison's coverage, and trimmed to whole buckets where resolution is
|
||||||
|
coarser than the cut.
|
||||||
|
- Both requested ranges and the matched range are shown.
|
||||||
|
- An empty match means "not comparable": absolute values only, no percentage.
|
||||||
|
- **D-08 Change figures.**
|
||||||
|
- The absolute difference is always shown.
|
||||||
|
- The percentage is "not applicable" when the baseline is ≤ 0 or unavailable.
|
||||||
|
- Colours depend on the metric: more consumption is not "good", more generation is.
|
||||||
|
- **D-09 Projections** are separate and labelled "Projection (straight-line from N days)".
|
||||||
|
- Method: the covered rate × the remaining days. Standing charges are added exactly per day.
|
||||||
|
- A projection is suppressed when:
|
||||||
|
- coverage ends more than 2× the meter's typical interval before now;
|
||||||
|
- covered elapsed time is under 7 days (month) or 30 days (year);
|
||||||
|
- the resolution is coarser than the period.
|
||||||
|
- A change chip never compares a projection with an actual.
|
||||||
|
|
||||||
|
## 3. Data layer
|
||||||
|
|
||||||
|
- **D-10 Engine intervals.** Every `Consumption` row carries its source interval: `IntervalStart`,
|
||||||
|
`IntervalEnd` and `Divided`, as EF-ignored properties, so the schema does not change. Each mode sets them:
|
||||||
|
|
||||||
|
| Mode | Interval |
|
||||||
|
|---|---|
|
||||||
|
| Counters | previous effective reading → this one, with `GapSegment` bounds for divided shares |
|
||||||
|
| Runtime | previous effective time |
|
||||||
|
| Tank | previous TankLevel event |
|
||||||
|
| Instant rate | previous sample |
|
||||||
|
| Direct delta | previous reading, or the labelled month for a label |
|
||||||
|
| First reading | the labelled month for a label; `[InstalledAt, t]` when set; otherwise an unknown start (D-14) |
|
||||||
|
|
||||||
|
`Coalesce` keeps the minimum start and the maximum end.
|
||||||
|
- **D-11 Midnight stamps.** A non-label row whose interval end falls exactly on a local midnight is stamped
|
||||||
|
1 second earlier, inside the day it describes, mirroring `InsideSegment`. Label rows keep `StampTime`.
|
||||||
|
`[from, to)` stays everywhere.
|
||||||
|
- **D-12 Tables.** These are plain tables, not hypertables. Each has an FK to `meter` with `ON DELETE CASCADE`.
|
||||||
|
They are written by `RecomputeMeterAsync` in the caller's transaction, by diff, so only changed rows are
|
||||||
|
touched.
|
||||||
|
|
||||||
|
| Table | Key | Columns |
|
||||||
|
|---|---|---|
|
||||||
|
| `consumption_rollup` | `(meter_id, day, kind)` | `amount`, `measured`, `manual`, `imported`, `estimated`, `rows`, `flags` (baseline-delta, divided) |
|
||||||
|
| `consumption_rollup_month` | `(meter_id, month, kind)` | same columns; month and year reads use it |
|
||||||
|
| `meter_coverage` | `(meter_id, span_from)` | `span_to`, `resolution_class`, `divided_at_months`, `gap_reason` |
|
||||||
|
| `meter_rollup_state` | `(meter_id)` | `revision`, `zone`, `normalized_unit`, `kind`, `built_at` |
|
||||||
|
|
||||||
|
All local dates use the configured zone.
|
||||||
|
- **D-13 Coverage runs.**
|
||||||
|
- Consecutive intervals of the same resolution class merge into one run. The classes are ≤ 1 h, ≤ 1 day,
|
||||||
|
≤ 7 days, ≤ 1 local month, and coarser.
|
||||||
|
- An interval longer than a month is its own run.
|
||||||
|
- These open a gap instead of coverage:
|
||||||
|
- an unexplained decrease;
|
||||||
|
- a reset without PrevValue;
|
||||||
|
- an instant-rate gap longer than max(1 h, 10 × the median sample interval);
|
||||||
|
- deliveries before a tank's first level.
|
||||||
|
- Coverage is capped at now.
|
||||||
|
- **D-14 Bucket status.**
|
||||||
|
- **missing:** no run overlaps the bucket.
|
||||||
|
- **partial:** runs cover only part of it.
|
||||||
|
- **unresolved:** an undivided interval crosses the bucket edge by more than 5 % of the bucket length. The only
|
||||||
|
exception is an edge at a local month boundary the normalizer divided at.
|
||||||
|
- **available:** everything else. An available bucket with no rows is a true zero.
|
||||||
|
- **Opening balance:** a first reading with unknown start marks its bucket "partial (opening balance, start
|
||||||
|
unknown)". It is excluded from comparisons and projections, and the UI offers to set an install date.
|
||||||
|
- Provenance is a separate dimension, derived from the per-quality amounts plus `derived` for virtual meters.
|
||||||
|
- **D-15 Reading a period.**
|
||||||
|
- Rollups (month table for month/year buckets, day table otherwise) cover complete local days.
|
||||||
|
- For at most two partial edge days per range, one direct `consumption` query covers
|
||||||
|
`[edge-day midnight, cutoff)` (`meter_id = ANY(@ids)`).
|
||||||
|
- Each request makes one query per table and one tariff load. Virtual dependencies are expanded in memory
|
||||||
|
first. The 400-point and 6-series limits are enforced before any SQL runs.
|
||||||
|
- **D-16 Rebuild.**
|
||||||
|
- `CurrentRevision` becomes 3, because the engine books differently (D-11, intervals).
|
||||||
|
- The startup upgrade rebuilds consumption, rollups and coverage, and records `meter_rollup_state`.
|
||||||
|
- A meter whose state is missing or outdated (revision, or zone ≠ the reader's zone) reads as "analysis being
|
||||||
|
prepared", never as "no data".
|
||||||
|
- The migration purges derived rows of virtual meters. `RecomputeMeterAsync` purges them if a meter becomes
|
||||||
|
virtual.
|
||||||
|
- The upgrade skips and logs any meter whose oldest consumption predates its oldest reading or event, instead
|
||||||
|
of truncating history (D-44).
|
||||||
|
- **D-17 Continuous aggregates.** The new migration removes their policies and drops the three views.
|
||||||
|
`Monthly_continuous_aggregate_refreshes_and_matches_base` is replaced by a rollup-equals-consumption test
|
||||||
|
(water Dec 2022 = 14 m³).
|
||||||
|
- **D-18 Freshness.**
|
||||||
|
- The last reading or event time is the freshness mark.
|
||||||
|
- A live source is stale when that time is older than the larger of 3 × the median of its last 20 intervals
|
||||||
|
and 3 × its poll interval.
|
||||||
|
- Import-only meters are "historical", never "stale".
|
||||||
|
- **D-19 Availability.**
|
||||||
|
- A quantity scope's availability is its coverage.
|
||||||
|
- A cost scope's availability is its billed meters' coverage plus its manual costs' `PeriodStart` days.
|
||||||
|
- Both are capped at now.
|
||||||
|
- "Latest period with data" is the latest local month ≤ now in that union. It is returned with its month and
|
||||||
|
basis (meters / manual / both).
|
||||||
|
|
||||||
|
## 4. Quantities, units and totals
|
||||||
|
|
||||||
|
- **D-20 Normalized quantity.** A Core function `NormalizedQuantity(meter, tank, definition)` returns
|
||||||
|
`(kind, unit)`.
|
||||||
|
- Kinds: consumption, generation, export, runtime, and for virtual meters also net or indicator.
|
||||||
|
- Units by mode:
|
||||||
|
|
||||||
|
| Mode | Unit |
|
||||||
|
|---|---|
|
||||||
|
| RuntimeCounter | `h`, or the tank unit with a Fixed rate (kind stays runtime; provenance estimated) |
|
||||||
|
| InstantRate | the rate unit without `/h` (W→Wh, kW→kWh) |
|
||||||
|
| ConsumableBalance | the tank unit |
|
||||||
|
| Virtual | its declared result unit |
|
||||||
|
| Others | `Meter.Unit` |
|
||||||
|
|
||||||
|
- Aliases are normalized (m3 = m³).
|
||||||
|
- The normalized unit is stored in `meter_rollup_state`. Raw units appear only on the Readings tab.
|
||||||
|
- **D-21 Roles.** `total_load`, `grid_import` and `grid_export` are unique per energy type; saving a role moves
|
||||||
|
it and says who held it. The editor shows localized names and one-line meanings, and offers each role only for
|
||||||
|
compatible modes.
|
||||||
|
- **D-22 Per-type totals algorithm.** Pure and ordered:
|
||||||
|
1. **Supply meters** are grid_import or grid_export meters, GenerationCounter meters, and generation-kind
|
||||||
|
virtual meters. A link out of a supply meter is a *supply* edge and never makes its target a submeter.
|
||||||
|
2. **Containment.** A link from a physical, consumption-kind, non-supply meter makes the target a breakdown of
|
||||||
|
its parent.
|
||||||
|
3. **Measures per type:**
|
||||||
|
- *Use* is the total_load meter if there is one, otherwise the consumption roots. Consumption roots are
|
||||||
|
physical consumption-kind meters that are not supply meters, have no containment parent and are not
|
||||||
|
runtime meters. Tanks count.
|
||||||
|
- *Grid import* is the grid_import meters.
|
||||||
|
- *Export* is the grid_export meters, which are never consumption.
|
||||||
|
- *Generation* is the GenerationCounter roots.
|
||||||
|
- *Runtime* is the runtime meters.
|
||||||
|
4. Measures are never added across units.
|
||||||
|
5. Virtual meters are analysis views. Retired meters keep their history.
|
||||||
|
6. Seeded result: use = {Haus}, breakdown = {Auto}, grid import = {Netz}, generation = {Solar 1, Solar 2},
|
||||||
|
analysis-only = {Summe Solar}, runtime = {Brenner}, water use = {Wasser}, oil use = {Öltank}.
|
||||||
|
- **D-23 Override.**
|
||||||
|
- `Meter.Meta.totals` is `auto|always|never`. `always` on a virtual meter replaces its expanded dependencies in
|
||||||
|
that measure and in the bill. `always` on a meter whose ancestor or dependent is already counted is refused
|
||||||
|
on save, naming the other meter. `never` removes a meter from the measures it would join.
|
||||||
|
- The resulting cover is shared by the quantity totals and the bill.
|
||||||
|
- **D-24 Lifecycle.** Outside `[InstalledAt, RetiredAt]`, when set, a meter contributes a known zero to totals
|
||||||
|
and to virtual evaluation.
|
||||||
|
|
||||||
|
## 5. Virtual meters
|
||||||
|
|
||||||
|
- **D-25 Definition.**
|
||||||
|
- `Meter.Meta` holds `expression`, `referencedMeterIds` (always derived from the expression and rewritten on
|
||||||
|
save), `resultKind` (consumption|generation|net|indicator), `resultUnit` and
|
||||||
|
`costRule` (none|sourceCosts|ownQuantity).
|
||||||
|
- Topology links never define a calculation.
|
||||||
|
- **D-26 Formula.**
|
||||||
|
- The grammar is the existing one (`+ - * /`, parentheses, numbers). It is parsed to an AST, with limits of
|
||||||
|
2,000 characters and nesting depth 64.
|
||||||
|
- References are `m<id>`; any other identifier is invalid.
|
||||||
|
- Validation on save and on read covers: syntax, unknown or self references, cycles through nested virtual
|
||||||
|
meters (with the path), and kind/unit.
|
||||||
|
- Kind/unit rules for operands:
|
||||||
|
- `+`/`−` need the same unit and kind, or a declared `net`.
|
||||||
|
- Meter × or ÷ meter needs a declared `resultUnit` and kind `indicator`.
|
||||||
|
- Indicators are non-additive, never totalled and never costed.
|
||||||
|
- **D-27 Evaluation.**
|
||||||
|
- Evaluation runs on read, per bucket, from the sources' rollups.
|
||||||
|
- A virtual meter's coverage is the intersection of its sources' coverage, and its resolution is the coarsest
|
||||||
|
among them.
|
||||||
|
- A missing source makes the bucket missing (strict); an observed zero is a valid input. A non-finite result
|
||||||
|
makes it invalid, with the reason.
|
||||||
|
- A period total is the formula applied to the sources' totals over the joint coverage. It is partial when that
|
||||||
|
coverage is smaller than the period. For a linear formula without a constant this equals the sum of its
|
||||||
|
buckets; otherwise the series is marked non-additive.
|
||||||
|
- The result carries every source's series, status and dependency path.
|
||||||
|
- **D-28 Legacy definitions.**
|
||||||
|
- At startup, an expression-less virtual meter whose same-type incoming links name sources of one unit and
|
||||||
|
kind gets the equivalent explicit sum. Meters are processed in dependency order, the run is idempotent, and
|
||||||
|
the counts are logged.
|
||||||
|
- Anything else is flagged "needs configuration".
|
||||||
|
- Until converted, the reader evaluates the implied sum with status "legacy — confirm".
|
||||||
|
- `ReferenceDataImporter` writes Summe Solar's definition directly: `m(Solar 1) + m(Solar 2)`, generation,
|
||||||
|
kWh.
|
||||||
|
- **D-29 One evaluator.** `VirtualNormalizer` is removed from `NormalizationEngine.CreateDefault`. Its tests and
|
||||||
|
the golden Netz Einsparung reconciliation move to the new evaluator over month buckets (≥ 20 matches, ±1 kWh).
|
||||||
|
- **D-30 Flow.**
|
||||||
|
- A pure-sum virtual meter's incoming edges are its calculation dependencies, drawn at each source's own value
|
||||||
|
and marked "calculated".
|
||||||
|
- Other virtual meters appear only in the table view.
|
||||||
|
- Links are capped at the parent's value, and proportional splits are marked estimated.
|
||||||
|
- The Flow tab gets "Manage connections".
|
||||||
|
- **D-31 Editor.**
|
||||||
|
- Sum, Difference and Advanced modes, with source pickers by name (showing unit, kind and install/retire
|
||||||
|
dates) and a live preview for the selected period.
|
||||||
|
- On a virtual meter's page, a Calculation tab replaces Sources. Register details and Readings are removed.
|
||||||
|
Events keeps Note.
|
||||||
|
- Saving a Sum offers to sync the incoming links.
|
||||||
|
- **D-32 Export/import** carries `meter_link` and remaps meter ids inside definitions.
|
||||||
|
- **D-33 Deleting a meter** lists the virtual meters that depend on it and requires confirmation.
|
||||||
|
|
||||||
|
## 6. Costs
|
||||||
|
|
||||||
|
- **D-34 Billing.**
|
||||||
|
- Per energy type, the grid_import meters are billed if the type has one, otherwise the *use* meters (D-22).
|
||||||
|
- Generation meters are never billed.
|
||||||
|
- The feed-in credit is the FeedIn price × the export of grid_export meters.
|
||||||
|
- Runtime and virtual meters are not billed unless D-39 applies.
|
||||||
|
- **D-35 Separately billed submeter.** A containment child with an applicable meter-scoped UnitPrice is billed at
|
||||||
|
its own price. Its monthly quantity is subtracted from its billed ancestor's for pricing. Quantity totals do
|
||||||
|
not change.
|
||||||
|
- **D-36 Prices.**
|
||||||
|
- The monthly convention is kept: the price valid on the 15th of each local month.
|
||||||
|
- Every bucket size is priced month by month, so week and year buckets are split by local month. Changing the
|
||||||
|
bucket never changes a total.
|
||||||
|
- **D-37 Tariff applicability.**
|
||||||
|
- A UnitPrice or FeedIn tariff applies only when the unit denominator matches the meter's normalized unit.
|
||||||
|
Known scales are converted (ct, per 100 L, per MWh).
|
||||||
|
- A parsed mismatch makes the cost "unavailable (unit)". An unparseable unit applies, with a warning.
|
||||||
|
- BasePrice units are per day, per month (the default) or per year.
|
||||||
|
- The tariff editor validates units on save and states that Bonus, Discount and Tax are not applied yet.
|
||||||
|
- **D-38 Coverage.**
|
||||||
|
- A billed scope with no UnitPrice tariff at any date is "not priced (no tariff)". That is an attention item,
|
||||||
|
not "unavailable".
|
||||||
|
- A gap inside a priced scope's tariff history makes the cost "unavailable" for those months.
|
||||||
|
- An explicit zero tariff is a valid zero.
|
||||||
|
- A missing FeedIn price is reported only where a grid_export meter exists.
|
||||||
|
- **D-39 Virtual costs.**
|
||||||
|
- `sourceCosts` adds the sources' metered costs. It is allowed only for pure sums and excludes scope-level
|
||||||
|
standing charges.
|
||||||
|
- `ownQuantity` prices the virtual quantity with normal precedence. It is allowed only for linear formulas
|
||||||
|
without a constant.
|
||||||
|
- The default is `sourceCosts` for pure sums and `none` otherwise. The rule is named next to every virtual
|
||||||
|
cost.
|
||||||
|
- A virtual meter is part of the bill only through D-23.
|
||||||
|
- **D-40 Standing charges.**
|
||||||
|
- A standing charge accrues per local day, at value ÷ days in that local month, over the scope's service
|
||||||
|
period. The service period runs from the earliest InstalledAt or first data to the latest RetiredAt or now,
|
||||||
|
regardless of reading gaps.
|
||||||
|
- Meter-scoped charges stay on their meter.
|
||||||
|
- Type- and global-scoped charges are their own rows ("Standing charge — <type>" / "— global"), never split
|
||||||
|
across meters.
|
||||||
|
- **D-41 Manual costs** are booked in full on their `PeriodStart` local day, when that day is in `[from, to)`
|
||||||
|
and ≤ today. `PeriodEnd` is informational. A cost with `MeterId` set goes to that meter's categories.
|
||||||
|
- **D-42 Categories.**
|
||||||
|
- A category's cost is the priced cost of the non-overlapping cover of its members, using the bill algorithm
|
||||||
|
restricted to them, plus its manual costs. For example, Strom {Haus, Netz, Auto, Solar 1, Solar 2} gives
|
||||||
|
Netz × price, and a category {Auto} gives Auto × price.
|
||||||
|
- A type- or global-scoped standing-charge row joins a category only if the whole type (or, for global, every
|
||||||
|
billed meter) is a member.
|
||||||
|
- The composition is the disjoint categories, plus Uncategorized, plus standing-charge rows, and it reconciles
|
||||||
|
to the bill.
|
||||||
|
- A category that overlaps another, or covers meters outside the bill, is an "overlapping view" and stays
|
||||||
|
outside the composition.
|
||||||
|
- The donut is drawn only when every slice is ≥ 0; otherwise signed bars are used.
|
||||||
|
- **D-43 Currency.** `MeterVault__Currency` is used everywhere through one `Format.Money`.
|
||||||
|
- **D-44 Seed.**
|
||||||
|
- The water tariff 7.00 €/m³ from 2026-01-01 is added.
|
||||||
|
- Summe Solar gets an explicit definition.
|
||||||
|
- The seed tariffs are otherwise unchanged.
|
||||||
|
- Golden: the seeded yearly bill equals the sheet's `Jahreskosten` within ±0.02 € for 2022 (421.52),
|
||||||
|
2025 (7,907.64) and 2026 (2,940.19). This is computed on a frozen clock after 2026-05-31, with the tank
|
||||||
|
unpriced.
|
||||||
|
- 2023 and 2024 differ by 3.78 € and 0.46 €, because the sheet rounds its displayed prices. That is documented
|
||||||
|
and not tuned away.
|
||||||
|
|
||||||
|
## 7. API
|
||||||
|
|
||||||
|
- **D-45 Compatibility.**
|
||||||
|
- Contract tests for `/consumption`, `/cost` and `/dashboard/summary` are written before anything is rerouted.
|
||||||
|
- Every existing field and type is kept.
|
||||||
|
- `/consumption` and `/cost`:
|
||||||
|
- They keep exact-instant bounds, now converted with `ToUniversalTime()`.
|
||||||
|
- `cost` stays numeric (0 when nothing is priced), and `costStatus` and `missingPrices[]` are added.
|
||||||
|
- Virtual meters return evaluated values with a status.
|
||||||
|
- `/dashboard/summary`:
|
||||||
|
- It keeps its calendar month and year windows (legacy semantics, documented) but uses the new billing set.
|
||||||
|
- It adds `deltaPercentApplicable` and `latestMonth`.
|
||||||
|
- The numeric change is listed in the release notes.
|
||||||
|
|
||||||
|
## 8. Pages, navigation, state
|
||||||
|
|
||||||
|
- **D-46 URL state.**
|
||||||
|
- The query parses into an immutable `AnalysisQuery` value. Analysis reloads only when that value changes,
|
||||||
|
with a generation counter and cancellation.
|
||||||
|
- An action drop or tab change never reloads it.
|
||||||
|
- Toolbar and tab changes replace the history entry; drill-downs push. Defaults are never written on load, so
|
||||||
|
a deep-linked dialog is not dismissed.
|
||||||
|
- Initial loads stay in `OnInitialized`/`OnParametersSet`, because render tests read prerendered data.
|
||||||
|
- **D-47 Keys.**
|
||||||
|
- Meter tabs are `analysis|readings|normalized|events|tariffs|sources|calculation`. Legacy `consumption` maps
|
||||||
|
to `normalized`. On virtual meters, `sources` maps to `calculation` and `readings` to `analysis`.
|
||||||
|
- Energy-type tabs are `overview|history|flow|meters`.
|
||||||
|
- Analysis page scope is `scope=portfolio|type|category|meter|meters` with `id`/`ids` (at most 6), plus
|
||||||
|
`metric=consumption|generation|export|runtime|net|cost|balance`.
|
||||||
|
- Link helpers append the new keys after the existing ones.
|
||||||
|
- **D-48 Navigation.**
|
||||||
|
- Sidebar entries: Overview, Analysis, Meters, Energy types (with a retry item on error), Specialized views
|
||||||
|
(Solar, Tanks & consumables — always listed, with a setup state when unsupported), Data import,
|
||||||
|
Configuration.
|
||||||
|
- Expanded groups persist in a cookie. `NavState` gains `MetersChanged`.
|
||||||
|
- Breadcrumbs (Overview → type → meter) carry the period, and Back returns to the parent.
|
||||||
|
- Search shows a text label from the md breakpoint up, and its results link to Analysis (with the period) plus
|
||||||
|
quick entry.
|
||||||
|
- **D-49 Theme.**
|
||||||
|
- A scoped `ThemeState` is backed by a cookie that App reads, so prerender and the language switch keep the
|
||||||
|
mode.
|
||||||
|
- Charts use a transparent background and the theme's mode, and are re-keyed on theme or result change.
|
||||||
|
- Units and currency go into JS formatter strings. Points are nullable; there is no smoothing and no joining
|
||||||
|
across gaps.
|
||||||
|
- **D-50 Tables.**
|
||||||
|
- Readings, Normalized data and Events are paged server-side (100 rows), keyset-ordered, with `from`/`to`
|
||||||
|
filters.
|
||||||
|
- The manual-entry dialog runs its own queries (latest reading, reading at T with flags, boundaries), so its
|
||||||
|
verdicts never depend on a page of rows.
|
||||||
|
- **D-51 Drill-down.** Clicking a chart bucket keeps the scope, sets the bucket's range and the next finer
|
||||||
|
supported bucket. If there is none, it opens Normalized data filtered to that bucket.
|
||||||
|
- **D-52 Deep links.**
|
||||||
|
- Tariffs: `/admin/tariffs?scope=&id=&component=&from=&action=new` opens a pre-filled new-tariff dialog.
|
||||||
|
Missing-cost explanations link there with the first uncovered month.
|
||||||
|
- **D-53 Attention items.** Missing required price (scope and first month), stale live source, invalid or
|
||||||
|
unconverted virtual definition, recorded-after-now rows, possible overlap (a total_load and a grid_import root
|
||||||
|
that are not linked).
|
||||||
|
- **D-54 Solar and consumables.**
|
||||||
|
- Both adopt the shared toolbar, cards and charts. Units come from D-20.
|
||||||
|
- Solar shows a setup card for each missing role.
|
||||||
|
- Tanks show "Last dipstick: <value> on <date>" separately from "Estimated now (incl. deliveries since)". For a
|
||||||
|
historical range they show the balance at the range end. Deliveries are filtered to the range.
|
||||||
|
- The forecast is suppressed when the dipstick is older than 60 days.
|
||||||
|
- **D-55 CSV export.** The analysis table as CSV: one row per bucket and series, with local ISO bucket bounds,
|
||||||
|
timezone, invariant numbers, empty cells for unavailable values, and status, provenance, cost, cost status,
|
||||||
|
currency and the comparison value. Served by an App endpoint that takes the same URL keys.
|
||||||
|
|
||||||
|
## 9. Evidence, limitations, deviations
|
||||||
|
|
||||||
|
- **D-56 Evidence.**
|
||||||
|
- Frozen-clock tests cover New Year, Berlin DST in spring and autumn, 29 Feb, 31 Jan → Feb, and New York.
|
||||||
|
- Seeded goldens: D-44, the D-22 classification, and water Dec 2022 = 70 € / 14 m³.
|
||||||
|
- Worked virtual examples: A+B, A−B, missing vs zero, nested, cycle, division by zero.
|
||||||
|
- A synthetic generator (test trait) for 1,000 meters × 10 years with recorded timings.
|
||||||
|
- Screenshots and a manual checklist (EN/DE × light/dark × 360/768/desktop) from the seeded instance. No bUnit
|
||||||
|
or Playwright is added.
|
||||||
|
- *As implemented:*
|
||||||
|
- The frozen-clock, seeded-golden and worked-virtual suites exist as planned. `docs/ANALYSIS_REPORT.md` lists
|
||||||
|
them with counts. At the end: Core 1,733 tests, Integration 746.
|
||||||
|
- The synthetic generator and timings are `tests/Integration.Tests/Performance` (trait `Category=Performance`,
|
||||||
|
skipped unless `METERVAULT_PERF=1`).
|
||||||
|
- The manual checklist ran as Chrome DevTools Protocol scripts against seeded instances: four acceptance
|
||||||
|
reviewers plus the page agents, in EN/DE, light/dark, at 1440/390/360 px. Those scripts and the screenshots are
|
||||||
|
outside the repository. Server-rendered pages are covered by `HtmlRenderer`-based render tests in EN and DE.
|
||||||
|
- **D-57 Limitations.**
|
||||||
|
- Raw retention is not implemented; `/admin/settings` labels it "not enforced", and the Readings tab explains
|
||||||
|
it. The brief's "Retained history" scenario is a documented blocker.
|
||||||
|
- Monthly imports are never interpolated to days (SDD §8.7 / §14.2 unchanged).
|
||||||
|
- A full recompute still runs per live reading.
|
||||||
|
- Bonus, Discount and Tax tariffs are not applied.
|
||||||
|
- *Measured and found later (see `docs/ANALYSIS_REPORT.md`):*
|
||||||
|
- The per-reading recompute is linear in a meter's reading count: ~0.1 s for a monthly meter, ~1.4 s for a year
|
||||||
|
of hourly data. The startup rebuild is ~20 % slower per meter than in 0.3.0.
|
||||||
|
- The freshness query (`AnalysisQueries.RecentReadingsAsync`) has no time bound, so it plans across every raw
|
||||||
|
chunk.
|
||||||
|
- The window-sum query (`AnalysisQueries.WindowSumsAsync`) gets no plan-time chunk exclusion.
|
||||||
|
- Both grow with history length, and neither is fixed.
|
||||||
|
- The billing basis cannot change month by month (A-17).
|
||||||
|
- Batteries are not modelled in Solar's calculated feed-in.
|
||||||
|
- The Solar page has no CSV export, because the export has no derived measures.
|
||||||
|
- **D-58 SDD deviations.**
|
||||||
|
- §14.1: virtual meters are computed on read, and nothing is materialized.
|
||||||
|
- §5.4 / §10: the configured zone is used, and rollups replace the continuous aggregates.
|
||||||
|
- §8.1: the Overview shows one selected period.
|
||||||
|
- §7.4: prices inside expressions are not supported; the `ownQuantity` cost rule covers savings.
|
||||||
|
- *Also marked in the SDD at the end of the rework:*
|
||||||
|
- §3 (FR-9, FR-11, FR-12, FR-16), §4.1 / §4.2 (no aggregates, the two-reader pipeline) and §5.1 (the new tables).
|
||||||
|
- §5.5 (raw retention not enforced, D-57).
|
||||||
|
- §7.1 (revision 3), §7.3 (tank "now" vs period), §7.5 (the bill, D-34 – D-43).
|
||||||
|
- §8.0 (the shared contract), §8.2 – §8.7 (the pages), and the monthly-history note (no interpolation).
|
||||||
|
- §9 (additive API fields, D-45, A-21), §10 (half-open bounds, `TimeProvider`), §11 – §13 (layout, milestones,
|
||||||
|
tests) and §14.1 – §14.4 plus the new §14.9 – §14.14.
|
||||||
|
- Appendix B (Ersparnis is not an expression).
|
||||||
|
|
||||||
|
## 10. Deliberate behaviour changes
|
||||||
|
|
||||||
|
| Area | Old | New |
|
||||||
|
|---|---|---|
|
||||||
|
| Seeded Strom bill | Haus + Netz + Auto priced | Netz (grid import) billed; matches the sheet |
|
||||||
|
| Feed-in | credited on all generation | credited on grid_export only |
|
||||||
|
| Missing tariff | cost 0 | not priced / unavailable (D-38) |
|
||||||
|
| Standing charge | per meter per month with data | once per scope, per day of service |
|
||||||
|
| Midnight readings | booked in the next day | booked in the day they close (D-11) |
|
||||||
|
| "Last 12 months" | 13–14 buckets, including a future month | 12 buckets, actuals up to now |
|
||||||
|
| Virtual meters | no analysis; flow sums links | full analysis from the formula |
|
||||||
|
| Overview "this year" | full year vs complete previous year | selected period vs matched coverage |
|
||||||
|
| Currency | hard-coded € | configured currency |
|
||||||
|
| Continuous aggregates | refreshed hourly, unused | dropped |
|
||||||
|
| API | — | additive fields only; the summary's values follow the new bill |
|
||||||
|
|
||||||
|
Added as the amendments and pages landed (the release notes, `docs/RELEASE_NOTES.md`, list them for users):
|
||||||
|
|
||||||
|
| Area | Old | New |
|
||||||
|
|---|---|---|
|
||||||
|
| Year and week buckets | a year priced at the 1 July price, a type/global base price per meter and bucket | every bucket priced month by month at the price of the 15th (D-36) |
|
||||||
|
| Separately priced subsection | added on top of its parent | billed at its own price, out of its parent (D-35, A-19) |
|
||||||
|
| Manual costs | in the summary but not the trend; a cost later this month counted at once | once, on its start day, once that day has come, everywhere (D-41) |
|
||||||
|
| Categories | sum of member meters' costs | priced non-overlapping cover; overlapping categories are views (D-42) |
|
||||||
|
| Intervals longer than a month (tank, runtime, direct delta) | booked whole in the later month, zeros between | months "only coarser data"; longer buckets priced when the months share one price (A-16) |
|
||||||
|
| Months without a grid meter in service | — | cost unavailable, with an attention item (A-17) |
|
||||||
|
| Meter fee on a meter no line prices | per meter and bucket | its own standing-charge row (A-18) |
|
||||||
|
| Rows recorded after now | counted in to-date totals | reported apart; a day holding one reads partial (D-04, A-14, A-20) |
|
||||||
|
| Summe Solar / generation sums | not costed (no analysis) | analysed; cost rule `none` (A-15) |
|
||||||
|
| Percentage against a negative baseline | divided by its absolute value | not applicable (D-08) |
|
||||||
|
| `/api/v1/cost` of generation, runtime, invalid meters | `Priced` (a generation meter could carry a negative feed-in cost) | `NotPriced` with `costRule`/`notCosted` (A-21) |
|
||||||
|
| `/api/v1/consumption` months without data | 0 | left out (only rows holding quantity data) |
|
||||||
|
| Deleting a meter or energy type | its scoped tariffs stayed behind and could be restored onto another meter | deleted with it (`EntityDeletion`); export/import skips such orphans (A-37) |
|
||||||
|
|
||||||
|
Tests rewritten on purpose (none weakened; each rewrite states the new rule):
|
||||||
|
- `MeterPeriodServiceTests`: deleted with `MeterPeriodService`. Its cases moved to `MeterAnalysisLoaderTests`, with
|
||||||
|
the virtual-null case replaced by positive and error-state cases (the worked A+B example, missing vs zero, an
|
||||||
|
invalid calculation).
|
||||||
|
- `FlowServiceTests.Virtual_sum_meter_aggregates_its_upstreams` became `Virtual_sum_meter_is_its_formula`, plus
|
||||||
|
legacy, non-sum, capped-link, missing-sub-meter, other-unit and after-now cases.
|
||||||
|
- The CAgg test became `CostReconciliationTests.Monthly_rollup_equals_the_consumption_it_sums`. `SchemaTests` now
|
||||||
|
pins that the aggregates and their jobs are gone.
|
||||||
|
- `FormatCultureTests`: the currency case became `Money_is_in_the_configured_currency_written_the_readers_way`, plus
|
||||||
|
formatter cases.
|
||||||
|
- `LocalTimeEntryTests.Tab_keys_map_to_panel_indexes` became `Tab_keys_resolve_by_key_and_mode`.
|
||||||
|
- `VirtualMeterTests` and `ElectricityReconciliationTests.Netz_einsparung_virtual_matches_the_sheet` now run through
|
||||||
|
`VirtualEvaluator` (D-29).
|
||||||
|
- `ExpressionEvaluatorTests`: deleted with the evaluator. `FormulaParserTests` pins that unknown identifiers are
|
||||||
|
errors.
|
||||||
|
- `DashboardRenderTests`: the literal labels of the old pages, replaced by assertions on the new ones in EN and DE.
|
||||||
|
- `DashboardServicesTests`: four Solar and tank tests moved to `Specialized/` with the new services' assertions.
|
||||||
|
- `AnalysisChartModelTests`: a missing bucket is marked "–", not "*" (A-28). `MeterAnalysisLoaderTests` reads the
|
||||||
|
cost change's new type (A-23). `AttentionItemsTests` has the specific duplicate-role text.
|
||||||
|
|
||||||
|
## 11. Amendments after the Phase 1 module review
|
||||||
|
|
||||||
|
These refine the decisions above where the independently built Core modules met.
|
||||||
|
|
||||||
|
- **A-01 Opening balance.** An opening balance is never a coverage run. The rollup day it is booked in
|
||||||
|
carries a baseline-delta flag, which the coverage evaluator takes as an input. Matched coverage gets the
|
||||||
|
booked stamps of opening-balance rows. `CoverageGapReason.OpeningBalance` is removed.
|
||||||
|
- **A-02 DividedAtMonths.** This is true when no interval of a run straddles a local month boundary undivided.
|
||||||
|
An interval is either divided at month boundaries, or lies inside one local month. A zero increase across a
|
||||||
|
month boundary counts as divided, because a register that did not move is exactly 0 in every month.
|
||||||
|
- **A-03 Divided intervals** are classified at most `Month`: they are never coarser for month and year
|
||||||
|
buckets. Runs are rejoined by source interval identity, not by adjacency.
|
||||||
|
- **A-04 Capping at now.**
|
||||||
|
- Stored runs are uncapped and carry `LastIntervalStart`. A reader caps a run at now, but when now falls
|
||||||
|
inside the run's final interval, the run ends at `LastIntervalStart`. That final interval's row is
|
||||||
|
recorded after now (D-04).
|
||||||
|
- A to-date bucket counts as fully covered when its coverage reaches within one interval of the run's class
|
||||||
|
of its end. The consumption since the last reading is not yet known, and the bucket is not reported
|
||||||
|
Partial for that reason.
|
||||||
|
- One module owns capping: `CoverageRuns.CapAt`.
|
||||||
|
- **A-05 Recorded after now (Phase 2).** Each rollup day stores the latest interval end among its rows. A day
|
||||||
|
whose rows end after now is reported as recorded after now, not as an actual.
|
||||||
|
- **A-06 Auto bucket** is chosen from the period's nominal range (ytd → month, mtd → day), so one URL renders
|
||||||
|
the same way all year. The point limit is checked on the elapsed range.
|
||||||
|
- **A-07 Roles.**
|
||||||
|
- Analysis reads roles only through `MeterRoleRules.Effective`: case-insensitive, and only for modes that
|
||||||
|
may hold them.
|
||||||
|
- Virtual meters never hold a role.
|
||||||
|
- A role is unique among meters that are not retired. A retired meter keeps its role for its history, and
|
||||||
|
each meter counts only within its own service period (D-24).
|
||||||
|
- **A-08 Virtual result kinds** are consumption, generation, net or indicator, nothing else. Save writes the
|
||||||
|
effective (inferred) kind, unit and cost rule, so readers never re-infer them.
|
||||||
|
- **A-09 One classifier, one unit table.**
|
||||||
|
- The resolution classifier lives once, in `Coverage`.
|
||||||
|
- `Units` (Quantities) is the only unit normalizer, and every module uses its comparer.
|
||||||
|
- **A-10 Comparison mapping details (D-06).**
|
||||||
|
- A range starting on a day the target month lacks (30 March → February) starts at the end of that month,
|
||||||
|
so a range starting 30 March matches from 1 March.
|
||||||
|
- A "now" in the second pass of an autumn fold maps to the end of the fold when the target date has no fold,
|
||||||
|
which keeps the mapping monotonic.
|
||||||
|
- Comparison buckets are paired with the current buckets by index (`ComparisonResolver.PairBuckets`), never
|
||||||
|
planned separately.
|
||||||
|
- **A-11 A separately connected heat pump** (its own supply, not below the main meter) is modelled as its own
|
||||||
|
energy type with its own grid_import meter. Containment children with their own price remain D-35.
|
||||||
|
- **A-12 Joint coverage.** The reader derives each virtual source's per-day coverage and bucket states from
|
||||||
|
`CoverageEvaluator`, and feeds them to `VirtualEvaluator`. The evaluator does not re-derive coverage rules.
|
||||||
|
- **A-13 Default comparison.** D-06 lists the comparisons without fixing a default. Every page compares with the
|
||||||
|
previous year when `compare` is absent (`prev-year`): the Overview's month to date with the same elapsed days a
|
||||||
|
year earlier, a history page's last 12 months with the 12 months a year before. Compared with the period just
|
||||||
|
before, a seasonal utility would show the season as a trend. Like every default it applies only to an absent key
|
||||||
|
and is never written into a URL; `compare=none` and `compare=prev-period` remain one click away.
|
||||||
|
- **A-14 Capping inside an earlier interval (A-04).** When now falls inside an interval that is not the run's last
|
||||||
|
(two readings stamped ahead, a reading stamped weeks ahead whose month shares are several intervals, a sheet row that
|
||||||
|
carries the current month's register into a later month), the stored run does not say where that interval starts.
|
||||||
|
The run then ends at the earliest instant it can start: the later of the local month start of now and now minus one
|
||||||
|
interval of the run's class; a coarse run ends where it starts. Such a run is always divided at months, because an
|
||||||
|
undivided interval across a month edge is its own run. So coverage never claims time whose row is recorded after
|
||||||
|
now: a label run gives up exactly the current month, finer data at most one interval. The shares of such an interval
|
||||||
|
that closed before now stay actuals, as A-05 reads interval ends per share.
|
||||||
|
- **A-15 Virtual source costs (D-39).**
|
||||||
|
- `sourceCosts` adds, for each physical source, what that source's own scope costs: a consumption source at its unit
|
||||||
|
price (or its bill line), an export source as its feed-in credit, a generation or runtime source nothing. A sum
|
||||||
|
whose sources price nothing is not costed, and the reason is named (generation, runtime).
|
||||||
|
- The sources come from the formula's weights, through nested pure sums, each once: `m1 + m1 - m1 + m2` is m1 and m2.
|
||||||
|
- A generation sum defaults to `none`, because generation is never billed (D-34). The seed and the legacy derivation
|
||||||
|
write the default, so Summe Solar is stored with `none`. A stored `sourceCosts` on a generation sum costs nothing.
|
||||||
|
- A sum over a nested calculation that is not a pure sum is not a sum of metered costs. Its default is `none`. A
|
||||||
|
stored `sourceCosts` stays valid for the quantity but is taken as `none` on read (not costed: "a source calculation
|
||||||
|
is not a plain sum"), and is reported as `CostRuleNeedsPureSum` for the editor to refuse on save
|
||||||
|
(`VirtualValidation.CostRuleProblem`, `IsSavable`).
|
||||||
|
- **A-16 Intervals longer than a month (D-36).** Pricing month by month left a meter whose reading intervals span
|
||||||
|
several months (a tank dipped every few months, a quarterly delta, burner hours read quarterly) without a cost at
|
||||||
|
every bucket size. When a bucket of several months holds an unresolved month, the cost engine also reads the bucket
|
||||||
|
whole. If every month the bucket has data in has the same price (the same tariff outcome and converted unit price,
|
||||||
|
D-37), the bucket costs its quantity at that price. A price change inside it leaves it unavailable, with an attention
|
||||||
|
item naming the meter and the months. Month buckets stay unknown; year buckets and period totals are priced, and the
|
||||||
|
bucket size still never changes a total. The legacy adapters no longer turn such an unknown cost into a priced 0:
|
||||||
|
`ConsumableSummary.CostKnown`, `MeterPeriodView.YearToDateCostKnown`, and `costAvailability` on `/api/v1/cost`
|
||||||
|
(additive, D-45). The dashboard summary keeps D-45's numeric legacy windows.
|
||||||
|
- **A-17 Months without a grid meter (D-34 with D-24).** The billing basis is chosen per energy type for all time. In
|
||||||
|
a month where no billed grid_import meter is in service on every day (before its install date, after it retired
|
||||||
|
without a successor) while a use meter in service measured something, the grid meter's known zero would bill that use
|
||||||
|
as free. Such months are unavailable instead, with an attention item naming the grid meter and the months. Switching
|
||||||
|
the basis month by month is deferred: the category composition (D-42) would need the same per-month basis to stay
|
||||||
|
reconciled with the bill.
|
||||||
|
- **A-18 Meter fees without a line (D-40).** A meter-scoped standing charge of a physical meter that no line of the
|
||||||
|
figure prices (a PV or house meter behind the billed grid meter) accrues as its own standing-charge row on that meter,
|
||||||
|
over its service period: in the type's bill, the portfolio and the meter's own scope. In the composition it is a row
|
||||||
|
like the type's: it joins the one disjoint category that holds its meter, and is a slice of its own otherwise.
|
||||||
|
- **A-19 Kaskade (D-35 with D-22).** A consumer with its own meter-scoped unit price, linked directly below a billed
|
||||||
|
grid_import meter with no house meter in between, is billed at its own price and taken out of the grid meters that
|
||||||
|
link to it. D-22 still reads that link as a supply edge, so the measures do not change. A priced meter nothing links
|
||||||
|
is still reported as an unused meter price.
|
||||||
|
- **A-20 Withheld days are not complete (A-05, D-14).** A-05 withholds a whole rollup day or month once a row in it
|
||||||
|
closes after now, and that can take rows recorded before now with it (a current-month label beside live readings
|
||||||
|
takes the day's live share). Coverage cannot see this, so such a bucket, and a total holding it, reads partial with
|
||||||
|
the issue "recorded after now", never available: an empty day is not a true zero. The rows stay in the "recorded
|
||||||
|
after now" block.
|
||||||
|
|
||||||
|
## 12. Amendments after the acceptance review
|
||||||
|
|
||||||
|
These refine decisions where the acceptance review found a gap. No golden bill or reconciliation figure changes.
|
||||||
|
|
||||||
|
- **A-21 Not-costed meters on `/api/v1/cost` (A-16, D-45).** A meter without a cost rule (generation, runtime, an
|
||||||
|
indicator, a calculation that cannot be evaluated) returns `costStatus: NotPriced` and, as `costAvailability`, the
|
||||||
|
status of its quantity (`Invalid` for a loop or a division by zero) — never "Priced, Available" beside the numeric 0
|
||||||
|
D-45 keeps. Two additive fields say why: `costRule` (`MeterCostRule`) and `notCosted` (`MeterNotCostedReason`). A costed
|
||||||
|
meter's month whose quantity is invalid or pending never reports an available cost either. Release note: physical
|
||||||
|
generation and runtime meters changed from `Priced` to `NotPriced`.
|
||||||
|
- **A-22 A category whose members price nothing (D-39, D-42).** The cost math is unchanged: a calculated view, a
|
||||||
|
generation or runtime meter adds nothing to a category. The cost reader now reports it
|
||||||
|
(`CostAttentionKind.CategoryPricesNothing`, with the category and the members), for a category scope and for every
|
||||||
|
category of a portfolio read. The Analysis page shows the explanation instead of "No data yet", the Overview lists the
|
||||||
|
category in its composition as "No cost – members not billed", and the meter editor says under a virtual meter's cost
|
||||||
|
categories that membership adds no cost.
|
||||||
|
- **A-23 One cost-change rule on every page (D-07).** The Overview's rule (`OverviewComparison.Between`: the totals when
|
||||||
|
both periods are complete, else the paired buckets complete on both sides, else not comparable) is used by the energy
|
||||||
|
type page, the Analysis page (cards and the table's total row) and the meter page (which now also states it from the
|
||||||
|
totals when both are complete), with the same "over the part both periods cover" caption. The Analysis page's
|
||||||
|
one-meter quantity view reads the meter's comparison cost for its cost card.
|
||||||
|
- **A-24 A measure's resolution (D-51).** A per-type measure carries the coarsest resolution of the meters it counts
|
||||||
|
(a virtual member's evaluated resolution), so a type or Solar view over monthly data never drills a month into days;
|
||||||
|
the Overview's own fallback is gone. Solar bounds drilling by every series it charts. Pages offer a click, a drill
|
||||||
|
column and a drill hint only where some bucket leads somewhere.
|
||||||
|
- **A-25 A virtual meter's bucket with nothing finer (D-51).** A virtual meter has no records, so where a physical meter
|
||||||
|
opens its Normalized data, a virtual meter's bucket opens the meter's own analysis over that bucket; its source
|
||||||
|
contributions link on to each source's records for it. The bucket that already is the whole view leads nowhere.
|
||||||
|
- **A-26 Nothing booked (D-19, D-41).** A cost bucket with no line, no charge, no manual cost and nothing missing stays
|
||||||
|
unknown in the engine (SeededBillTests) and now reads "No data" everywhere: never "Priced" beside "—", never complete,
|
||||||
|
and `Missing` (not `Available`) in the CSV export.
|
||||||
|
- **A-27 A tariff's value (D-38, D-52).** The tariff editor starts a new tariff without a value and refuses to save
|
||||||
|
without one, so the missing-price deep link cannot turn a gap into a free period by one click. A typed 0 for a unit
|
||||||
|
price, base price or feed-in is saved as the valid zero D-38 defines, with the note "A price of 0 makes this period
|
||||||
|
free of charge".
|
||||||
|
- **A-28 Chart marks (D-49, brief §4.3).** Bars are outlined in their colour, so a true zero is a line on the baseline
|
||||||
|
and a gap draws nothing; a bucket without a value is marked "–" (its own note), a qualified value keeps "*". A chart
|
||||||
|
with nothing to draw says why: data only coarser than the buckets (naming the resolution, with the interval that
|
||||||
|
shows it) or a cost without a price — "no data" only when there is none. A unit mismatch in an attention item says
|
||||||
|
what does not fit: the currency, a base price's period, or the meter's unit.
|
||||||
|
- **A-29 Record tabs and "now" (D-04, D-50).** The record tabs list the whole named range, so their toolbar shows those
|
||||||
|
dates (the end of the month for month to date), and every row dated after now carries an "After now" mark.
|
||||||
|
- **A-30 Preview period (D-31).** The calculation preview opens on the period of the page the editor was opened from
|
||||||
|
and offers every preset, custom dates and all available history (the sources' own dates, however old), through the
|
||||||
|
shared toolbar; a range too long for months previews in years.
|
||||||
|
|
||||||
|
## 13. Amendments recorded with the final documentation
|
||||||
|
|
||||||
|
The page agents and the integration made these decisions while building. They are implemented and tested, but were
|
||||||
|
not written down above. They are recorded here so the note stays the complete list. None of them changes a golden
|
||||||
|
figure.
|
||||||
|
|
||||||
|
- **A-31 Page-specific URL keys (D-46, D-47).**
|
||||||
|
- The energy History tab uses `view=total|meters`.
|
||||||
|
- The Overview uses `chart=` for its chart selection.
|
||||||
|
- The record tabs reuse the page's `period`/`from`/`to`, and `all` there means no date bound.
|
||||||
|
- None of these keys belongs to `AnalysisUrlKeys`. They are written with replace and never reload the analysis.
|
||||||
|
- **A-32 Overview projection (D-09).**
|
||||||
|
- It is offered only for month or year to date, only from a complete figure, and only after 7 or 30 days.
|
||||||
|
- Metered use (net of feed-in credit) and standing charges are extended at their observed rate per elapsed day. D-09
|
||||||
|
said standing charges are added exactly per day; that is not done.
|
||||||
|
- Manual costs are kept as booked, not projected.
|
||||||
|
- **A-33 Series on one chart (D-15, brief §7.4).**
|
||||||
|
- The Analysis page draws comparison overlays for at most three series. With more, the comparison stays in the
|
||||||
|
table, with a note.
|
||||||
|
- The energy History "individual meters" view charts at most six meters and links to the Analysis page for the rest.
|
||||||
|
- A category is analysed by quantity only when all its meters share one kind and unit. The meters are then shown
|
||||||
|
side by side and never added, because members can overlap. Otherwise the page explains why and offers the
|
||||||
|
alternatives.
|
||||||
|
- **A-34 Context and counts on the meter page (brief §7.2, D-50).**
|
||||||
|
- Events and tariff changes in the range are listed under the chart, not drawn on it: the shared chart has no
|
||||||
|
annotation support.
|
||||||
|
- Record tab labels carry no counts, because that meant counting every row on each load. Each table states its own
|
||||||
|
count, capped at 10,000.
|
||||||
|
- **A-35 Solar figures (D-54).**
|
||||||
|
- Self-consumption is total load − grid import, else generation − grid export.
|
||||||
|
- Feed-in is the grid export meter, else generation − self-consumption. The calculated form is labelled, and
|
||||||
|
batteries are not modelled.
|
||||||
|
- Site use is the total load meter, else self-consumption + grid import.
|
||||||
|
- Savings are self-consumption × the grid unit price, month by month through `CostCalculator`. The feed-in credit is
|
||||||
|
the cost reader's own line.
|
||||||
|
- Mixed units make a figure invalid, naming the units.
|
||||||
|
- **A-36 Tariff deep link (D-52, A-27).**
|
||||||
|
- The prefilled dialog opens once. `action`, `component` and `from` are then dropped from the address.
|
||||||
|
- `scope`/`id` stay and filter the list to the tariffs that can price that meter or type, with "Show all tariffs".
|
||||||
|
- The suggested unit follows the scope until the user types one.
|
||||||
|
- A stored tariff whose unit no longer fits shows an issue icon and cannot be saved again until the unit is fixed.
|
||||||
|
- Both admin and meter tariff lists show the effective end: the day before the next tariff of the same kind starts
|
||||||
|
(`TariffValidity`).
|
||||||
|
- **A-37 Deleting a meter or an energy type (D-32, D-33).** `tariff.scope_id` has no foreign key, so `EntityDeletion`
|
||||||
|
deletes the tariffs scoped to the meter or type together with it, in one transaction. For a meter, its readings and
|
||||||
|
consumption go too. Export/import no longer restores a tariff whose meter or type is gone onto whichever id replaces
|
||||||
|
it.
|
||||||
|
- **A-38 Where the toolbar sits (brief §7.2, §7.3).**
|
||||||
|
- The energy page has one toolbar above its four tabs:
|
||||||
|
- Interval and comparison show on Overview and History; metric and export only on History.
|
||||||
|
- A bucket refused for too many points is read again on auto, so every tab still shows figures while the toolbar
|
||||||
|
offers the coarser size.
|
||||||
|
- The meter page keeps its toolbar inside the Analysis tab, so its tab bar sits directly under the header.
|
||||||
|
- **A-39 Shell after the acceptance review.**
|
||||||
|
- Buttons, icon buttons, links, chips, tabs and nav links get a 2 px focus ring in the theme's text colour.
|
||||||
|
- `MeterVaultMudLocalizer` gives MudBlazor's own labels German text. The English values are MudBlazor's own.
|
||||||
|
- Meter → Sources links each source's connector to its editor, and the source dialog has "Edit connector". Both
|
||||||
|
keep the existing detour and its draft.
|
||||||
|
- The theme defaults to dark when no `mv-theme` cookie is set.
|
||||||
|
- The Calculation tab words a calculation problem as the attention list does, one wording per `VirtualProblemKind`.
|
||||||
|
|
||||||
|
## 14. Amendments from the performance measurement
|
||||||
|
|
||||||
|
The measurement of brief §9.10 / D-56 on a synthetic 1,000-meter × 10-year instance named three costs that grow with
|
||||||
|
history rather than with what a page asks for. They are fixed here. No golden bill, reconciliation figure or displayed
|
||||||
|
value changes; only what a request reads does.
|
||||||
|
|
||||||
|
- **A-40 What a request reads (D-15, D-18, D-56).**
|
||||||
|
- **The freshness mark is stored, not searched.** `meter_rollup_state` gains `last_reading_at`: the stamp of the
|
||||||
|
meter's latest raw reading, written by the recompute that every write path already runs. D-18 is unchanged — the
|
||||||
|
last reading or event time is still the mark — but a request no longer queries `reading` to find it, so an
|
||||||
|
import-only meter's mark stays exactly as old as its data without planning across a decade of raw chunks. The
|
||||||
|
migration backfills the column in one pass, so nothing waits for a rebuild.
|
||||||
|
- **Only a live source's rhythm is sampled, and only from the recent past.** The median of D-18 is consulted for a
|
||||||
|
meter with a live source, so the reading times are read for those meters alone, bounded by
|
||||||
|
`FreshnessRules.RecentWindow` (90 days) — the bound is what lets PostgreSQL exclude the older chunks at plan time.
|
||||||
|
A live meter that delivered nothing inside the window has no rhythm there; those few meters are read again over
|
||||||
|
their whole history, so a long-silent source is still called stale by its own rhythm. An instance without any live
|
||||||
|
source reads `reading` not at all.
|
||||||
|
- **The window-sum statement carries its overall bounds.** Its windows arrive through an `unnest` join, so their
|
||||||
|
bounds are columns and exclude no chunk. The minimum start and maximum end of the window set are repeated as
|
||||||
|
constants in the `WHERE` clause. No row outside them can match any window, so no tally changes; with enough
|
||||||
|
windows it is the difference between an indexed probe and a parallel scan of the whole hypertable with a sort
|
||||||
|
spilling to disk.
|
||||||
|
- **One Overview load holds one catalog.** The page's quantities, its bill and its comparison's bill are three
|
||||||
|
figures of one period. `DashboardService.GetOverviewAsync` loads the meters, tanks, links and rollup states once
|
||||||
|
and passes that snapshot to all three (`CostReader.ReadAsync(db, catalog, …)`), so every figure answers from the
|
||||||
|
same snapshot and the load is not repeated. The cards, the change table and the composition are derived from those
|
||||||
|
results and are never priced again. `OverviewReadBudgetTests` asserts the statement count of one load.
|
||||||
|
|
||||||
|
## 15. Amendments from the first reports of a live instance
|
||||||
|
|
||||||
|
Three findings from one real instance: imported history only, a Heizöl type whose tank is dipped with a stick
|
||||||
|
a few times a year and whose burner hours are noted every few months. Two of them are about what a figure says
|
||||||
|
when the measurement is coarser than the question, one is about the order and the completeness of the lists
|
||||||
|
beside it. No figure, golden reconciliation number or seeded bill total changes.
|
||||||
|
|
||||||
|
- **A-41 A meter read a few times a year (D-05, D-13, D-14, D-07, A-02, A-03, A-06, A-24).** An instance with imported
|
||||||
|
history only, a heating-oil tank dipped with a stick once a year and a burner whose hours are noted every few months,
|
||||||
|
showed that three separate rules break down when a meter's *measurement* is coarser than the question asked of it.
|
||||||
|
The pages it reported are the energy type page, the Overview, the Analysis page and Tanks & consumables; all four read
|
||||||
|
the two readers, so all four are fixed by the three changes below.
|
||||||
|
- **A bucket waiting for a measurement is unresolved, not empty.** A dipstick interval books nothing until the next
|
||||||
|
dipstick closes it, so "last 12 months" opening eleven days after the last one is covered by no run at all. The
|
||||||
|
coverage evaluator called that *missing* — "No data for this period" — beside a coverage panel listing four years of
|
||||||
|
it, and beside a burner that read "Only coarser data" for the same period. It is the same situation A-04 already
|
||||||
|
names for a bucket that ends at now: what has accrued since the last reading is not yet known, and that is no
|
||||||
|
shortfall. So a bucket that no run covers, that no *gap* run overlaps (a known hole keeps its own reason) and that
|
||||||
|
holds no opening balance is `Unresolved` with `CoarseResolution` when the meter's last coverage before it cannot
|
||||||
|
place a bucket of that size anyway, and lies within one interval of its own class (`LimitOf`, unbounded for data
|
||||||
|
coarser than a month). It carries that resolution, so the card, the coverage panel, the chart's "only coarser data"
|
||||||
|
and the table agree; it carries no number, because there is none. A meter that books its own buckets as it goes —
|
||||||
|
an hourly source gone silent, a monthly sheet asked about a later month — is unchanged: a bucket it does not cover
|
||||||
|
really has no data. `CoverageEvaluator.AwaitsMeasurement`, pinned by `CoverageEvaluatorTests`.
|
||||||
|
- **Auto coarsens for the data only as far as the data gains by it.** `ResolutionClass.Coarse` means no more than
|
||||||
|
"longer than a local month", and `MinimumSizeFor` maps it to years. Auto therefore answered a 12-month request with
|
||||||
|
one bar per year — which resolved exactly nothing, because a dipstick taken every autumn straddles a New Year as
|
||||||
|
surely as it straddles every month start, so the yearly buckets were unresolved too. The floor now comes from
|
||||||
|
`ResolutionClassifier.PlanningResolution(runs, zone)`: one decision for the whole chart (a chart has one bucket
|
||||||
|
size, D-05), the coarsest class among the plotted runs, with divided runs counted as monthly (A-03) and with the
|
||||||
|
class capped at `Month` as soon as any coarse run in the range crosses a local year edge. A meter read on the
|
||||||
|
quarter, whose intervals all lie inside one year, still charts in years — years do hold each of those whole
|
||||||
|
(`CostReviewFixTests`). The 400-point limit and explicit sizes are untouched, and `MinimumSizeFor` keeps its
|
||||||
|
meaning for drill-downs (D-51) and for the coarser interval the empty chart offers (A-28).
|
||||||
|
- **A comparison line names the figure it is about.** Matched coverage (D-07) belongs to one series: on one page the
|
||||||
|
tank's use shared no covered day with the year before while the burner beside it compared over October to May. The
|
||||||
|
page stated the *use* measure's verdict — "Not comparable: the periods share no covered days" — directly above a
|
||||||
|
table that showed the burner's matched comparison, and beside a runtime card showing its change. `ComparisonSummary`
|
||||||
|
takes a `Subject`, and the energy type's Overview and History, Solar and the Overview's cost line pass the figure
|
||||||
|
the line speaks of whenever they show more than one. Both statements are then true. The energy type's coverage
|
||||||
|
panel also lists a measure's own availability whenever it differs from the scope's, which is what made "no data"
|
||||||
|
read as a contradiction in the first place.
|
||||||
|
- **A-42 Every dated list reads newest first.**
|
||||||
|
- A table is read from the top, so its first row is the period the reader is in. `AnalysisTableModel.Build` turns the
|
||||||
|
plan round once, after the rows are built and paired with the comparison by index, and puts the **total row above**
|
||||||
|
them — it sums what follows it. Every page that renders `AnalysisTable` inherits this: the meter's Analysis tab, the
|
||||||
|
energy type's History tab, `/trends`, Solar, Tanks & consumables and the Overview's history table. The plan itself
|
||||||
|
is never reordered, so nothing that indexes it (drill-downs, comparison pairs, chart series) has to know.
|
||||||
|
- The same rule applies to the other dated lists that were still ascending: a virtual meter's calculation preview
|
||||||
|
(and its capped warnings), the tariffs of a meter's Tariffs tab and of `/admin/tariffs` (newest validity first
|
||||||
|
inside each component, so the price in force is at the top), and the tariff changes among the meter page's
|
||||||
|
contextual markers. The markers are now **one** list: events and price changes interleave by local day
|
||||||
|
(`MeterMarkerList`), because two lists one after the other made the dates run down, jump back up and run down again.
|
||||||
|
- Already newest first, and left alone: the record tabs (readings, normalized rows, events — D-50 keysets), the
|
||||||
|
deliveries of a tank, and the import batches.
|
||||||
|
- **The chart stays chronological** — it is read left to right — and so does the **CSV export** (D-55): a file is
|
||||||
|
sorted, charted and differenced by whatever opens it, and every one of those expects time to run forwards.
|
||||||
|
- Lists that are not dated keep their own order: meters by energy type and name, the flow table by node name, a
|
||||||
|
virtual meter's source contributions by the formula, connectors and sources by name and priority.
|
||||||
|
- **A-43 A meter with no comparable change is listed, not dropped.**
|
||||||
|
- "Largest changes by meter" ranked the meters whose change could be measured over the days both periods cover (D-07)
|
||||||
|
and showed nothing at all about the others. On the reported Heizöl page that left only the burner: the tank, read by
|
||||||
|
dipstick a few times a year, has no matched coverage against last year and so vanished although it has data.
|
||||||
|
- `MeterChanges.Of` now returns three things: the ranked changes (unchanged rule), **every other meter that has data
|
||||||
|
in either period**, and how many the caps left over. A row of the second list carries the meter's two totals as the
|
||||||
|
reader read them, so the page words each of them with the same status text every other figure gets (`FigureText`,
|
||||||
|
`DisplayNames`): "No data", "Only coarser data", "Being prepared". Only one case needs more than that — both totals
|
||||||
|
known yet no shared day — and it is said with the page's existing sentence, `Comparison_NotComparable`.
|
||||||
|
- A meter with nothing on either side stays out: repeating "no data" twice is not information.
|
||||||
|
- The two lists are capped separately, and the unranked one is ordered with the meters that have no change at all in
|
||||||
|
front, so a long ranking can never crowd out the very rows this fix is about. What neither list holds is counted and
|
||||||
|
said out loud ("Showing 4 of 10 meters"), never silently dropped.
|
||||||
|
- The caption "A change is measured over the dates both periods cover" now stands above the ranked rows only.
|
||||||
|
- **Checked and left as they are:** the Overview's "What changed" (`DashboardService.LineRows`/`CategoryRows`) already
|
||||||
|
ranks rows without a comparable change last instead of dropping them; it drops only rows with no *money* on either
|
||||||
|
side, and those are exactly the ones the composition panel lists with their price coverage and the attention list
|
||||||
|
offers a tariff for (D-42, D-53). The attention list caps with a "show all", the meter list and the flow table drop
|
||||||
|
nothing.
|
||||||
@@ -0,0 +1,439 @@
|
|||||||
|
# Dashboard, navigation and historical analysis: implementation report
|
||||||
|
|
||||||
|
Final report for [DASHBOARD_ANALYSIS_CHANGE_BRIEF.md](DASHBOARD_ANALYSIS_CHANGE_BRIEF.md) §12. It states which of
|
||||||
|
the brief's findings were fixed and how, how physical and virtual results now agree, which cost and aggregation
|
||||||
|
decisions were applied to existing data, what was tested, and what is still missing.
|
||||||
|
|
||||||
|
- **Base revision:** `c0f52db` (the revision the brief reviewed). **Release:** 0.4.0.
|
||||||
|
- **Decisions:** D-01 – D-58 and amendments A-01 – A-39 in
|
||||||
|
[ANALYSIS_IMPLEMENTATION_NOTE.md](ANALYSIS_IMPLEMENTATION_NOTE.md). Nothing here decides anything new; where a
|
||||||
|
behaviour is deliberate, the decision id says so.
|
||||||
|
- **User-facing changes and the upgrade path:** [RELEASE_NOTES.md](RELEASE_NOTES.md). This report does not repeat
|
||||||
|
them.
|
||||||
|
- **Build state at the end:** `dotnet build` 0 errors; Core.Tests 1,733 of 1,733 and Integration.Tests 751 of 751
|
||||||
|
passing (plus the two opt-in performance facts, skipped); the golden spreadsheet reconciliation and the seeded
|
||||||
|
bill goldens unmoved by the last fix round.
|
||||||
|
|
||||||
|
## 1. Findings A01 – A14
|
||||||
|
|
||||||
|
One row per finding of the brief's §2. "Pinned by" names the test that would fail if the behaviour returned; a
|
||||||
|
class name without a method means the whole suite covers that area.
|
||||||
|
|
||||||
|
| Finding | What was wrong | What it is now | Where the code lives | Pinned by |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **A01** / P0 — virtual meters excluded from meter analysis | `MeterPeriodService.GetAsync` returned `null` for `MeterMode.Virtual`; the meter page showed a notice and sent the user to the flow page. A test pinned the refusal. | A virtual meter is a full analysis subject: the reader expands its dependencies and evaluates it per bucket, returning period total, history, comparison, status, provenance, source contributions and a cost rule — the same shape a physical meter returns. `MeterPeriodService` and its test are deleted. | `src/Core/Analysis/Virtual/VirtualEvaluator.cs`, `src/Infrastructure/Analysis/AnalysisReader.cs`, `src/App/MeterDetails/MeterAnalysisLoader.cs`, `src/App/Components/Pages/MeterPage/MeterAnalysisTab.razor` | `AnalysisReaderTests.A_two_source_generation_sum_reads_like_a_physical_meter`; `MeterAnalysisLoaderTests.A_virtual_sum_of_two_generation_meters_reads_250_and_200_by_month_and_450_in_total` |
|
||||||
|
| **A02** / P0 — "virtual" meant different things per layer | `FlowService` summed every incoming link, `VirtualNormalizer` ran expressions (in tests only), and the editor offered upstream selection with no formula editor. A subtraction could disagree with the flow diagram. | One canonical definition in `Meter.Meta` (`expression`, `referencedMeterIds`, `resultKind`, `resultUnit`, `costRule`), one evaluator, one validator. Links are topology only and never change a calculation (D-25). The Sankey draws a pure sum from its calculation inputs, over the same reader result as the page. `VirtualNormalizer` and `ExpressionEvaluator` are deleted. | `src/Core/Analysis/Virtual/` (`VirtualDefinition`, `VirtualDefinitionJson`, `FormulaParser`, `VirtualValidator`, `DependencyGraph`), `src/App/MeterEditing/`, `src/App/Components/Shared/MeterEditing/`, `src/Infrastructure/Dashboard/FlowService.cs` | `FlowServiceTests.Virtual_sum_meter_is_its_formula`; `VirtualValidatorTests`; `VirtualDefinitionJsonTests`; `MeterEditorLogicTests` |
|
||||||
|
| **A03** / P0 — the virtual read/materialize pipeline was incomplete | Normalization skipped virtual meters and costing read stored consumption, so a virtual meter had neither stored nor computed values; assigning a cost category was not a fix. | Virtual meters are evaluated on read and store nothing (D-27; the migration purges the rows they used to have). Costing resolves them through the same evaluator and prices them by their named rule (D-39, A-15), independently of category membership. | `src/Core/Analysis/Virtual/VirtualEvaluator.cs`, `src/Infrastructure/Analysis/AnalysisRun.cs`, `src/Infrastructure/Costing/BillRun.cs`, `src/Infrastructure/Normalization/NormalizationService.cs` | `VirtualEvaluatorTests`; `CostReaderTests.Virtual_meters_are_costed_by_their_named_rule`; `SchemaTests.The_analysis_tables_are_plain_tables_that_go_with_their_meter` |
|
||||||
|
| **A04** / P0 — costs could count overlapping meters twice | The Overview summed every meter, the energy page summed every meter of the type beside a topology-root throughput, and categories deduplicated ids only inside one category. | One pure classification decides what counts: supply edges, containment, the measures Use / GridImport / Export / Generation / Runtime, and a per-meter `totals` override. The bill prices the grid import meters where a type has them, otherwise its use meters; a separately priced subsection is taken out of its parent. A category prices the non-overlapping cover of its members; an overlapping category is a view and stays out of the composition. | `src/Core/Analysis/Totals/TotalsPolicy.cs`, `TotalsGraph.cs`, `CategoryCover.cs`, `src/Core/Analysis/Costing/CostCalculator.cs`, `src/Infrastructure/Costing/CostReader.cs` | `TotalsPolicyTests` (incl. `The_seeded_topology_classifies_every_meter_as_the_note_pins`); `CategoryCoverTests`; `EnergyTypePageTests.An_overlapping_topology_totals_the_parent_with_the_child_as_a_breakdown`; `SeededBillTests.Standing_charges_and_categories_compose_the_portfolio_bill` |
|
||||||
|
| **A05** / P0 — zero, missing, unpriced and invalid were conflated | History filled absent months with 0 and hid all-zero history; a missing tariff resolved to 0; virtual evaluation substituted 0 for absent sources and for non-finite results. | Every bucket carries a `BucketStatus` (`Available`, `Partial`, `Missing`, `Unresolved`, `Invalid`, `Pending`) derived from coverage runs and their resolution class, never from the amount; provenance, value issue and freshness are separate dimensions. A missing price is `NotPriced` or `PriceGap`, an explicit 0 tariff is a valid zero, a non-finite virtual result is `Invalid` with its reason. The chart draws a true zero as an outlined bar on the baseline and an unknown bucket as a gap marked "–". | `src/Core/Analysis/Coverage/` (`CoverageBuilder`, `CoverageEvaluator`, `CoverageRuns`, `ResolutionClassifier`), `src/Core/Analysis/Costing/CostAmount.cs`, `src/App/Analysis/FigureText.cs`, `src/App/Analysis/AnalysisChartModel.cs` | `CoverageEvaluatorTests`; `CostAmountTests`; `AnalysisChartModelTests.A_true_zero_bar_is_drawn_on_the_baseline_and_a_bucket_without_value_is_marked_as_such`; `AnalysisReaderTests.An_all_zero_year_is_visible_and_a_net_balance_stays_signed` |
|
||||||
|
| **A06** / P1 — inconsistent time ranges | No selector on the Overview, a fixed 12-month strip on the meter page, 24 months with Apply on Trends, 60 months elsewhere, and "all time" meaning 1,200 months. | One URL-borne period contract with one toolbar on every analysis page: `period=mtd|last-month|ytd|prev-year|12m|24m|all|custom` with `from`/`to`, `bucket` and `compare`. The Overview defaults to `mtd`, history pages to `12m` (12 calendar buckets ending with the current partial month); `all` spans the availability metadata. Defaults are never written into the address. | `src/App/Analysis/AnalysisQuery.cs`, `AnalysisDefaults.cs`, `AnalysisPeriods.cs`, `src/Core/Analysis/Time/PeriodResolver.cs`, `src/App/Components/Shared/Analysis/PeriodToolbar.razor` | `AnalysisQueryTests`; `PeriodResolverTests.The_last_12_months_are_twelve_calendar_months_ending_with_the_current_partial_one`; `PeriodResolverTests.All_history_spans_the_available_data_and_stops_at_its_last_day_when_that_is_in_the_past` |
|
||||||
|
| **A07** / P0 — comparison and cut-off semantics differed | The summary asked for full calendar years, the breakdown ended at `asOf.AddMonths(1)`, the difference truncated to whole months, several pages derived "today" from UTC, and the meter quantity SQL had no upper bound. | A period resolves once per request, in the instance zone, into a local inclusive display range and a half-open UTC range `[from, to)` used by quantities, costs, comparisons and the export alike. "Now" comes from the registered `TimeProvider`, read once per page. Comparisons shift in calendar units and are measured only over the coverage both periods share; rows closing after now are excluded from actuals and reported apart. | `src/Core/Analysis/Time/PeriodResolver.cs`, `ComparisonResolver.cs`, `src/Core/Analysis/Coverage/MatchedCoverage.cs`, `src/App/InstanceClock.cs`, `src/App/Analysis/CostChanges.cs` | `PeriodResolverTests`; `ComparisonResolverTests`; `MatchedCoverageTests`; `AnalysisReaderTests.A_comparison_is_confident_only_over_the_coverage_both_periods_share`; `AnalysisReaderTests.Rows_that_close_after_now_are_left_out_and_reported`; `CostConsistencyTests.A_partial_period_states_the_same_matched_cost_change_on_every_page` |
|
||||||
|
| **A08** / P1 — too little history to investigate a change | Trends was one monthly total-cost chart, energy pages had no series at all, and the record tabs showed the latest 200 rows. | `/trends` is a scope × metric exploration page (portfolio, type, category, one meter, or up to six meters); energy types have a History tab with a total or per-meter view; the meter page has a full Analysis tab with chart, table, comparison overlay, projection and coverage. Record tabs page through the whole history, 100 rows at a time, keyset-ordered and filtered by the selected dates. Everything shown exports as CSV. | `src/App/AnalysisPage/`, `src/App/Energy/`, `src/App/Components/Pages/Energy/EnergyHistoryTab.razor`, `src/App/MeterDetails/RecordPager.cs`, `src/Infrastructure/Dashboard/MeterDetailService.cs`, `src/App/Analysis/AnalysisExportEndpoints.cs` | `AnalysisPageSelectionTests`; `AnalysisPageLoaderTests`; `MeterDetailServiceTests.Readings_page_newest_first_by_keyset_and_filter_by_a_half_open_range`; `MeterPageLogicTests.The_pager_walks_keyset_pages_and_back`; `AnalysisExportEndpointTests` |
|
||||||
|
| **A09** / P1 — Overview and Trends disagreed | The summary counted manual costs, the monthly trend did not; "latest month with data" gave an amount without its month and judged recency from consumption alone. | Both read the one bill. Manual costs are booked once, in full, on their `PeriodStart` day, everywhere. "Latest period with data" returns its month and its basis (meter data, manual costs or both), from the union of billed-meter coverage and manual-cost days, capped at now. One cost-change rule (`OverviewComparison.Between`) serves the Overview, the energy page, the Analysis page and the meter page. | `src/Core/Analysis/Costing/CostCalculator.cs`, `src/Infrastructure/Dashboard/OverviewModels.cs`, `src/Infrastructure/Costing/CostReader.cs`, `src/App/Energy/EnergyAnalysisLoader.cs` | `CostReaderTests.Manual_costs_are_booked_once_on_their_start_day_wherever_they_belong`; `CostReaderTests.The_latest_period_with_data_includes_manual_costs`; `OverviewDataTests.The_seeded_previous_year_is_the_sheet_s_bill_and_every_panel_adds_up_to_it`; `CostConsistencyTests` |
|
||||||
|
| **A10** / P1 — navigation mixed analysis with specialized pages | Type links opened a page headed "flow", settings were spread across editor, Sources tab and admin, and a nav database error silently removed the energy-type links. | A fixed sidebar (Overview, Analysis, Meters, Energy types, Specialized views, Data import, Configuration) with persisted groups and a Retry item when the types cannot be loaded. Energy pages are titled with the type's own name and have Overview / History / Flow / Meters tabs. Breadcrumbs Overview → type → meter carry the period; link helpers are the only way links are built. | `src/App/Components/Layout/NavMenu.razor`, `src/App/NavGroups.cs`, `src/App/MeterLinks.cs`, `AnalysisLinks.cs`, `TariffLinks.cs`, `src/App/Analysis/AnalysisNavigation.cs`, `src/App/Components/Shared/Analysis/AnalysisBreadcrumbs.razor` | `AppLinkTests`; `AnalysisNavigationTests.Breadcrumbs_carry_the_period_up_and_end_at_the_current_page`; `ShellPreferenceTests`; `DashboardRenderTests` |
|
||||||
|
| **A11** / P1 — visual semantics varied | The meter history was a hand-built 110 px HTML bar chart using absolute values, and the chart components hard-coded dark mode. | One `AnalysisChart` (ApexCharts) with one axis per unit, nullable points, no smoothing or joining across gaps, a real zero line for signed data, outlined bars and a per-bucket mark, plus an accessible `AnalysisTable` for every chart. Light/dark lives in a scoped `ThemeState` backed by a cookie the app reads at prerender; charts re-key on a theme change. `SeriesChart`, `TrendChart`, `CategoryDonut` and `DeltaChip` are deleted. | `src/App/Components/Shared/Analysis/AnalysisChart.razor`, `AnalysisTable.razor`, `src/App/Analysis/AnalysisChartModel.cs`, `AnalysisChartOptions.cs`, `src/App/Theme/ThemeState.cs` | `AnalysisChartModelTests`; `AnalysisTableModelTests`; `AnalysisComponentRenderTests`; `ShellPreferenceTests.The_theme_defaults_to_dark_and_tokens_round_trip` |
|
||||||
|
| **A12** / P1 — misleading labels and units | Most cost views called `Format.Euro` while the meter page used the configured currency, and `Meter.Unit` was used for normalized period results. | `NormalizedQuantity(meter, tank, definition)` gives every meter its analysis kind and unit (runtime in `h` or the tank unit at a fixed rate, instant rate integrated, the tank unit, a virtual meter's declared unit); `Units` is the only normalizer, and raw units appear only on the Readings tab. Amounts go through `Format.Money` over `InstanceCurrency`; `Format.Euro` is gone and no `€` is written in code or resources. | `src/Core/Analysis/Quantities/NormalizedQuantity.cs`, `Units.cs`, `TariffUnit.cs`, `src/App/Format.cs`, `src/App/InstanceCurrency.cs` | `NormalizedQuantityTests`; `UnitsTests`; `TariffUnitTests`; `FormatCultureTests.Money_is_in_the_configured_currency_written_the_readers_way` |
|
||||||
|
| **A13** / P1 — rapid navigation discarded, no retry | An `_loading` early return dropped later requests, panels had no retry, and nav errors removed links. | Every page commits one value through `LoadSequencer.RunAsync` into a `LoadState<T>`: superseded loads are cancelled and never committed, and a failure keeps the previous value visible while reporting itself. `LoadPanel` renders the initial, refreshing and `PanelError`-with-Retry states; the nav keeps its group with a Retry item on error. Subscriptions are disposed with the circuit. | `src/App/Analysis/LoadSequencer.cs`, `src/App/Components/Shared/Analysis/LoadPanel.razor`, `PanelError.razor`, `RefreshIndicator.razor` | `LoadSequencerTests` (incl. `A_delayed_first_load_cannot_overwrite_the_later_one` and `A_failure_keeps_the_previous_value_visible_and_is_reported`) |
|
||||||
|
| **A14** / P1 — long-history reads scanned `consumption` | Several services aggregated `consumption` directly; the continuous aggregates were Berlin-only, amount-only, never backfilled and read by nothing; flow summed all meters before filtering by type. | Normalization writes per-meter day and month rollups, coverage runs and a rollup state by diff, in the same transaction as consumption. `AnalysisReader` reads the month table for month and year buckets, the day table otherwise, plus at most two partial edge days from `consumption`; limits are checked before any SQL runs. The three continuous aggregates and their jobs are dropped. Measured: 8–16 statements per request whatever the meter count. | `src/Infrastructure/Normalization/AnalysisDataWriter.cs`, `src/Core/Analysis/Rollups/RollupBuilder.cs`, `src/Infrastructure/Analysis/AnalysisQueries.cs`, `AnalysisReader.cs`, `src/Infrastructure/Persistence/Migrations/20260919090259_AnalysisRollups.cs` | `SchemaTests.The_continuous_aggregates_and_their_refresh_jobs_are_gone`; `CostReconciliationTests.Monthly_rollup_equals_the_consumption_it_sums`; `AnalysisReaderTests`; `Performance/ReaderTimingTests` (opt-in) |
|
||||||
|
|
||||||
|
Two of the brief's starting points were kept rather than removed. `Core/Costing/TariffResolver` survives as the
|
||||||
|
legacy resolver behind `CostService`, which is now only the adapter under `/api/v1/consumption|cost`; and
|
||||||
|
`DashboardService.GetMonthlyTrendAsync` / `GetCategoryBreakdownAsync` / `GetCategoryDifferenceAsync` survive as
|
||||||
|
legacy entry points that only tests call. Nothing a page renders goes through either.
|
||||||
|
|
||||||
|
## 2. How physical and virtual results agree
|
||||||
|
|
||||||
|
### 2.1 One read path
|
||||||
|
|
||||||
|
Pages, `/api/v1`, the CSV export, Solar, Consumables, Flow and the Overview read quantities through
|
||||||
|
`AnalysisReader` and money through `CostReader`. There is no second path: a figure that queried `consumption` or
|
||||||
|
`reading` directly would be the only one able to disagree, and none is left. `AnalysisReader` loads its catalog
|
||||||
|
once, expands virtual dependencies **in memory**, then reads each table once for all physical meters involved.
|
||||||
|
A virtual series and a physical series therefore come out of the same rollup rows, the same coverage runs, the
|
||||||
|
same bucket plan and the same comparison.
|
||||||
|
|
||||||
|
### 2.2 The worked example (brief §5.4)
|
||||||
|
|
||||||
|
Generation meters A and B, complete monthly data, evaluated as `m(A) + m(B)`:
|
||||||
|
|
||||||
|
| Month | A | B | Virtual sum A+B |
|
||||||
|
|---|---:|---:|---:|
|
||||||
|
| January | 100 kWh | 150 kWh | 250 kWh |
|
||||||
|
| February | 80 kWh | 120 kWh | 200 kWh |
|
||||||
|
| **Period total** | **180 kWh** | **270 kWh** | **450 kWh** |
|
||||||
|
|
||||||
|
The sum reads 450 kWh for the two months, `Available`, kind `Generation`, unit kWh, with a year bucket of 450, the
|
||||||
|
contributions A +1 × 180 kWh and B +1 × 270 kWh, and the two physical series unchanged on their own pages. The
|
||||||
|
type's Generation measure over the same period is the same 250 / 200 / 450, and the sum is classed `AnalysisOnly`,
|
||||||
|
so it is never added on top of its sources. A separate `m(A) − m(B)` meter reads −50 and −40 kWh, total −90 kWh,
|
||||||
|
kind `Net`, plotted below a real zero line and exported signed: it does not inherit the old flow service's sum.
|
||||||
|
Pinned by `VirtualEvaluatorTests.A_plus_B_is_250_and_200_by_month_and_450_in_total`,
|
||||||
|
`A_minus_B_is_minus_50_and_minus_40_and_stays_signed` and
|
||||||
|
`AnalysisReaderTests.A_two_source_generation_sum_reads_like_a_physical_meter`; reproduced in a running instance
|
||||||
|
during the acceptance review (scenarios V1 and V3).
|
||||||
|
|
||||||
|
### 2.3 Seeded Summe Solar
|
||||||
|
|
||||||
|
The seed now stores the definition directly: `expression = m4 + m5`, `resultKind = generation`,
|
||||||
|
`resultUnit = kWh`, `costRule = none`, `referencedMeterIds = [4, 5]`, with the links 4→9 and 5→9 kept as topology.
|
||||||
|
For 2025 it reads 4,750 kWh, which is Zähler Solar 1 (3,123) + Zähler Solar 2 (1,627), the same figure the Strom
|
||||||
|
type's Generation measure shows month by month. It is not costed, because generation is never billed (D-34, A-15),
|
||||||
|
and the page says so rather than showing a blank. An installation seeded by an earlier version gets the same
|
||||||
|
definition from `VirtualDefinitionUpgrade` at startup: the implied sum of the incoming links is stored when they
|
||||||
|
name sources of one unit and kind; the run is idempotent (meta and `updated_at` of every virtual meter were
|
||||||
|
byte-identical after a second start); an existing explicit expression always wins; and anything ambiguous, mixed
|
||||||
|
or looping is flagged "needs configuration" and logged. Pinned by
|
||||||
|
`NormalizedQuantityTests.Summe_Solar_is_the_generation_it_declares`, `LegacyVirtualDerivationTests`,
|
||||||
|
`VirtualManagementTests` and `TotalsPolicyTests`.
|
||||||
|
|
||||||
|
### 2.4 Joint coverage, missing versus observed zero
|
||||||
|
|
||||||
|
The reader derives each source's per-day coverage and bucket states from `CoverageEvaluator` and hands them to
|
||||||
|
`VirtualEvaluator`; the evaluator never re-derives coverage rules (A-12). From that:
|
||||||
|
|
||||||
|
- **A missing source is unknown, not zero.** With B absent in February, February is `Missing` with
|
||||||
|
`MissingSource` and the dependency path naming B, and the period total is `Partial` 250 — not a confident 80.
|
||||||
|
- **An observed zero is a value.** With B observed as 0 in February, February is a complete 80 and the two-month
|
||||||
|
total is a complete 330.
|
||||||
|
- **Partial and coarser coverage propagate.** A source covering part of a bucket makes it partial with the jointly
|
||||||
|
covered value; a monthly source cut inside its month is `Unresolved`, not a plausible-looking partial; a source
|
||||||
|
still being rebuilt makes every bucket `Pending`.
|
||||||
|
- **A meter outside its install and retire dates contributes a known zero**, while a gap inside its lifetime stays
|
||||||
|
missing (D-24).
|
||||||
|
- **Non-finite arithmetic and loops are explained, never numbers.** A division by zero is `Invalid` with the
|
||||||
|
reason; a cycle is `Invalid` with the loop path, and both ends are named by meter name, never by `#id`.
|
||||||
|
- **A period total is the formula over the joint coverage.** For a linear formula without a constant that equals
|
||||||
|
the sum of its buckets; otherwise the series is marked non-additive and the total is the ratio of totals. An
|
||||||
|
indicator (meter × or ÷ meter) is never additive, never totalled into a measure and never costed.
|
||||||
|
|
||||||
|
Pinned by `VirtualEvaluatorTests` and by
|
||||||
|
`AnalysisReaderTests.A_missing_source_is_unknown_and_an_observed_zero_is_a_value`,
|
||||||
|
`A_difference_stays_negative_and_nested_meters_resolve_once`, `A_dependency_loop_is_named_and_never_a_number` and
|
||||||
|
`A_division_by_zero_is_invalid_and_a_ratio_is_not_additive`.
|
||||||
|
|
||||||
|
### 2.5 Contributions, and what a virtual meter does not get
|
||||||
|
|
||||||
|
Every virtual result carries each source's own series, status and dependency path, which the page renders as
|
||||||
|
"Source meters" (`SeriesContributions`), with the weight the formula gave it and a link on to that source's
|
||||||
|
records. Nested sums appear nested. A virtual meter has no Readings and no Normalized data tab, and the page never
|
||||||
|
offers "add a reading" to fix missing history; it has a Calculation tab instead, showing the formula with meter
|
||||||
|
names beside the `m<id>` tokens and any problem worded exactly as the attention list words it. Where a physical
|
||||||
|
meter's bucket drills into its normalized records, a virtual meter's bucket opens its own analysis over that
|
||||||
|
bucket, from which each source links on (A-25). A virtual meter never holds a role, and it joins a type's totals or
|
||||||
|
the bill only through an explicit `totals = always` override, which is refused — naming the other meter — when an
|
||||||
|
ancestor or a dependent already counts.
|
||||||
|
|
||||||
|
## 3. Cost and aggregation decisions applied to existing data
|
||||||
|
|
||||||
|
The bill is computed by `BillRun` → `CostCalculator` over a `TariffBook`, from the same reader passes as the
|
||||||
|
quantities. What that changed for data that already exists:
|
||||||
|
|
||||||
|
| Decision | Applied as | Pinned by |
|
||||||
|
|---|---|---|
|
||||||
|
| **D-34** Billing set | Per energy type, the grid import meters where it has any, otherwise the use meters. Generation, runtime and virtual views are never billed. | `TotalsPolicyTests.The_seeded_bill_is_the_grid_import_for_electricity_and_household_use_elsewhere`; `SeededBillTests` |
|
||||||
|
| **D-34** Feed-in | Credited only on meters holding the `grid_export` role, at the FeedIn price. A missing feed-in price is an optional credit, reported only where an export meter exists. | `CostReaderTests.Export_is_credited_at_the_feed_in_price_and_a_missing_one_is_an_optional_credit` |
|
||||||
|
| **D-35 / A-19** Separately priced subsection | A containment child with its own meter-scoped unit price is billed at that price, and its monthly quantity is deducted from the ancestor that bills it. Quantities do not change. | `CostReaderTests.A_separately_billed_subsection_is_priced_at_its_own_price_out_of_its_parent`; `SeparatelyBilledSubmeterTests` |
|
||||||
|
| **D-36** Prices per local month | Every bucket is cut into local months and priced at the price valid on the 15th, so a year equals the sum of its months and the bucket size never changes a total. | `CostReaderTests.The_bucket_size_never_changes_a_total_and_every_part_is_priced_in_its_month`; `CostCalculatorPricingTests` |
|
||||||
|
| **A-16** Intervals longer than a month | A multi-month bucket holding an unresolved month is priced whole when every month it has data in shares one price; a price change inside it leaves it unavailable, with an attention item naming the meter and the months. | `CostReaderTests.A_monthly_import_prices_its_month_although_its_days_are_unresolved`; `CostCalculatorCoverageTests` |
|
||||||
|
| **D-40 / A-18** Standing charges | Accrued per local day over the scope's service period, **once per scope**, regardless of reading gaps. Type and global charges are their own rows; meter fees stay on their meter, including on a meter no bill line prices. | `CostReaderTests.A_standing_charge_accrues_once_per_scope_and_a_meter_fee_on_its_meter`; `CostCalculatorStandingChargeTests`; `CostConsistencyTests.The_energy_card_counts_the_meter_fees_on_bill_lines_like_every_other_card` |
|
||||||
|
| **D-41** Manual costs | Booked once, in full, on their `PeriodStart` local day, once that day has come, in every figure that covers it. | `CostReaderTests.Manual_costs_are_booked_once_on_their_start_day_wherever_they_belong`; `CostCalculatorManualCostTests` |
|
||||||
|
| **D-42 / A-22** Categories | A category prices the non-overlapping cover of its members, plus its manual costs. The disjoint categories, Uncategorized and the standing-charge rows form the composition and reconcile to the bill; an overlapping category is a view. A category whose members price nothing says so instead of reading "No data yet". | `CategoryCoverTests`; `SeededBillTests.Standing_charges_and_categories_compose_the_portfolio_bill`; `CostReviewFixTests` |
|
||||||
|
| **D-37 / D-38** Price coverage | A tariff applies only when its unit fits the meter's normalized unit and the instance currency; otherwise `UnitMismatch`. No tariff at any date is `NotPriced` (an attention item, never a silent partial), a hole in a priced history is `PriceGap`, and an explicit 0 is a valid zero. | `CostReaderTests.A_missing_tariff_is_not_priced_and_a_zero_tariff_is_a_valid_zero`, `A_gap_in_a_price_history_makes_those_months_unavailable`, `A_price_in_another_unit_is_a_unit_mismatch_and_a_currency_follows_the_options` |
|
||||||
|
| **D-39 / A-15** Virtual cost rules | `sourceCosts` for pure sums (each physical source once, at what its own scope costs), `ownQuantity` for linear formulas, `none` otherwise and for generation sums. The rule is named next to every virtual cost. | `CostReaderTests.Virtual_meters_are_costed_by_their_named_rule`, `A_virtual_meter_counted_by_an_override_is_billed_by_its_cost_rule` |
|
||||||
|
| **A-17** Months without a grid meter | A month in which no billed grid meter was in service on every day, while a use meter measured something, is unavailable rather than free, with an attention item naming the meter and the months. | `CostCalculatorBillTests`; `CostReaderTests.A_retired_meter_is_a_known_zero_on_the_bill_and_missing_on_its_own_page` |
|
||||||
|
| **A-21 / A-26** Honest cost status | A meter with no cost rule reports `NotPriced` with `costRule` and `notCosted`, never a priced zero; a bucket with nothing booked reads "No data" on pages and `Missing` in the export, never "Priced" beside "—". | `ApiContractTests.A_meter_that_is_not_costed_or_cannot_be_evaluated_never_reads_as_a_priced_zero`; `AnalysisExportEndpointTests.A_month_with_nothing_booked_is_exported_as_no_data_not_as_an_available_priced_blank` |
|
||||||
|
| **D-43** Currency | One `Format.Money` over `MeterVault__Currency`; a tariff in another currency is reported as not fitting, never converted. | `FormatCultureTests`; acceptance scenario C8 (a USD instance shows no € or EUR on any amount) |
|
||||||
|
|
||||||
|
### 3.1 The seeded bill against the spreadsheet
|
||||||
|
|
||||||
|
`SeededBillTests.The_seeded_yearly_bill_equals_the_sheet_s_Jahreskosten` runs on a frozen clock after 2026-05-31,
|
||||||
|
with the oil tank unpriced:
|
||||||
|
|
||||||
|
| Year | Sheet `Jahreskosten` | 0.4.0 | Note |
|
||||||
|
|---|---:|---:|---|
|
||||||
|
| 2022 | 421.52 € | 421.52 € | within ±0.02 € |
|
||||||
|
| 2023 | — | — | differs by 3.78 €: the sheet multiplies by its rounded displayed price |
|
||||||
|
| 2024 | — | — | differs by 0.46 €, same cause |
|
||||||
|
| 2025 | 7,907.64 € | 7,907.65 € | Strom alone is 4,742.64 € = Zähler Netz × price, as the sheet bills it |
|
||||||
|
| 2026 | 2,940.19 € | 2,940.19 € | was 4,402.19 € in 0.3.0, which priced Haus + Netz + Auto and water at 5 €/m³ |
|
||||||
|
|
||||||
|
The 2026 figure is the most visible deliberate change in the release: the old sum of every meter is replaced by the
|
||||||
|
grid import meter, and the seed gained the water price rise to 7.00 €/m³ from 2026-01-01 (D-44). The two
|
||||||
|
differences are documented and not tuned away. The golden consumption reconciliation of all four fixture CSVs, and
|
||||||
|
the Netz Einsparung relation, are unchanged — the latter now computed through `VirtualEvaluator` instead of the
|
||||||
|
deleted normalizer (D-29).
|
||||||
|
|
||||||
|
### 3.2 What an existing installation sees change
|
||||||
|
|
||||||
|
The complete list, with old and new values, is the **Changed figures** table in
|
||||||
|
[RELEASE_NOTES.md](RELEASE_NOTES.md#changed-figures), together with the upgrade steps (the first start rebuilds all
|
||||||
|
analysis data at normalization revision 3, before the web server listens) and the additive REST API changes. It is
|
||||||
|
not repeated here.
|
||||||
|
|
||||||
|
## 4. What was tested
|
||||||
|
|
||||||
|
### 4.1 Suites and counts
|
||||||
|
|
||||||
|
| Suite | Count | Needs | Covers |
|
||||||
|
|---|---:|---|---|
|
||||||
|
| `tests/Core.Tests` | 1,733 | nothing | parsers, normalizers, swap→12, and `Analysis/`: periods, DST, comparisons, buckets, coverage, rollups, totals, category cover, virtual formulas and evaluation, the cost calculator |
|
||||||
|
| `tests/Integration.Tests` | 751 | Docker (TimescaleDB) for the database facts | reconciliation against the four golden CSVs, import commit and revert, ingestion and events, rollups, the reader, the cost engine, the seeded bill, API contracts, the export, rendered pages, plus the pure UI-model tests under `Analysis/`, `MeterPage/`, `Overview/`, `Editor/` and `Specialized/` |
|
||||||
|
| `tests/Integration.Tests/Performance` | 2 | `METERVAULT_PERF=1` | the synthetic 1,000-meter × 10-year dataset, reader and cost timings, statement counts, query plans; skipped by default |
|
||||||
|
|
||||||
|
All green at the end of the last fix round, where Integration rose from 727 to 746, and again after the performance
|
||||||
|
fixes of §5.4, which added five more.
|
||||||
|
|
||||||
|
### 4.2 Kinds of tests
|
||||||
|
|
||||||
|
- **Frozen-clock period and DST tests.** `PeriodResolverTests`, `ComparisonResolverTests` and `BucketPlannerTests`
|
||||||
|
cover half an hour into New Year, the 23 h spring day and the month containing it, the repeated autumn hour, 29
|
||||||
|
February, 31 March compared with all of February, and New York as a zone behind UTC. `AnalysisReaderTests`
|
||||||
|
asserts the same against real rollups ([24, 24], [23], [25], and [24, 24] in New York).
|
||||||
|
- **Golden fixture reconciliation.** `Reconciliation/` reconciles electricity, water, oil and costs against the
|
||||||
|
four `sampledata/` CSVs, including `GapSplittingIsInertOnFixturesTests` (no fixture interval is divided, in UTC
|
||||||
|
or in Berlin) and `CoverageOfFixturesTests`.
|
||||||
|
- **Seeded bill goldens.** `SeededBillTests` pins the yearly bill against the sheet, the composition, and the
|
||||||
|
manual-cost-only instance; `CostReconciliationTests` pins water Dec 2022 = 14 m³ / 70 € and rollup = consumption.
|
||||||
|
- **Reader and rollup tests.** `AnalysisReaderTests`, `AnalysisDataTests`, `AnalysisCatalogTests`,
|
||||||
|
`RollupBuilderTests`, `CoverageBuilderTests`, `CoverageEvaluatorTests`, `SchemaTests`.
|
||||||
|
- **Cost engine.** `CostCalculator*Tests` (bill, pricing, coverage, standing charges, manual costs),
|
||||||
|
`CostingTariffBookTests`, `CategoryCoverTests`, `CostReaderTests`, `CostConsistencyTests`.
|
||||||
|
- **Virtual evaluator and definitions.** `VirtualEvaluatorTests`, `VirtualValidatorTests`, `FormulaParserTests`
|
||||||
|
(an unknown identifier is an error, never 0), `DependencyGraphTests`, `LegacyVirtualDerivationTests`,
|
||||||
|
`VirtualManagementTests`, `MeterDraftPreviewTests`.
|
||||||
|
- **URL, link, chart and table models.** `AnalysisQueryTests`, `AppLinkTests`, `AnalysisNavigationTests`,
|
||||||
|
`LoadSequencerTests`, `AnalysisChartModelTests`, `AnalysisTableModelTests`, `AttentionItemsTests`,
|
||||||
|
`AnalysisCsvWriterTests`.
|
||||||
|
- **Rendered HTML** through the framework `HtmlRenderer`, in English and German: `AnalysisComponentRenderTests`,
|
||||||
|
`DashboardRenderTests`, `OverviewPageTests`, `AdminPagesRenderTests`, `MeterSourcesRenderTests`.
|
||||||
|
- **API contracts.** `ApiContractTests` pins every existing field and type of `/consumption`, `/cost` and
|
||||||
|
`/dashboard/summary`, the offset-bounds acceptance, and the not-costed cases.
|
||||||
|
- **CSV export.** `AnalysisExportEndpointTests` and `AnalysisCsvWriterTests`: one row per bucket and series, local
|
||||||
|
ISO bounds with offset, invariant numbers, empty cells for unknown values, and a 400 with a reason for anything
|
||||||
|
the endpoint cannot answer.
|
||||||
|
- **Localization.** `StringResourceTests` fails the build on a missing or blank translation, a placeholder
|
||||||
|
mismatch, an orphan or an unreferenced key; `EnumDisplayNameTests` fails on a localized enum value with no
|
||||||
|
wording.
|
||||||
|
|
||||||
|
### 4.3 Acceptance scenarios
|
||||||
|
|
||||||
|
Four independent reviewers walked the brief's §11 scenarios against the built application — each in its own
|
||||||
|
worktree and its own seeded database, driving the browser with Chrome DevTools Protocol scripts and reading tests —
|
||||||
|
and grouped them as costs (C), time and history (T), virtual meters (V), and UI and navigation (U). Status is as
|
||||||
|
recorded at review time; every confirmed finding behind a "partial" was fixed afterwards (§4.4).
|
||||||
|
|
||||||
|
| Id | Scenario | Status | Evidence |
|
||||||
|
|---|---|---|---|
|
||||||
|
| C1 | Overlapping topology: parent 300, child 100 | pass | `EnergyTypePageTests.An_overlapping_topology_totals_the_parent_with_the_child_as_a_breakdown`; live, a linked parent/child pair reads 300 kWh on the type page, the Overview card, `/trends`, the portfolio measure and the CSV, never 400, with the child shown as a breakdown |
|
||||||
|
| C2 | Missing versus free tariff, and the Add-tariff deep link | partial | `CostReaderTests` (not priced / price gap / valid zero) pass; live, the oil tank reads "Not priced (no tariff)" while its 2,100 L stays visible, and the link opens the tariff dialog prefilled with scope, component and first uncovered month. Finding F1: the dialog could be saved with no value, turning a gap into a free period |
|
||||||
|
| C3 | Cost stability: manual costs once, standing charges once, bucket-invariant totals | pass | Five cost tests plus `SeededBillTests`; live, seeded 2025 totals 7,907.65 € (metered 5,807.64 + manual 2,100.01) at every bucket size, on the Overview, on `/trends` and in the CSV |
|
||||||
|
| C4 | Historical-only instance | pass | With `period=mtd` every page says "No data for this period", names the available dates and offers "Go to latest data"; the Overview names "Latest month with data: May 2026 (Meter data and manual costs)"; `prev-year` shows physical and virtual quantities and costs |
|
||||||
|
| C5 | Manual-cost-only instance | pass | `SeededBillTests` and `OverviewDataTests` agree; live, on an empty database with three manual costs, the Overview, Analysis, the category scope and the latest month all read the same figures without a single meter |
|
||||||
|
| C6 | Phase 4 exit: scopes reconcile across cards, charts, tables and exports | partial | Overview 7,907.65 €, Strom 4,742.64 €, the energy card, `/trends` (portfolio, type, category), the chart series read through CDP and the CSV all match row by row; quantities stay visible where cost is unavailable. Findings F2 (the energy card understated standing charges), F5 and F6 (chart and CSV wording) |
|
||||||
|
| C7 | Discoverability: Overview → type or breakdown row → scoped analysis; missing cost → tariff editor | partial | Links carry period, bucket and comparison; category rows land on the matching figure; the Add-tariff action opens once and is then dropped from the address. Findings F1 and F3 (a currency mismatch worded as a unit problem) |
|
||||||
|
| C8 | Currency | pass | With `MeterVault__Currency=USD` no page shows € or EUR on an amount in either language; the only EUR text is user-entered tariff units |
|
||||||
|
| T1 | Local-time boundaries, half-open bounds | pass | 232 Core and 90 Integration tests in the reviewer's worktree; frozen-clock coverage of New Year, both DST changes, 29 February, short months and New York; the SQL is `>= from AND < to` throughout `AnalysisQueries` |
|
||||||
|
| T2 | Future rows and partial periods | pass | Readings pushed at 23:00 today and six days ahead are excluded from a 363 kWh month to date, reported as "values dated after now … are not counted yet" with a link to those records, and comparisons state the matched dates |
|
||||||
|
| T3 | Zero and negative history, percentage rules | pass | A register that never moved gives 12 `Available` zeros and a 0 total; a net series stays signed; a zero or negative baseline gives "percentage not applicable" with the absolute difference still shown |
|
||||||
|
| T4 | Monthly legacy resolution | partial | Day buckets over monthly data are 31 `Unresolved` rows with the month total intact — no spike, no zeros — and `auto` never plans finer than the data. Findings F1 (type measures drilled a month into empty days), F3 and F4 |
|
||||||
|
| T5 | Retained history | documented blocker | Raw retention is not enforced (D-57). `/admin/settings` and the Readings tab say so, asserted in English and German by `AdminPagesRenderTests` |
|
||||||
|
| T6 | Import, correction and freshness without duplicates | pass | A throwaway test drove swap, manual-reading deletion, tariff edit, definition edit and an import commit through the reader: each shows in the next read, for the physical meter and for the virtual meter above it, with no duplicates and no cache |
|
||||||
|
| T7 | Compare two historical years; explain a spike; paged records | partial | Virtual 2024 against 2023 reads 4,778 versus 4,583 kWh with an overlay, comparison table and source contributions; the physical path drills a March bar into that month's records. Findings F2 (a virtual bucket was a dead end) and F5 |
|
||||||
|
| T8 | Phase 3 exit: Overview → type → meter → month and back | partial | The whole path was driven with CDP: breadcrumbs and Back return through each step with the dates intact. Findings F1 and F2 |
|
||||||
|
| V1 | Two-source virtual generation sum | pass | 250 / 200 / 450 kWh, generation, kWh, contributions, type history agreeing; seeded Summe Solar 2025 = 4,750 kWh = 3,123 + 1,627 |
|
||||||
|
| V2 | Missing versus zero source | pass | Missing B makes February `Missing` with the source named and the total `Partial` 250; an observed zero gives a complete 80 and a complete 330 total; the API and the CSV agree |
|
||||||
|
| V3 | Difference and nesting; named cycle error | partial | −50 / −40 below a real zero line, total −90 kWh; a nested sum resolves its leaves once; a cycle is `Invalid` with the loop path everywhere. Finding F1: two views named the meter as `#id` |
|
||||||
|
| V4 | Invalid arithmetic and non-additive rollup | partial | Division by zero gives "—" with "The formula has no finite result", the ratio total is the ratio of totals with a non-additive note, and the CSV writes an empty value with status `Invalid` — never 0 or infinity. Finding F4 (no reason column in the CSV) was refuted as outside D-55 |
|
||||||
|
| V5 | Legacy virtual configuration | pass | The seed stores the definition; an expression-less meter is converted at startup and logged; a rerun changes nothing (byte-identical meta); an existing formula wins over links; adding links afterwards changes no calculation |
|
||||||
|
| V6 | Virtual overlap | pass | Sources 100 + 150 and the virtual 250 stay 250 in the portfolio; with "always" the sum replaces its sources; the seeded bill and the Strom cost do not move |
|
||||||
|
| V7 | Inspect and configure a combined meter (EN and DE) | pass | Search → the meter's Analysis tab with the period carried and source contributions shown; the editor offers Sum by default, sources by name with unit, kind and dates, and a live preview |
|
||||||
|
| V8 | Phase 2 exit | pass | Every worked meter — sum, difference, nested, missing, zero, ratio, cycle — has the full Analysis tab without raw readings or categories; new calculated meters default to analysis-only; 145 filtered Integration and 342 Core tests |
|
||||||
|
| U1 | Rapid filter and navigation changes, URL state | pass | `LoadSequencerTests` plus CDP runs switching type, meter and period within 30 ms: the last request wins every time; toolbar and tab changes replace, drill-downs push |
|
||||||
|
| U2 | Compatibility actions and legacy tab keys | pass | Every legacy `?tab=`/`?action=` URL opened the intended tab and dialog once and then dropped `action` from the address, including `tab=consumption` → Normalized data and a virtual meter's `tab=sources` → Calculation |
|
||||||
|
| U3 | Theme, EN/DE, narrow screens, keyboard, tables | partial | The in-circuit theme toggle re-keys every chart; 23 resource tests pass; no page-wide overflow on 33 URLs at 360 px in German. Finding F3: the keyboard focus indicator was near-invisible |
|
||||||
|
| U4 | Sidebar, persisted groups, search, breadcrumbs | pass | The fixed structure renders as specified, the cookie survives a reload, the current route's group opens, the active item is visible, and search carries the period |
|
||||||
|
| U5 | Discoverability paths | partial | Quick entry from the header, the list and search; Flow → Manage connections names both ends; calendar-year comparison from the toolbar. Findings F5 (no way to edit the connector in use) and F6 (refuted) |
|
||||||
|
| U6 | Definition of done in the running app | partial | Checked at 1440 and 390 px, English and German, light and dark: the virtual meter has totals, comparison, table, export, contributions and its Calculation tab; a true zero reads "0 kWh Complete" against "— No data". Findings F1, F2 and F4 |
|
||||||
|
|
||||||
|
### 4.4 The review-and-fix round
|
||||||
|
|
||||||
|
The four reviewers raised 24 findings. Each was re-checked by an independent verifier before anything was changed:
|
||||||
|
20 were confirmed, three were refuted as deliberate design (C-F4, the "What changed" table omitting rows with no
|
||||||
|
figure on either side; V-F4, the CSV column set, which D-55 fixes; U-F6, sidebar entries opening at their own
|
||||||
|
default, which D-48 and D-02 intend), and one (T-F4, `cost_status` in the export) was left uncertain and is covered
|
||||||
|
by the fix for C-F6. The fix agent reports all 19 findings it took on as fixed — T-F2 and U-F4 were the same
|
||||||
|
problem and took one fix — with the build green and the golden figures unmoved. The decisions those fixes needed
|
||||||
|
are recorded as A-21 – A-30 in §12 of the implementation note; A-31 – A-39 in §13 record what the page agents and
|
||||||
|
the integration decided while building.
|
||||||
|
|
||||||
|
Tests added in that round: `ApiContractTests` (not-costed and unevaluable meters), `CostReviewFixTests`,
|
||||||
|
`CostConsistencyTests` (the same cost change on four pages, and matching standing charges) and
|
||||||
|
`MeterSourcesRenderTests`, plus cases in `AnalysisPageLoaderTests`, `MeterAnalysisLoaderTests`,
|
||||||
|
`MeterDraftPreviewTests`, `AttentionItemsTests`, `AnalysisChartModelTests`, `AnalysisNavigationTests`,
|
||||||
|
`AnalysisTableModelTests`, `AnalysisExportEndpointTests`, `TariffEditingTests` and `MeterPageLogicTests`.
|
||||||
|
|
||||||
|
Tests rewritten on purpose across the whole rework — none weakened, each rewrite stating the new rule — are listed
|
||||||
|
at the end of §10 of the implementation note.
|
||||||
|
|
||||||
|
## 5. Performance
|
||||||
|
|
||||||
|
Measured with the opt-in Performance trait (`METERVAULT_PERF=1`,
|
||||||
|
`dotnet test tests/Integration.Tests -c Release --filter "FullyQualifiedName~Performance.ReaderTimingTests"`, about
|
||||||
|
10 minutes) against a deterministic synthetic instance: 1,000 meters over 10 years, ≈1.34 M readings, 7 energy
|
||||||
|
types, 21 links, 109 tariff rows, 64 manual costs and 20 virtual meters nested up to three levels.
|
||||||
|
|
||||||
|
Hardware: Ryzen 9 9950X3D (16 cores, 32 threads), 64 GB, Windows 11, Docker Desktop, PostgreSQL 16.6 with
|
||||||
|
TimescaleDB 2.17.2 (`jit=off`), .NET 10 Release. The figures below are the final run, taken on an otherwise idle
|
||||||
|
machine (host 7–13 % CPU) after the fixes of §5.4. The "first run" column is the earlier measurement, taken before
|
||||||
|
those fixes and while other work was building and testing on the same machine — it is kept to show what the fixes
|
||||||
|
moved, not as a like-for-like comparison.
|
||||||
|
|
||||||
|
### 5.1 Reader and cost requests
|
||||||
|
|
||||||
|
Median and p95 of 10 runs after 2 warm-ups; SQL is the number of commands one extra, untimed run sent.
|
||||||
|
|
||||||
|
| Request | Median ms | p95 ms | SQL | First run, median |
|
||||||
|
|---|---:|---:|---:|---:|
|
||||||
|
| 100 selected meters, 10 years by month, catalog cached — **the brief's 2 s target** | 286 | 472 | 8 | 374 |
|
||||||
|
| … the same request through the public API | 274 | 317 | 12 | 361 |
|
||||||
|
| Portfolio, last 12 months by month (measures only, as the Overview) | 70 | 75 | 12 | 268 |
|
||||||
|
| … with one series per meter (1,000 series) | 170 | 186 | 12 | 379 |
|
||||||
|
| … with the previous-year comparison | 156 | 177 | 14 | 414 |
|
||||||
|
| One energy type (402 meters), 10 years by month | 156 | 182 | 12 | 313 |
|
||||||
|
| … with one series per meter (the type page's table) | 698 | 731 | 12 | 917 |
|
||||||
|
| One meter: 10 years by week, or 365 days by day | 12–16 | 13–27 | 10–11 | 29–36 |
|
||||||
|
| Portfolio bill, 12 months by month, with categories | 144 | 204 | 16 | 320 |
|
||||||
|
| Portfolio bill, 10 years by month | 577 | 627 | 15 | 838 |
|
||||||
|
| Virtual meter nested three levels, 10 years by month | 197 | 216 | 10 | 245 |
|
||||||
|
| Virtual difference of a 40-meter and an 8-meter sum, 10 years by month | 300 | 344 | 11 | 377 |
|
||||||
|
| Catalog load: 1,000 meters, tanks, links, states, validation, classification | 6 | 10 | 4 | 7 |
|
||||||
|
| 1,000-meter selection with the limit raised, 12 months by month | 151 | 171 | 12 | 353 |
|
||||||
|
| Refused: 1,000 meters against the 6-series limit; portfolio or bill by day over 10 years | 0 | 0 | **0** | 0 |
|
||||||
|
|
||||||
|
The brief's target is met with margin: 286 ms median and 472 ms p95 against 2,000 ms. The statement count per
|
||||||
|
request is constant at 8–16 whether the request covers 1, 100 or 1,000 meters — there is no per-meter query storm.
|
||||||
|
Refused requests send no SQL at all, because the 400-point and 6-series limits are checked before any query is
|
||||||
|
built. Rollup reads use the primary-key index: 4 ms for 125 meters over 10 years of month rollups, 7 ms for 980
|
||||||
|
meters over 11 months.
|
||||||
|
|
||||||
|
### 5.2 Rebuild and per-reading recompute
|
||||||
|
|
||||||
|
Rebuilding all 1,000 meters at startup through `NormalizationUpgrade` took 279 s, about 3.6 meters per second.
|
||||||
|
One meter's full recompute is 51–56 ms with monthly readings, 0.50 s for a daily meter (3,670 readings) and 1.16 s
|
||||||
|
for a meter with a year of hourly data (9,314 readings). That is also the cost of one ingested reading, because
|
||||||
|
every write path recomputes its meter in full (D-57).
|
||||||
|
|
||||||
|
### 5.3 Page loads, old against new
|
||||||
|
|
||||||
|
The same synthetic raw data was loaded into a database migrated by the old app (`c0f52db`) and one migrated by the
|
||||||
|
new app; each rebuilt its own derived data, then prerendered GETs were timed (median of 10), with the statements
|
||||||
|
per GET taken from a separate logging pass.
|
||||||
|
|
||||||
|
| Page | 0.3.0 median ms | new median ms | SQL per GET |
|
||||||
|
|---|---:|---:|---|
|
||||||
|
| `/` Overview | 28,175 | 2,384 | 48,244 → 205 |
|
||||||
|
| `/trends` | 5,101 | 1,290 | 6,004 → 30 |
|
||||||
|
| `/energy/1` | 3,244 | 1,136 | 2,426 → 42 |
|
||||||
|
| `/meters` | 222 | 163 | 4 → 8 |
|
||||||
|
| `/solar` | 92 | 18 | 32 → 20 |
|
||||||
|
|
||||||
|
The old Overview ran a meter load, a tariff load and a `time_bucket` query 8,021 times per request. The individual
|
||||||
|
meter pages and `/consumables` stayed in the same range (tens to low hundreds of milliseconds) and are not listed.
|
||||||
|
The new-side numbers in that table came from the foundation-era pages on a busy machine, so they are an order of
|
||||||
|
magnitude rather than a final figure; the finished pages, measured on the idle machine against the same
|
||||||
|
1,000-meter instance, load in 455 ms (`/`), 383 ms (`/meters`), 469 ms (`/solar`), 641 ms (`/energy/1`),
|
||||||
|
684 ms (`/trends`), 104 ms (`/meters/1`) and 152 ms (a nested virtual meter).
|
||||||
|
|
||||||
|
### 5.4 Hotspots found and fixed
|
||||||
|
|
||||||
|
The first measurement found three. All three are fixed (A-40), each proved by `EXPLAIN` or a statement count
|
||||||
|
before and after, with the tallies unchanged.
|
||||||
|
|
||||||
|
| Hotspot | Before | After |
|
||||||
|
|---|---|---|
|
||||||
|
| **The freshness query had no time bound.** `AnalysisQueries.RecentReadingsAsync` planned across all 123 raw chunks on every request, for every meter. | 13.8 ms planning + 36.1 ms execution for the portfolio (980 meters); 189 ms on a cold connection | 0.58 + 0.44 ms, over 4 chunks and only the 40 meters with a live source; an instance without a live source never reads `reading` at all |
|
||||||
|
| **Window sums could not exclude chunks.** `AnalysisQueries.WindowSumsAsync` took its bounds only from the `unnest` join. | a 1,960-window stress case: 42 chunks, parallel scan of 1.39 M rows, ~45 MB sort spill, 121.5 ms | 5 chunks, nested-loop index scans, 8.7 ms |
|
||||||
|
| **The Overview loaded the catalog three times**, once per read (quantities, bill, comparison bill). | 31 statements per load; 64 statements and 133 ms of SQL on the 1,000-meter instance | 23 statements per load; 39 statements and 81 ms of SQL |
|
||||||
|
|
||||||
|
The freshness fix splits what D-18 had conflated: the *mark* (a meter's last reading time) is now stored on
|
||||||
|
`meter_rollup_state` and maintained by every recompute, with a one-pass backfill in the `FreshnessMark` migration,
|
||||||
|
so an import-only meter keeps its years-old "last activity" without touching `reading`; the *rhythm* (the sample
|
||||||
|
intervals that decide stale versus live) is sampled inside a 90-day window, and only for meters that have a live
|
||||||
|
source. A live source that has been silent longer than that window is re-read unbounded, so it is still reported
|
||||||
|
stale by its own rhythm rather than by a default.
|
||||||
|
|
||||||
|
In-process work dominates what is left, and scales with series × buckets (roughly 10–16 µs per series bucket).
|
||||||
|
That is the budget that matters for very large exports and tables, not for a normal page.
|
||||||
|
|
||||||
|
## 6. Remaining limitations and known gaps
|
||||||
|
|
||||||
|
**Documented in D-57:**
|
||||||
|
|
||||||
|
- **Raw retention is not enforced.** `MeterVault__RawRetentionDays` is displayed but nothing deletes readings,
|
||||||
|
because every recompute rebuilds a meter from the readings that remain. `/admin/settings` and the Readings tab
|
||||||
|
say so. This is the brief's "Retained history" acceptance scenario, and it stands as a documented blocker.
|
||||||
|
- **Monthly imports are never interpolated to days.** A day or week view of monthly data says "only coarser data"
|
||||||
|
and offers the monthly interval; it never invents measured daily detail.
|
||||||
|
- **A full recompute runs per ingested reading.** Fine for monthly and daily meters (0.1–0.6 s); about 1.4 s for a
|
||||||
|
meter with a year of hourly data, and it grows with history.
|
||||||
|
- **Bonus, Discount and Tax tariffs are stored but not applied.** The tariff editor says so.
|
||||||
|
|
||||||
|
**Further limitations, measured or decided during the work:**
|
||||||
|
|
||||||
|
- `AnalysisQueries.OpeningBalancesAsync` has the same unbounded shape the freshness query had (a lateral
|
||||||
|
`ORDER BY time LIMIT 1` over `consumption`, 6.4 ms in the heaviest measured request). It was left alone: smaller,
|
||||||
|
and `consumption` has 42 chunks against `reading`'s 123.
|
||||||
|
- The billing basis (grid meter or household use) is chosen per energy type for all time and cannot switch month by
|
||||||
|
month, because the category composition would need the same per-month basis to stay reconciled with the bill
|
||||||
|
(A-17).
|
||||||
|
- Batteries are not modelled: without a grid-export meter, Solar's feed-in is calculated, and labelled as such.
|
||||||
|
- The Solar page has no CSV export, because the export has no derived measures.
|
||||||
|
- An old explicit difference formula over two generation meters stores `meter_rollup_state.kind` as `Consumption`.
|
||||||
|
Nothing reads that column, and the meter's declared kind governs every figure.
|
||||||
|
- `Dashboard.razor` and `Trends.razor` subscribe to `LocationChanged` without checking that the location is still
|
||||||
|
their own page, so leaving them can start a load on a disposing component. No error from it appeared in the
|
||||||
|
application log.
|
||||||
|
- The navigation's retry-on-error path for energy types is code-reviewed only: no automated test covers it, and it
|
||||||
|
could not be forced in a running instance.
|
||||||
|
|
||||||
|
**Testing gaps:**
|
||||||
|
|
||||||
|
- **No bUnit and no Playwright** (a committed stack decision). Browser behaviour — interactive ApexCharts updates,
|
||||||
|
browser history, responsive layout, keyboard focus — was checked with Chrome DevTools Protocol scripts against
|
||||||
|
seeded instances by the page agents and the four acceptance reviewers, in English and German, light and dark, at
|
||||||
|
1440 / 390 / 360 px. Those scripts and their raw screenshots live outside the repository. Inside it,
|
||||||
|
server-rendered pages are covered by `HtmlRenderer`-based render tests in both languages.
|
||||||
|
- For three of the last-round fixes (naming a culprit meter, and the virtual-bucket dead end) the tests were
|
||||||
|
written after the fix, so they were never watched to fail; the reviewers' reproductions are the "before".
|
||||||
|
- The "After now" mark on record rows is covered only by a test: the seeded demo holds no rows dated after now, so
|
||||||
|
it could not be seen in a browser.
|
||||||
|
- The performance figures are preliminary (§5); re-run them on a quiet machine before quoting them as a property.
|
||||||
|
|
||||||
|
**Deviations from the SDD** are listed in D-58 and marked in place in [`SDD.md`](SDD.md). The largest is §14.1:
|
||||||
|
virtual meters are computed on read and nothing is materialized.
|
||||||
|
|
||||||
|
## 7. Screenshots
|
||||||
|
|
||||||
|
Taken from a seeded instance. "Before" is `c0f52db`; the rest is 0.4.0.
|
||||||
|
|
||||||
|
| Image | What it shows |
|
||||||
|
|---|---|
|
||||||
|
| [`screenshots/analysis/before-overview-desktop.png`](screenshots/analysis/before-overview-desktop.png) | The 0.3.0 Overview: no period selector, totals that summed every meter, and a cost card that needed cost categories configured before it said anything |
|
||||||
|
| [`screenshots/analysis/before-meter-virtual-desktop.png`](screenshots/analysis/before-meter-virtual-desktop.png) | The 0.3.0 meter page for a virtual meter: the "virtual" notice and a link to the flow page instead of any history |
|
||||||
|
| [`screenshots/analysis/overview-desktop.png`](screenshots/analysis/overview-desktop.png) | The new Overview at desktop width: the period toolbar, the cost split into metered use, standing charges, manual costs and feed-in credit, one card per energy type in its own unit, the history chart, "What changed", the cost composition and the attention items |
|
||||||
|
| [`screenshots/analysis/overview-mobile.png`](screenshots/analysis/overview-mobile.png) | The same page at phone width: the toolbar and cards wrap, and wide tables scroll in their own region |
|
||||||
|
| [`screenshots/analysis/meter-physical-desktop.png`](screenshots/analysis/meter-physical-desktop.png) | A physical meter's Analysis tab: tabs directly under the header, the period total with its status, the cost with its rule, the change against the comparison period, the chart with the previous-year overlay, the table, and "Data quality and coverage" |
|
||||||
|
| [`screenshots/analysis/meter-virtual-desktop.png`](screenshots/analysis/meter-virtual-desktop.png) | Summe Solar in 0.4.0: the same analysis as a physical meter, "Not costed — generation is never billed", the "Source meters" contributions, and a Calculation tab in place of Sources |
|
||||||
|
| [`screenshots/analysis/meter-virtual-mobile.png`](screenshots/analysis/meter-virtual-mobile.png) | The virtual meter page at phone width, in German |
|
||||||
|
| [`screenshots/analysis/energy-history-desktop.png`](screenshots/analysis/energy-history-desktop.png) | An energy type's History tab: one toolbar above the four tabs, the total or per-meter view with an explanation of how each meter counts, chart plus table, and Export CSV |
|
||||||
|
| [`screenshots/analysis/energy-history-mobile.png`](screenshots/analysis/energy-history-mobile.png) | The same tab at phone width |
|
||||||
|
|
||||||
|
## 8. Related documents
|
||||||
|
|
||||||
|
| Document | What it holds |
|
||||||
|
|---|---|
|
||||||
|
| [`DASHBOARD_ANALYSIS_CHANGE_BRIEF.md`](DASHBOARD_ANALYSIS_CHANGE_BRIEF.md) | The work order: findings A01 – A14, the target journeys, the acceptance scenarios, the definition of done |
|
||||||
|
| [`ANALYSIS_IMPLEMENTATION_NOTE.md`](ANALYSIS_IMPLEMENTATION_NOTE.md) | Every decision (D-01 – D-58) and amendment (A-01 – A-39), the deliberate behaviour changes, the evidence and the limitations |
|
||||||
|
| [`RELEASE_NOTES.md`](RELEASE_NOTES.md) | What a user sees change in 0.4.0: the upgrade path, the changed figures, the API changes |
|
||||||
|
| [`SDD.md`](SDD.md) | The design reference, with each deviation marked in place |
|
||||||
|
| [`../CLAUDE.md`](../CLAUDE.md) | The architecture as it now is |
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
# Dashboard, navigation, and historical analysis: change brief for Claude Code
|
||||||
|
|
||||||
|
**Status:** proposed implementation brief; no application changes made by this review.
|
||||||
|
**Reviewed:** 2026-09-19, repository revision `c0f52db`.
|
||||||
|
**Scope:** overview dashboard, navigation, meter detail, energy-type pages, trends, virtual meters, and the relevant calculation services.
|
||||||
|
|
||||||
|
## 1. Objective and review boundaries
|
||||||
|
|
||||||
|
Make MeterVault feel like one coherent application in which users can find an option where they expect it, inspect historical data at any useful period, and understand why a particular number is unavailable.
|
||||||
|
|
||||||
|
The central requirement is that a virtual meter combining two meters must have the same applicable consumption/generation analysis as a physical meter: period totals, history, comparisons, quality information, and costs where a valid costing rule exists. Lack of raw readings is expected for a virtual meter and must not prevent derived analysis.
|
||||||
|
|
||||||
|
This is a source-code and product-flow review, not a browser usability test. Findings below are grounded in the checked-in Razor components, services, models, migrations, and tests. No running instance, production data, screenshots, or query timings were inspected. Layout improvements are implementation proposals; verify them in a running seeded instance before declaring completion.
|
||||||
|
|
||||||
|
Read [CLAUDE.md](../CLAUDE.md) and [SDD.md](SDD.md), especially §§5.4–5.5, 7.4–7.5, 8, 10, and 14.1. The historical analysis work also fills existing SDD §8.3 requirements. Treat the phases below as incremental work on the existing application, not a restart of M0–M7. Proposed product defaults in this brief are explicit design decisions for this work, not claims about existing behavior. Document any necessary deviation from the SDD, including any deliberately changed reconciliation result.
|
||||||
|
|
||||||
|
## 2. Confirmed problems and their causes
|
||||||
|
|
||||||
|
Paths below are relative to the repository root. Method/component names are provided because line numbers will move during implementation.
|
||||||
|
|
||||||
|
| ID / priority | Finding and user impact | Evidence / implementation starting point |
|
||||||
|
|---|---|---|
|
||||||
|
| A01 / P0 | Virtual meters are explicitly excluded from meter period analysis. A functioning combined meter is redirected to the flow page instead of getting its own history. | `src/Infrastructure/Dashboard/MeterPeriodService.cs`, `GetAsync`: returns `null` for `MeterMode.Virtual`. `src/App/Components/Pages/MeterDetail.razor`: virtual notice; `tests/Integration.Tests/MeterPeriodServiceTests.cs`, `A_virtual_meter_reports_nothing_rather_than_a_confident_zero` pins this limitation. |
|
||||||
|
| A02 / P0 | Virtual means different things in different layers. The flow service sums all upstream meters for every virtual meter; the core normalizer supports expressions; the editor exposes upstream selection without a formula editor. A subtraction formula can therefore disagree with flow. | `FlowService.GetFlowAsync`, `Core/Normalization/Normalizers/VirtualNormalizer.cs`, `Infrastructure/Normalization/MeterConfigFactory.ParseVirtual`, `Shared/MeterEditor.razor`. |
|
||||||
|
| A03 / P0 | The documented virtual read/materialization pipeline is incomplete in the inspected services. Normalization skips virtual meters, and costing reads stored consumption without resolving virtual expressions. Assigning a cost category is not a demonstrated fix for missing virtual analysis. | `Infrastructure/Normalization/NormalizationService.cs`, `RecomputeMeterAsync`; `Costing/CostService.cs`, `GetMeterCostsAsync` and `QueryConsumptionAsync`. Verify all write paths before introducing any materialization. |
|
||||||
|
| A04 / P0 | Costs can count overlapping meters more than once. Overview totals sum every meter; energy-type cost sums every meter of the type, whereas its throughput is a topology-root total. Categories deduplicate meter IDs only within that category, not parent/child coverage. | `DashboardService.ActiveMeterIdsAsync` / `TotalCostAsync`; `Pages/EnergyView.razor`, `LoadAsync`; `CostService.GetCategoryCostsAsync`. This is a structural risk; actual inflation depends on topology, memberships, and tariffs. |
|
||||||
|
| A05 / P0 | Zero, missing data, missing prices, and invalid calculations are conflated. History fills absent months with zero and hides all-zero history. Missing tariffs resolve to zero. Virtual evaluation substitutes zero for absent source timestamps and non-finite results. | `MeterPeriodService.BuildHistory`; `Core/Costing/TariffResolver.ResolveValue`; `VirtualNormalizer.Normalize`. `EnergyView.NodeValue` also falls back to zero. |
|
||||||
|
| A06 / P1 | Time ranges are inconsistent. Overview has no selector; meter detail has a fixed 12-month mini-chart; Trends defaults to 24 months with Apply; energy/Solar/consumables default to 60 months and reload immediately. “All time” is 1,200 months. | `Pages/Dashboard.razor`, `MeterDetail.razor`, `Trends.razor`, `EnergyView.razor`, `Solar.razor`, `Consumables.razor`. |
|
||||||
|
| A07 / P0 | Comparison and cutoff semantics differ. Dashboard summary requests full calendar years; breakdown ends at `asOf.AddMonths(1)`; difference truncates that span to whole months. Several pages derive today from UTC. Meter quantity SQL has no upper bound and starts at UTC Jan 1, while its cost query stops at now. | `DashboardService.GetSummaryAsync` / `GetCategoryDifferenceAsync`; `Dashboard.OnInitializedAsync`; `MeterPeriodService.MonthlySql` / `GetAsync` / `LoadCostsAsync`. Future-dated rows and local year boundaries can produce inconsistent totals. |
|
||||||
|
| A08 / P1 | History is too limited to investigate changes. Trends is total monthly cost only. Energy pages provide a flow diagram and meter list, without a historical series. Meter consumption/readings tabs show only the latest 200 records. | `Pages/Trends.razor`, `EnergyView.razor`; `MeterDetailService.MaxRows`; SDD §8.3 calls for more. |
|
||||||
|
| A09 / P1 | Overview and Trends can disagree even for matching dates: summary includes manual costs, monthly trend does not. “Latest month with data” supplies an amount without its month and derives recency only from consumption. | `DashboardService.TotalCostAsync`, `GetMonthlyTrendAsync`, `LatestMonthCostAsync`; `DashboardSummary`. A manual-cost-only instance is not handled consistently. |
|
||||||
|
| A10 / P1 | Navigation mixes analysis by energy type with specialized Solar/consumables pages. Type links open a page headed “flow,” not a general type overview. Important settings remain spread between the meter editor, Sources tab, and admin pages. Existing shortcuts help but do not provide a consistent analysis journey. | `Layout/NavMenu.razor`, `Pages/EnergyView.razor`, `MeterDetail.razor`, `Shared/MeterEditor.razor`, `MeterLinks.cs`. |
|
||||||
|
| A11 / P1 | Visual semantics vary. Meter history is a custom 110px HTML bar chart; other charts use ApexCharts. Its bars use absolute values, obscuring negative results. Chart components hardcode dark mode despite the app theme toggle. | `MeterDetail.BarStyle`; `Shared/SeriesChart.razor`, `TrendChart.razor`, `CategoryDonut.razor`; `Layout/MainLayout.razor`. |
|
||||||
|
| A12 / P1 | Labels and formatting can mislead: most cost views call `Format.Euro`, meter detail uses configured currency; `Meter.Unit` is the raw unit and is also used for normalized period results. This needs explicit handling for runtime conversions. | `App/Format.cs`; `MeterPeriodView.Unit`; `Core/Domain/Meter.cs`; `RuntimeCounterNormalizer`. |
|
||||||
|
| A13 / P1 | Rapid navigation/filter changes can be discarded by an `_loading` early return. Errors generally lack a panel-level retry state; nav DB errors silently remove the energy-type links. | `EnergyView.LoadAsync`, `Solar.LoadAsync`, `Trends.LoadAsync`, `NavMenu.LoadEnergyTypesAsync`. Reproduce stale-page behavior with delayed requests. |
|
||||||
|
| A14 / P1 | Long-history reads are aggregated directly from `consumption` in several services. Existing continuous aggregates are not a drop-in solution: the migration fixes the zone to Berlin and stores only amount sums, without coverage/quality. Flow sums all meters before filtering to the selected type. | `CostService.QueryConsumptionAsync`, `MeterPeriodService.MonthlySql`, `FlowService.GetFlowAsync`, `Persistence/Migrations/20260713094634_ContinuousAggregates.cs`. Actual performance and refresh behavior require measurement. |
|
||||||
|
|
||||||
|
P0 means calculation/meaning must be settled before exposing more totals. P1 is required for the finished user experience, not optional polish.
|
||||||
|
|
||||||
|
## 3. Target navigation and user journeys
|
||||||
|
|
||||||
|
### 3.1 Sidebar and terminology
|
||||||
|
|
||||||
|
Use one stable navigation structure:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Overview /
|
||||||
|
Analysis /trends (retain route; improve existing page)
|
||||||
|
Meters /meters
|
||||||
|
Energy types expandable group of user-defined types
|
||||||
|
<type name> /energy/{id}
|
||||||
|
Specialized views
|
||||||
|
Solar /solar
|
||||||
|
Tanks & consumables /consumables
|
||||||
|
Data import /import
|
||||||
|
Configuration existing /admin/* routes
|
||||||
|
Energy types / Tariffs / Cost categories / Connectors / Settings
|
||||||
|
```
|
||||||
|
|
||||||
|
- “Energy types” is the analysis entry; “Configuration → Energy types” edits definitions. Make that distinction visible in page titles and descriptions.
|
||||||
|
- Keep specialized views grouped and available with useful setup states. Do not infer electricity or oil from names or IDs; scope relevant links by capabilities and configured roles.
|
||||||
|
- Preserve existing routes and bookmarked query parameters. Extend the current helpers rather than constructing competing URLs in individual components.
|
||||||
|
- Add breadcrumbs: `Overview → <energy type> → <meter>`. Preserve the incoming analysis period and selected metric through drill-down and Back navigation.
|
||||||
|
- Persist expanded navigation groups and ensure the active item is visible after reload. If type links fail to load, keep the group with an error/retry affordance instead of silently removing it.
|
||||||
|
- Keep global meter search and its quick-entry actions. Show search text on desktop and an accessible icon on narrow screens. Search results should link to analysis and appropriate entry actions without forcing a trip through the meter list.
|
||||||
|
|
||||||
|
### 3.2 Concrete discoverability requirements
|
||||||
|
|
||||||
|
| User intention | Required path |
|
||||||
|
|---|---|
|
||||||
|
| Understand this period's usage/cost | Overview → energy-type card or cost breakdown row → scoped Analysis |
|
||||||
|
| Explain a spike | Chart bucket → finer supported period / table → meter → relevant records/events |
|
||||||
|
| Inspect a combined meter | Meter search/list/type list → virtual meter → Analysis, with source contributions visible |
|
||||||
|
| Compare two historical years | Analysis → select scope and years → previous-year overlay and comparison table |
|
||||||
|
| Add a reading or delivery | Existing meter header and list quick action; no need to locate a tab first |
|
||||||
|
| Change the source/connector | Meter → Sources → Edit connection; preserve the existing connector detour and draft |
|
||||||
|
| Understand a missing cost | Cost panel explanation → tariff editor scoped to the relevant meter/type and dates |
|
||||||
|
| Configure a virtual sum | Add/edit meter → Virtual → Sum → select source meters by name → preview |
|
||||||
|
| Configure energy topology | Energy type → Flow → Manage connections, with clear source/destination names |
|
||||||
|
|
||||||
|
Keep the existing successful behaviors: shared meter editor, event rules, stable manual-entry keypad, tank setup shortcut, source draft restoration, one-shot URL actions, and nav refresh after energy-type edits.
|
||||||
|
|
||||||
|
## 4. Shared period and analysis contract
|
||||||
|
|
||||||
|
Create a reusable analysis query/result contract and period selector. Suggested names are illustrative; fit the existing project conventions.
|
||||||
|
|
||||||
|
### 4.1 Query state
|
||||||
|
|
||||||
|
An `AnalysisQuery` should carry scope (meter/type/category/explicit meter selection/overview), metric, local start date, local end date, bucket, comparison, and aggregation basis. Resolve relative presets against an injected `TimeProvider` and the configured instance timezone.
|
||||||
|
|
||||||
|
- Presets: this month to date, last complete month, year to date, previous calendar year, last 12 months, last 24 months, all available history, custom dates.
|
||||||
|
- Default Overview to month to date. Default history pages to last 12 months including the current partial month. This means exactly 12 calendar buckets, not 13 or a partial extra future month.
|
||||||
|
- Display the effective dates next to the preset, plus the timezone in the range details.
|
||||||
|
- The UI's inclusive end date becomes a local-midnight exclusive upper bound on the next day. To-date presets stop at the captured current instant. Use the same resolved bounds for quantities, costs, comparisons, and exports.
|
||||||
|
- Never silently include future-dated readings in a “to date” total. A deliberately selected future range should distinguish recorded future data from actual-to-date and projections.
|
||||||
|
- All available history comes from availability metadata, not an arbitrary century-long range.
|
||||||
|
- Presets apply immediately. Custom date editing applies once both dates form a valid range, using one consistent Apply interaction across pages.
|
||||||
|
- Encode state in query parameters, e.g. `/meters/42?tab=analysis&from=2025-01-01&to=2025-12-31&bucket=month&metric=generation&compare=previous-year`. Use stable invariant tokens and localized visible labels.
|
||||||
|
- Use the URL as the authoritative state for reload/share/back. Preserve one-shot `action` handling separately; changing filters must not reopen a reading or source dialog.
|
||||||
|
- Validate bounds, IDs, enum tokens, maximum series count, and bucket/point limits. Invalid input must produce a recoverable message or documented fallback.
|
||||||
|
|
||||||
|
### 4.2 Buckets and comparisons
|
||||||
|
|
||||||
|
- Support day, week, month, year, and Auto where the stored data supports them. Week starts Monday in the instance timezone; preserve actual start/end dates for partial weeks.
|
||||||
|
- Auto chooses an appropriate bucket with at most 400 visible points per series. Explicit choices that exceed the limit should offer a coarser bucket rather than silently truncate.
|
||||||
|
- Compare complete periods with complete periods. For MTD/YTD, default to the same elapsed calendar portion of the comparison period, including the local time-of-day cutoff. Clamp missing dates at shorter month/leap-year boundaries and show both exact ranges.
|
||||||
|
- Distinguish actual change from projection. A “vs last year” label must not secretly compare a current-year projection with a prior-year actual.
|
||||||
|
- Show absolute difference even when percentage is unavailable. A zero or negative baseline yields “percentage not applicable” by default; do not report 0% when a denominator is absent.
|
||||||
|
- Keep signed values signed. Generation increases and consumption increases do not share the same good/bad interpretation; use metric-specific or neutral colors and explicit wording.
|
||||||
|
- Projections remain secondary, explicitly labeled, and describe their method. Suppress projections for unavailable, stale, or insufficient coverage; do not extrapolate a lone old monthly reading as though it were live data.
|
||||||
|
|
||||||
|
### 4.3 Result and missing-data semantics
|
||||||
|
|
||||||
|
Return structured results rather than `null`, `[]`, or `0` with no explanation. Each series needs stable meter/scope identity, quantity kind, normalized unit/currency, available range, effective requested range, calculation basis, and bucket-level values/status.
|
||||||
|
|
||||||
|
Keep separate dimensions: availability (available/missing/partial/error), provenance (measured/manual/estimated/interpolated/derived), freshness, and price coverage. A derived value can be complete and current; these are not mutually exclusive states.
|
||||||
|
|
||||||
|
| Situation | Display and action |
|
||||||
|
|---|---|
|
||||||
|
| Valid observations yield zero | Show numeric zero, an actual chart point, and its coverage |
|
||||||
|
| No values in selected range, older history exists | “No data for this period”; show available dates and “Go to latest data” |
|
||||||
|
| No normalized history yet | Explain the mode-specific next step; do not assume every counter requires two readings because initial-baseline behavior already exists |
|
||||||
|
| Partial source coverage | Show a partial total only if meaningful, identify missing periods/sources, exclude it from confident comparisons |
|
||||||
|
| Valid quantities, no applicable tariff | Keep quantity analysis; show cost as unavailable with a tariff action |
|
||||||
|
| Explicit applicable zero-priced tariff | Show a valid zero cost |
|
||||||
|
| Invalid virtual expression / missing dependency | Name the problem and affected source; offer Edit calculation or Open source |
|
||||||
|
| Query/refresh error | Local panel error with Retry; distinguish retained stale data from current data |
|
||||||
|
| Valid virtual meter without raw readings | Show derived analysis; raw-reading controls are not applicable |
|
||||||
|
|
||||||
|
Do not promise an exact coverage percentage unless the stored metadata supports it. Monthly observations are not evidence of day-level completeness. Expose source resolution and known coverage bounds; where unknown, say so. Totals, charts, tables, comparisons, and exports must share these semantics.
|
||||||
|
|
||||||
|
## 5. Virtual meters as full analysis subjects
|
||||||
|
|
||||||
|
### 5.1 Canonical definition and editor
|
||||||
|
|
||||||
|
Separate **calculation dependencies** from **physical flow topology**. An upstream link says where energy flows; it must not silently overwrite a configured formula.
|
||||||
|
|
||||||
|
- Provide Sum, Difference, and Advanced expression modes in the shared editor, using source meter selectors with names, quantity kinds, and compatible units.
|
||||||
|
- Store one canonical definition: expression, referenced IDs, result kind, result unit, and supported evaluation/aggregation semantics. Derive referenced IDs from validated expressions or verify they agree exactly.
|
||||||
|
- Sum of two generation meters defaults to generation. Consumption sums default to consumption. Mixed-kind/net calculations require an explicit result meaning. Do not retain the current unconditional consumption kind.
|
||||||
|
- Validate self-reference, cycles including nested virtual meters, missing IDs, syntax, unit compatibility, and result semantics on save and again on read for legacy data. Reuse the restricted expression evaluator; do not evaluate arbitrary code.
|
||||||
|
- Give the editor a preview for the selected historical period, including per-source values and incomplete-data warnings. Show friendly source names beside any `m123` formula tokens.
|
||||||
|
- Show the formula and linked dependencies on virtual meter detail. Replace register/baseline/source-ingestion controls with appropriate calculation controls; keep any applicable note/event capability.
|
||||||
|
|
||||||
|
### 5.2 Existing data compatibility
|
||||||
|
|
||||||
|
The seeded `Summe Solar` meter is virtual with upstream links but no explicit formula (`Infrastructure/Import/ReferenceDataImporter.cs`). Do not break this example or existing installations configured the same way.
|
||||||
|
|
||||||
|
1. Preserve existing explicit expressions as authoritative.
|
||||||
|
2. For expression-less virtual meters with upstream links, compatible normalized units, and unambiguous quantity kind, migrate the existing implied sum to an explicit dependency definition. Update seed creation too.
|
||||||
|
3. Preserve the topology links as topology; changes to flow links after migration must not secretly alter a saved calculation. Offer an explicit calculation edit when desired.
|
||||||
|
4. Flag ambiguous/mixed-unit/cyclic/no-source definitions as needing configuration. Do not invent conversions or overwrite metadata unrelated to virtual calculations.
|
||||||
|
5. Make migration idempotent and report converted/unresolved meter counts. Never modify raw readings. Describe any historical semantic change in release notes.
|
||||||
|
|
||||||
|
### 5.3 Evaluation rules
|
||||||
|
|
||||||
|
Introduce a shared Infrastructure reader (for example `MeterSeriesService`) used by meter history, energy analysis, dashboard, costing, and flow value lookup. Physical meters read normalized aggregate data; virtual meters recursively resolve dependency series.
|
||||||
|
|
||||||
|
- Load all unique physical dependencies in bounded batches and evaluate the dependency graph in topological order. Detect cycles and enforce depth/series/point limits.
|
||||||
|
- Align source buckets by canonical instants and timezone, not exact raw timestamps or localized chart labels.
|
||||||
|
- Missing source data is unknown, not zero. Under the default strict policy, a sum bucket is complete only when all required inputs are available for that bucket. Explicitly known zero is a valid input. Preserve provenance from dependencies.
|
||||||
|
- Propagate nested failures with a useful dependency path. Non-finite arithmetic, including division by zero, produces an invalid bucket with an explanation, never a fabricated zero.
|
||||||
|
- Additive formulas such as `m1 + m2` and `m1 - m2` can roll up their evaluated base buckets. Arbitrary expressions are not necessarily additive: `sum(m1 / m2)` is not `sum(m1) / sum(m2)`.
|
||||||
|
- Define an evaluation basis for non-additive expressions and a metric-appropriate reducer (e.g. ratio of totals or weighted average). If those semantics are not supported, make that metric/granularity explicitly unavailable; do not silently change the formula's meaning when zooming.
|
||||||
|
- Preserve negative net values in history. Sankey rendering may use a separate nonnegative/directional representation, but its rendering limitation must not alter the canonical analysis value.
|
||||||
|
- Evaluation must not depend on cost-category membership. Implement read evaluation first. If costing needs materialized results under SDD §14.1, use the same evaluator with explicit dependency invalidation, rebuild rules, and tests.
|
||||||
|
- Invalidate caches after source ingestion, corrections, events, import/revert, normalization changes, definition edits, and relevant tariff edits. Avoid process-wide unbounded caches or recomputation per chart cell.
|
||||||
|
|
||||||
|
### 5.4 Minimum worked example
|
||||||
|
|
||||||
|
Given generation meters A and B with complete monthly data:
|
||||||
|
|
||||||
|
| Month | A | B | Virtual Sum A+B |
|
||||||
|
|---|---:|---:|---:|
|
||||||
|
| January | 100 kWh | 150 kWh | 250 kWh |
|
||||||
|
| February | 80 kWh | 120 kWh | 200 kWh |
|
||||||
|
|
||||||
|
The virtual meter shows 450 kWh for the two-month period, a generation label, both history points, source contribution details, and the same numbers in type analysis. It needs no raw readings and no cost category. If B is missing in February, that month is incomplete, not a confident 80 kWh. If B has an observed zero, the complete result is 80 kWh. A separate A−B meter shows −50 and −40 kWh rather than inheriting the flow service's sum.
|
||||||
|
|
||||||
|
## 6. Totals, costs, and energy-type semantics
|
||||||
|
|
||||||
|
Do not achieve visual consistency by making every page sum all meters. Define a shared aggregation policy and include its selected basis in results and visible explanations.
|
||||||
|
|
||||||
|
### 6.1 Quantity totals
|
||||||
|
|
||||||
|
- Separate consumption, generation, runtime, tank balance, and net quantities. Same energy-type membership does not guarantee addable units or independent measurement coverage.
|
||||||
|
- Default physical throughput to the appropriate non-overlapping topology roots, with consumption and generation separate. List exactly which meters contribute and which are excluded.
|
||||||
|
- A virtual view of already-counted sources is visible and analyzable but excluded from an additive portfolio total by default. Explicit selections can replace source coverage with a virtual result; they must not add both.
|
||||||
|
- Detect known overlap using topology and virtual dependencies. Do not claim completeness where overlapping measurements cannot be established from configuration; request scope configuration through a clear page action.
|
||||||
|
- Distinguish throughput from billed import and total household use. Multi-parent topology and grid-plus-solar supply do not justify summing every node as “consumption.”
|
||||||
|
- Retired meters retain their historical contribution. `IsActive` controls current operation, not erasure from historical totals; respect effective install/retire dates where reliable.
|
||||||
|
|
||||||
|
### 6.2 Costs
|
||||||
|
|
||||||
|
- Make cost inclusion explicit at the relevant meter/scope configuration. Default new virtual meters to analysis-only for portfolio costing so enabling their analysis does not increase the bill.
|
||||||
|
- Meter detail may show the cost of a physical or virtual scope without automatically including that scope in portfolio totals.
|
||||||
|
- Virtual costing must name its rule: tariff applied to the virtual quantity, or aggregation of already-priced independent sources. These differ when sources have different tariffs. Default to unavailable until a valid rule is inferable or configured; never sum source costs and reprice the combined quantity together.
|
||||||
|
- Handle standing charges once for the intended billing scope. Do not replicate a type/global base charge across every analytical submeter and virtual view. Preserve existing tariff precedence and document any changed billing rule.
|
||||||
|
- Add applicable price coverage to cost results. Historical tariff gaps produce partial/unavailable cost; existence of any tariff anywhere is insufficient. Missing optional credits must be distinguishable from missing required unit prices.
|
||||||
|
- For a year with tariff changes, aggregate correctly priced underlying billing periods; do not price the whole year from a July sample. Changing chart granularity must not change the total bill. Keep the existing monthly pricing convention unless a deliberate change is documented and reconciled.
|
||||||
|
- Include manual costs exactly once in matching overview, trend, and category totals. Show uncategorized contributions rather than dropping them. If categories overlap, label them as overlapping views and do not present their sum/donut as a disjoint breakdown of the bill.
|
||||||
|
- Use signed bars/tables for cost credits and negative totals. A donut is appropriate only for a nonnegative, disjoint composition.
|
||||||
|
- Make “Latest month with data” return its actual period and availability basis, including manual costs. Never silently switch every dashboard panel to historical data; offer an explicit action to open that period.
|
||||||
|
- Use configured currency consistently. Use normalized quantity units for analysis and raw units only for raw register values; resolve runtime-to-volume conversion explicitly.
|
||||||
|
|
||||||
|
## 7. Page specifications
|
||||||
|
|
||||||
|
### 7.1 Overview dashboard
|
||||||
|
|
||||||
|
Use a consistent header, period toolbar, and compact coverage/freshness summary. The initial viewport should answer: what happened in this period, what changed, and where to investigate.
|
||||||
|
|
||||||
|
1. Period cost with coverage and comparison. Show usage/generation per energy type with their own units, rather than adding unlike quantities to one total.
|
||||||
|
2. Energy-type cards with quantity, available cost, comparison, and a clear link to that type's analysis. Quantity cards work even without tariffs or categories.
|
||||||
|
3. Shared historical chart with metric toggle and previous-period overlay; the selected range applies to every analytical panel.
|
||||||
|
4. Ranked change table by category or meter: current, previous, absolute delta, percentage where meaningful. Rows link to scoped analysis with the same dates.
|
||||||
|
5. Cost composition that reconciles to the selected scope, with explicit overlapping/uncategorized handling.
|
||||||
|
6. Compact attention items only for relevant issues: missing prices, missing data, invalid virtual dependencies, stale sources. Provide a targeted action for each.
|
||||||
|
|
||||||
|
Keep the update banner separate from analytical status. Avoid making configuration of cost categories a prerequisite for viewing valid quantities.
|
||||||
|
|
||||||
|
### 7.2 Meter detail
|
||||||
|
|
||||||
|
Move the tab/navigation bar directly below the identity and action header. Put the analytical content inside the default **Analysis** tab so Sources and Events do not sit below a long wall of charts.
|
||||||
|
|
||||||
|
- Tabs: Analysis, Readings where applicable, Normalized data, Events, Tariffs, Sources for physical input meters / Calculation for virtual meters.
|
||||||
|
- Preserve old `tab=readings|consumption|events|tariffs|sources` links with a compatibility mapping. Resolve tabs by stable keys and capability, not fixed numeric indexes after conditional tabs are introduced.
|
||||||
|
- Analysis: shared period controls; selected-period quantity/cost/comparison; actual-versus-projection distinction; full-size chart; year-over-year view; accessible table; CSV export; data-quality/coverage explanation.
|
||||||
|
- History table: period, quantity, cost, comparison, quality, and coverage. Include year in date labels across multi-year ranges. Chart selection can drill to supported finer detail while retaining scope.
|
||||||
|
- Show lifecycle events and tariff changes as optional contextual markers, bounded to the selected range. A chart click should lead to records/events capable of explaining that interval.
|
||||||
|
- Raw and normalized-data tabs need server-side date filtering and pagination with a stable ordering. The latest-200 view is not full history. Explain raw retention separately from retained analytical history.
|
||||||
|
- Virtual Analysis includes source contributions and formula details. Do not show fake register totals or suggest adding a raw reading to fix virtual history.
|
||||||
|
|
||||||
|
### 7.3 Energy-type page
|
||||||
|
|
||||||
|
Title the page with the energy type's user-defined name. Use **Overview / History / Flow / Meters** tabs, defaulting to Overview.
|
||||||
|
|
||||||
|
- Overview: separate appropriate quantity kinds, cost with billing basis, coverage, trends, and largest changes.
|
||||||
|
- History: shared chart/table, day/week/month/year selection, calendar-year comparison, and optional per-meter series. Offer “total” and “individual meters” views with overlap explanations.
|
||||||
|
- Flow: retain Sankey as a topology tool, using the canonical meter values. Mark inferred proportional allocations as estimates. Provide a textual/table equivalent and a connection-management entry point.
|
||||||
|
- No topology must not prevent analysis. Negative/net values remain available in History even if unsuitable for a ribbon.
|
||||||
|
- Meters: searchable list with period values, quality/coverage, physical/virtual distinction, and existing quick actions. A missing graph node must not become a fake zero meter value.
|
||||||
|
- Add appropriate links to Solar/consumables without making users rediscover a different period selector there.
|
||||||
|
|
||||||
|
### 7.4 Analysis page (existing `/trends`)
|
||||||
|
|
||||||
|
Replace the single monthly total-cost chart with one reusable exploration page. Scope selector: portfolio cost, energy type, category, individual meter, or explicit meter comparison. Metric selector: supported quantity kind or cost. Support at most six simultaneous meter series by default, with an explanation when the selection exceeds that limit.
|
||||||
|
|
||||||
|
Use the same series reader, toolbar, chart, and table as meter/type pages. Do not build a second formula engine here. Comparable meter quantities must have compatible normalized units; otherwise split charts or explain why the comparison is unavailable. Category analysis is always available for cost; quantity analysis needs a single compatible quantity kind/unit.
|
||||||
|
|
||||||
|
### 7.5 Solar and consumables
|
||||||
|
|
||||||
|
Adopt the shared toolbar, theme, cards, history components, and missing-data semantics while retaining their specialized measures. Distinguish current tank balance/forecast from historical period totals. Show the balance's observation date explicitly; a historical date range must not label today's balance as a historical observation.
|
||||||
|
|
||||||
|
Replace visible instructions to edit raw role tags with friendly meter-role configuration controls. Missing Solar context should identify the required role and provide a scoped setup path. Use configured units/conversions rather than assuming every generation counter is measured in kWh.
|
||||||
|
|
||||||
|
## 8. Shared UI and interaction standards
|
||||||
|
|
||||||
|
- Extract common components for page header, period toolbar, metric card, analysis chart/table, and availability state. Reuse MudBlazor and the existing ApexCharts integration.
|
||||||
|
- Use the existing theme for colors, spacing, borders, typography, and density. Charts must respond to light/dark changes in the current circuit.
|
||||||
|
- Chart points retain temporal identity, nullable value, and provenance; avoid reducing them to only `string Label, double Value` before rendering. Preserve numerical precision in calculation and round only for display.
|
||||||
|
- Display units in axes/tooltips and currency in cost series. Do not smooth or connect across unknown intervals. Render signed bars around a real zero baseline.
|
||||||
|
- Provide keyboard-accessible links/actions, chart table alternatives, touch-friendly controls, visible focus, and meanings that do not rely only on red/green.
|
||||||
|
- Use localized EN/DE strings in both resources and existing typed accessors. Format user data without translating meter/type names. Cover long German labels on narrow screens.
|
||||||
|
- Validate at approximately 360px, 768px, and desktop widths. Controls should wrap predictably; keep page-wide overflow out of the main layout and contain wide tables locally.
|
||||||
|
- Cancel superseded loads or use request-generation IDs so only the latest requested scope/range is committed. Publish a coherent result atomically; do not mix the previous type's chart with the next type's title.
|
||||||
|
- Expose initial loading, refresh, error/retry, and stale-but-visible states consistently. Dispose subscriptions/cancellation sources with the circuit/component.
|
||||||
|
|
||||||
|
## 9. Data access, history resolution, and performance
|
||||||
|
|
||||||
|
Preserve immutable raw readings and existing normalization rules, including month-label attribution, local-month splitting, swaps/resets, and import/revert behavior. Do not rescan raw readings to draw long-range charts.
|
||||||
|
|
||||||
|
1. Centralize physical/virtual analytical reads in Infrastructure. Keep the formula algebra, compatibility checks, and reducers pure in Core where practical; keep presentation out of the domain model.
|
||||||
|
2. Select meter IDs and time bounds in SQL before aggregation. Batch dependency and tariff reads. Avoid one full scan/context/tariff load per meter per panel.
|
||||||
|
3. Audit existing continuous aggregates before switching readers: timezone, real-time/materialized behavior, backfill after historical imports, late corrections, retention, and current incomplete buckets. Their comments are not proof of freshness.
|
||||||
|
4. Add migrations for required aggregate/metadata changes rather than editing previously applied migrations. Support the configured instance timezone, not a hardcoded Berlin-only implementation.
|
||||||
|
5. Preserve coverage/provenance and available resolution through aggregation. The existing amount-only aggregates cannot answer these questions by themselves; define auxiliary summaries or sufficient aggregate fields.
|
||||||
|
6. Legacy monthly data must remain honestly monthly. Daily/week views cannot display one month-end amount as a measured daily spike. If interpolation is offered, make it explicit, mark it interpolated, and preserve monthly totals; otherwise explain the unavailable resolution.
|
||||||
|
7. Never include both a refreshed aggregate bucket and its underlying consumption in a live-tail merge. Test exact refresh boundaries and backfilled periods.
|
||||||
|
8. Bound raw/event retrieval and chart output separately. Prove history still works after raw readings are removed by the retention policy.
|
||||||
|
9. Keep current public API contracts compatible. Reuse the shared reader behind relevant endpoints where possible; add versioned/additive fields for availability rather than silently changing existing numeric response types.
|
||||||
|
10. Record representative timings and query plans before/after. Proposed review target: cached metadata plus a 10-year monthly request for 100 selected meters should complete within two seconds on documented local test hardware. Treat this as a target to measure, not a verified property. Test 1,000-meter selection/query planning and enforce output limits without a per-meter scan storm.
|
||||||
|
|
||||||
|
## 10. Implementation phases and exit criteria
|
||||||
|
|
||||||
|
### Phase 1 — Shared semantics and regression fixtures
|
||||||
|
|
||||||
|
- Add frozen-clock period resolution, analysis contracts, zero/missing/partial semantics, normalized units, and explicit aggregation/cost policy.
|
||||||
|
- Add fixtures reproducing virtual absence, overlapping scopes, missing prices, historical-only data, and inconsistent cutoffs.
|
||||||
|
- Resolve proposal choices in this brief in a short implementation note; identify any incompatible legacy assumptions before migration.
|
||||||
|
|
||||||
|
**Exit:** deterministic tests pin interval boundaries, scope membership, missing-data rules, and arithmetic semantics. No new UI claims rely on unresolved totals.
|
||||||
|
|
||||||
|
### Phase 2 — Virtual evaluation and compatible migration
|
||||||
|
|
||||||
|
- Implement shared physical/virtual series reader, canonical definitions, validation, editor, compatibility migration, and source contribution results.
|
||||||
|
- Remove the blanket virtual rejection from period analysis. Replace the old “virtual returns null” test with positive and error-state behavior tests.
|
||||||
|
- Route flow and cost quantity lookup through the same evaluator, keeping flow rendering separate.
|
||||||
|
|
||||||
|
**Exit:** the worked A+B/A−B examples and seeded Summe Solar have full analysis without raw readings/category setup; nested/error cases are explained; enabling analysis does not double portfolio totals.
|
||||||
|
|
||||||
|
### Phase 3 — History and page navigation
|
||||||
|
|
||||||
|
- Implement shared toolbar/chart/table; upgrade meter, energy-type, and Analysis pages.
|
||||||
|
- Add period-preserving URLs, breadcrumbs, compatibility tab routing, history pagination, exports, and stale-request protection.
|
||||||
|
|
||||||
|
**Exit:** a user can navigate Overview/type → meter → historical month and back without losing dates, and compare historical years for physical and virtual meters.
|
||||||
|
|
||||||
|
### Phase 4 — Overview and specialized consistency
|
||||||
|
|
||||||
|
- Rework dashboard quantities/costs/change tables, aligned manual costs, latest-data period, targeted setup actions, sidebar grouping, and Solar/consumable controls.
|
||||||
|
- Complete theme, currency, normalized-unit, localization, mobile, and accessibility work.
|
||||||
|
|
||||||
|
**Exit:** matching scopes/periods reconcile across cards, charts, tables, and exports. Valid quantity analysis remains visible when costs are unavailable.
|
||||||
|
|
||||||
|
### Phase 5 — Integration, performance, and documentation
|
||||||
|
|
||||||
|
- Run targeted regression suites and then the required full build/tests. Measure query behavior with historical and synthetic data.
|
||||||
|
- Walk through the seeded application in EN/DE, light/dark, and mobile/desktop. Capture representative before/after screenshots and any remaining limitations.
|
||||||
|
- Update SDD/CLAUDE descriptions that currently claim unsupported virtual behavior or no longer describe the navigation/calculation model.
|
||||||
|
|
||||||
|
**Exit:** all acceptance scenarios below pass or have an explicitly documented blocker. Do not mark the feature complete after only changing menus or removing the virtual `return null`.
|
||||||
|
|
||||||
|
## 11. Acceptance scenarios and validation
|
||||||
|
|
||||||
|
| Scenario | Required evidence |
|
||||||
|
|---|---|
|
||||||
|
| Two-source virtual generation sum | 100+150=250 and 80+120=200 by month; 450 total; generation units; physical and virtual pages/type history agree |
|
||||||
|
| Missing versus zero source | Missing B makes the bucket partial/unavailable; observed B=0 yields a complete sum |
|
||||||
|
| Difference and nesting | Negative A−B plots below zero; nested dependencies resolve once; cycle reports a named dependency error |
|
||||||
|
| Invalid arithmetic | Division by zero and unsupported non-additive rollup produce explanations, never zero or infinity |
|
||||||
|
| Legacy virtual configuration | Seeded Summe Solar acquires a compatible definition; migration rerun changes nothing; existing formulas retain precedence |
|
||||||
|
| Overlapping topology | Parent 300 and child 100 display 300 for the non-overlapping parent scope, with 100 as a breakdown, not 400 |
|
||||||
|
| Virtual overlap | Sources 100+150 and virtual 250 remain a 250 portfolio quantity where that is the selected coverage, not 500; costing has equivalent explicit coverage |
|
||||||
|
| Missing versus free tariff | Valid quantity with missing required price has unavailable cost; explicit zero tariff has valid zero cost |
|
||||||
|
| Cost stability | Manual costs appear once; standing charges follow the chosen billing scope; changing chart bucket does not reprice annual totals from one sampled tariff |
|
||||||
|
| Historical-only instance | Current period explains no data and offers actual available dates; selecting historical year shows quantities/costs for physical and virtual meters |
|
||||||
|
| Manual-cost-only instance | Overview, trend, category breakdown, and latest-data month agree without requiring a meter |
|
||||||
|
| Local-time boundaries | Frozen clock tests at local New Year, Berlin spring/fall DST, leap day, shorter months, and a zone behind UTC; from/to are half-open consistently |
|
||||||
|
| Future rows and partial periods | To-date totals exclude future data; comparisons state actual matched dates; projections are explicitly separate |
|
||||||
|
| Zero/negative history | Legitimate all-zero year remains visible; negative net history is signed and percentage rules are consistent |
|
||||||
|
| Monthly legacy resolution | Monthly import stays monthly or explicitly interpolated; daily chart never invents measured detail |
|
||||||
|
| Retained history | Charts still work after old raw readings are absent; raw-record tab explains retention |
|
||||||
|
| Rapid filter/navigation change | Delayed first request cannot overwrite the later selected type/range; reload/back preserves URL state |
|
||||||
|
| Compatibility actions | Existing reading/event/edit/source URLs still open the intended action once; connector detour preserves typed draft and return context |
|
||||||
|
| UI consistency | Theme toggle updates charts; EN/DE resource tests pass; narrow-screen controls and keyboard/table alternatives work |
|
||||||
|
| Import/correction freshness | Import, revert, reading deletion, swap/reset, and tariff/definition edits refresh affected physical/virtual history without duplicates |
|
||||||
|
|
||||||
|
Use the existing suites as starting points:
|
||||||
|
|
||||||
|
- `tests/Core.Tests/VirtualMeterTests.cs`, `ExpressionEvaluatorTests.cs`, and relevant normalization tests.
|
||||||
|
- `tests/Integration.Tests/MeterPeriodServiceTests.cs`, `FlowServiceTests.cs`, `CostSetupTests.cs`, and costing/reconciliation suites.
|
||||||
|
- `tests/Integration.Tests/DashboardRenderTests.cs`, `LocalTimeEntryTests.cs`, and `Localization/StringResourceTests.cs`.
|
||||||
|
|
||||||
|
Add tests around substantive data behavior and navigation state; do not rely on snapshots of markup alone. Server-render tests do not prove interactive ApexCharts updates, browser history, or responsive usability. Add meaningful interaction coverage using the repository's available tooling, or record a manual browser checklist when no suitable harness exists.
|
||||||
|
|
||||||
|
Commands for the implementing agent:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet build
|
||||||
|
dotnet test tests/Core.Tests
|
||||||
|
dotnet test tests/Integration.Tests
|
||||||
|
```
|
||||||
|
|
||||||
|
Integration tests need Docker/TimescaleDB. Report actual commands/results and prerequisites that prevented execution. Preserve golden CSV reconciliation unless a deliberate correctness fix is described with old/new values and a focused regression test. The source review that produced this brief did not run these tests.
|
||||||
|
|
||||||
|
## 12. Delivery requirements for Claude Code
|
||||||
|
|
||||||
|
Deliver working code in reviewable phases, necessary migrations, both language resources, substantive tests, and updated documentation. Keep the current stack and user-defined energy types. Do not rewrite ingestion, introduce a new frontend, or change raw-reading history to make charts look correct.
|
||||||
|
|
||||||
|
The final implementation report should state which findings were fixed, how physical and virtual results now agree, which cost/aggregation decisions were applied to existing data, what was tested, and any remaining limitations. Include screenshots of the new Overview, physical/virtual meter history, and energy-type history at desktop and mobile widths.
|
||||||
|
|
||||||
|
**Definition of done:** a user can find the correct action without guessing which page owns it, examine the same selected period consistently across the application, analyze a valid combined virtual meter as fully as its compatible physical inputs, and distinguish a true zero from unavailable or incomplete information.
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
# Release notes
|
||||||
|
|
||||||
|
User-visible changes per release. Earlier releases are described in the git history and in the README's upgrade
|
||||||
|
sections. Decision ids (D-nn, A-nn) refer to [`ANALYSIS_IMPLEMENTATION_NOTE.md`](ANALYSIS_IMPLEMENTATION_NOTE.md).
|
||||||
|
|
||||||
|
## 0.4.0 — Dashboard, navigation and historical analysis (unreleased)
|
||||||
|
|
||||||
|
This release rebuilds how MeterVault analyses and prices data, and the pages that show it. Every page now works on
|
||||||
|
the same selected period and the same numbers. A virtual (calculated) meter can be analysed like a physical one. A
|
||||||
|
true zero, missing data, data that exists only per month, and a missing price are always told apart.
|
||||||
|
|
||||||
|
**Some figures change on purpose.** Most visibly, an energy type's bill now counts its grid meter, not every meter
|
||||||
|
that exists. [Changed figures](#changed-figures) lists every deliberate change. The seeded demo's yearly costs now
|
||||||
|
match the spreadsheet: 2025 comes to 7,907.65 € against the sheet's 7,907.64 €. The seeded "this year" cost for 2026
|
||||||
|
was 4,402.19 € in 0.3.0, which priced Haus + Netz + Auto and water at 5 €/m³. It is now 2,940.19 €, the sheet's
|
||||||
|
figure.
|
||||||
|
|
||||||
|
### Upgrading
|
||||||
|
|
||||||
|
Back up the database (`pg_dump`) first.
|
||||||
|
|
||||||
|
- **The first start rebuilds all analysis data** (normalization revision 3):
|
||||||
|
- For every meter it rebuilds consumption and the new day and month rollups plus their coverage. It records the
|
||||||
|
state of each meter.
|
||||||
|
- This runs **before the web server listens**. The app is unreachable meanwhile, and Compose may report the
|
||||||
|
container unhealthy. Let it finish.
|
||||||
|
- The time grows with the number of raw readings. One meter took about 0.05 s with monthly readings, ~0.5 s with ten
|
||||||
|
years of daily readings (3,700 readings), and ~1.2 s with a year of hourly readings (9,300 readings).
|
||||||
|
- A synthetic 1,000-meter × 10-year instance (1.34 M readings) took 4.7 minutes (279 s) on an idle Ryzen 9 9950X3D —
|
||||||
|
about 3.6 meters a second. It writes rollups and coverage on top of consumption, so it does more per meter than
|
||||||
|
the 0.3.0 rebuild did.
|
||||||
|
- Progress is logged every 100 meters. A meter that fails is logged, kept pending and retried at the next start.
|
||||||
|
A meter whose stored consumption is older than its oldest remaining reading is skipped and logged, so its
|
||||||
|
history is not truncated.
|
||||||
|
- Until a meter is rebuilt, its pages say "analysis being prepared", never "no data".
|
||||||
|
- **The migration records each meter's last reading time** in the analysis state table and fills it in for the meters
|
||||||
|
you already have, in one pass over the readings. It is what the pages show as "last activity" and what decides
|
||||||
|
whether a live source is late, so no page has to search the raw readings for it any more.
|
||||||
|
- **Virtual meters without a formula** (such as a seeded *Summe Solar* from an earlier version) are converted at
|
||||||
|
startup. When their incoming links name meters of one unit and kind, the implied sum is stored as an explicit
|
||||||
|
formula (D-28). The log lists the converted meters, and the meters that still need configuration because their
|
||||||
|
links are ambiguous, in mixed units, or loop. The conversion runs once; a rerun changes nothing. From then on, links
|
||||||
|
are topology only and never change a calculation.
|
||||||
|
- **The migration drops the unused continuous aggregates** and their hourly refresh jobs. It also deletes consumption
|
||||||
|
rows stored for virtual meters, which nothing read (D-17).
|
||||||
|
- **Check the attention items** on the Overview after the first start. They name missing prices with a direct "Add
|
||||||
|
tariff" link, invalid calculations, stale live sources, rows dated after now, and meters that may be counted twice.
|
||||||
|
- **Rolling back** to 0.3.0 works. The old version ignores the new tables and never read the dropped aggregates.
|
||||||
|
Consumption stays as 0.4.0 booked it until each meter next ingests a reading. Stored virtual formulas remain, and
|
||||||
|
0.3.0 goes back to summing links.
|
||||||
|
|
||||||
|
### What's new
|
||||||
|
|
||||||
|
**Navigation.**
|
||||||
|
- The sidebar has a fixed structure: Overview, Analysis, Meters, an **Energy types** group, **Specialized views**
|
||||||
|
(Solar, Tanks & consumables; always listed, with a setup hint when not configured), Data import, and
|
||||||
|
**Configuration**.
|
||||||
|
- "Configuration → Energy types" edits the definitions; the Energy types group is for analysis.
|
||||||
|
- Expanded groups are remembered, and the current page's group always opens. If the energy types cannot be loaded,
|
||||||
|
the menu shows an error with Retry instead of silently dropping them.
|
||||||
|
- Breadcrumbs (Overview → energy type → meter) keep the selected period, and so does Back.
|
||||||
|
- "Find a meter" opens the meter's analysis with the current period and still offers quick entry. It is a text
|
||||||
|
button on desktop and an icon on phones.
|
||||||
|
|
||||||
|
**One period everywhere.**
|
||||||
|
- Every analysis page has the same toolbar: this month to date, last month, year to date, previous year, last 12 or
|
||||||
|
24 months, all history, or custom dates with one Apply.
|
||||||
|
- It also offers a bucket size (automatic, day, week, month, year) and a comparison: previous period, same period
|
||||||
|
last year (the default), or any calendar year.
|
||||||
|
- The effective dates are shown next to the choice, with the time zone. The selection lives in the address, so
|
||||||
|
reload, Back and shared links reproduce the page.
|
||||||
|
- Rapid clicks can no longer leave one page showing another selection's data.
|
||||||
|
|
||||||
|
**Overview.**
|
||||||
|
- It shows one selected period (default month to date):
|
||||||
|
- the cost, split into metered use, standing charges, manual costs and feed-in credit;
|
||||||
|
- a card per energy type with its quantities in their own units, its cost and billing basis, the change and
|
||||||
|
freshness;
|
||||||
|
- a history chart with a table view;
|
||||||
|
- "What changed", by category or by meter;
|
||||||
|
- the cost composition;
|
||||||
|
- attention items, each with one targeted action.
|
||||||
|
- Changes are compared only over the part both periods cover, and the page states both date ranges.
|
||||||
|
- When the period has no data, the page names the dates that do have data and offers "Go to latest data". It never
|
||||||
|
silently switches to an older month.
|
||||||
|
|
||||||
|
**Analysis page** (`/trends`, formerly the cost trend).
|
||||||
|
- Explore everything, one energy type, a cost category, one meter, or up to six meters side by side, by quantity or
|
||||||
|
by cost.
|
||||||
|
- Compare calendar years with an overlay and a comparison table. Click a bar or a row to drill into a finer period.
|
||||||
|
- Export exactly what is shown as CSV.
|
||||||
|
|
||||||
|
**Energy type pages** are titled with the type's own name and have four tabs:
|
||||||
|
- **Overview:** measures such as total use, grid import, generation and runtime, each in its own unit and never added
|
||||||
|
across units; the cost with its billing basis; coverage; the largest changes.
|
||||||
|
- **History:** the total, or up to six individual meters with an explanation of how each one counts.
|
||||||
|
- **Flow:** the Sankey, now using the same values as every other page. Calculated and estimated connections are
|
||||||
|
marked. It comes with a table version and **Manage connections**.
|
||||||
|
- **Meters:** each meter's value for the period and its data quality.
|
||||||
|
|
||||||
|
**Meter page.**
|
||||||
|
- The tabs sit directly under the header: Analysis, Readings, Normalized data, Events, Tariffs, Sources. A calculated
|
||||||
|
meter has Calculation instead of Sources.
|
||||||
|
- The Analysis tab shows:
|
||||||
|
- the period total with its unit and status, and the cost with its rule, or the reason it has none;
|
||||||
|
- the change against the comparison period;
|
||||||
|
- a labelled projection, where there is enough data for one;
|
||||||
|
- a full chart with the previous-year overlay, and a table;
|
||||||
|
- a "Data quality and coverage" section;
|
||||||
|
- events and tariff changes in the range.
|
||||||
|
- The record tabs page through the **whole history** (100 rows at a time, filtered by the selected dates), no longer
|
||||||
|
just the latest 200. Rows dated after now are marked.
|
||||||
|
- Existing links such as `?tab=consumption`, `?tab=readings&action=reading` and `?tab=events&action=swap` still open
|
||||||
|
the intended tab and dialog once.
|
||||||
|
|
||||||
|
**Virtual (calculated) meters.**
|
||||||
|
- Create them with **Sum**, **Difference** or a **Formula** over other meters, picked by name. A live preview of the
|
||||||
|
selected period shows every source's values and flags incomplete months.
|
||||||
|
- The result kind (consumption, generation, net or indicator), unit and cost rule are stored with the formula.
|
||||||
|
- A virtual meter gets the same analysis as a physical one, plus "Source meters" showing each source's contribution.
|
||||||
|
- **The rules:**
|
||||||
|
- A missing source month makes the result "no data" for that month, never a silent zero.
|
||||||
|
- An observed zero is a real zero.
|
||||||
|
- A division by zero or a loop is reported, and names the meters involved.
|
||||||
|
- Differences stay negative.
|
||||||
|
- New calculated meters are *analysis only*: they never add to a type's totals or the bill. The "Always count" option
|
||||||
|
lets one replace its sources instead.
|
||||||
|
|
||||||
|
**Solar and Tanks & consumables** use the same toolbar, cards and charts.
|
||||||
|
- **Solar** works out self-consumption, feed-in, site use, savings and autarky from the meters' roles. For a missing
|
||||||
|
role it shows a setup card with candidate meters instead of raw role tags.
|
||||||
|
- **Tanks** keep "Last dipstick (date)" apart from "Estimated now". A past period shows the contents at its end, not
|
||||||
|
today's. The forecast is a labelled projection, hidden when the dipstick is older than 60 days.
|
||||||
|
|
||||||
|
**Tariffs and configuration.**
|
||||||
|
- An "Add tariff" link from a missing price opens the tariff editor once, prefilled with the scope, component and
|
||||||
|
first uncovered month.
|
||||||
|
- The unit is checked against what it prices: a wrong unit or currency blocks the save.
|
||||||
|
- A new tariff needs a value; a typed 0 is a deliberate free period.
|
||||||
|
- The editor notes that Bonus, Discount and Tax are stored but **not applied** yet.
|
||||||
|
- Meter roles have friendly names and one-line meanings. A role is unique per energy type, and saving it names the
|
||||||
|
meter it moves from.
|
||||||
|
- The Settings page shows the analysis data state and that raw retention is not enforced.
|
||||||
|
|
||||||
|
**Also:**
|
||||||
|
- CSV export of any analysis view (`/export/analysis.csv`): statuses, provenance, costs and comparison values; unknown
|
||||||
|
values are empty cells, never 0.
|
||||||
|
- The light/dark choice persists across reloads and language switches, and charts follow it immediately.
|
||||||
|
- Visible keyboard focus, and tables for every chart.
|
||||||
|
- MudBlazor's own labels are in German too.
|
||||||
|
- Pages work at phone width.
|
||||||
|
- Amounts use the configured currency (`MeterVault__Currency`) instead of a hard-coded €.
|
||||||
|
- On a page that shows several figures, the line about the compared dates names the figure it is about, so it can no
|
||||||
|
longer say "not comparable" above a table that compares another meter month by month (A-41).
|
||||||
|
- Every dated table reads newest first, with the total above the rows it sums: the meter's Analysis tab, an energy
|
||||||
|
type's History, the Analysis page, Solar, Tanks & consumables, the tariff lists and a virtual meter's preview. The
|
||||||
|
meter page's events and price changes are one dated list instead of two. Charts and the CSV export stay
|
||||||
|
chronological, because that is how they are read (A-42).
|
||||||
|
- "Largest changes by meter" no longer leaves out a meter it cannot rank: one that has data but no comparable change —
|
||||||
|
a tank dipped a few times a year — is listed under "Other meters" with both its values and the reason there is no
|
||||||
|
change. A meter with nothing on either side stays out, and whatever the caps leave over is counted out loud (A-43).
|
||||||
|
- Deleting a meter or an energy type also deletes the tariffs scoped to it.
|
||||||
|
- JSON export/import now carries meter connections, re-links virtual formulas to the new meter ids, and no longer
|
||||||
|
restores the tariffs of deleted meters onto other meters.
|
||||||
|
|
||||||
|
### Changed figures
|
||||||
|
|
||||||
|
Every change below is deliberate. The golden spreadsheet reconciliation (consumption of all four sheets, Netz
|
||||||
|
Einsparung) is unchanged. The seeded yearly bill matches the sheet's `Jahreskosten` within ±0.02 € for 2022, 2025 and
|
||||||
|
2026. It differs by 3.78 € (2023) and 0.46 € (2024) only because the sheet multiplies by unrounded prices.
|
||||||
|
|
||||||
|
| Area | 0.3.0 | 0.4.0 |
|
||||||
|
|---|---|---|
|
||||||
|
| What an energy type's bill counts | Every meter's cost was summed: Haus + Netz + Auto for the seeded Strom | The type's grid import meter when it has one, otherwise its household use. Submeters are breakdowns; generation is never billed. Seeded Strom is Zähler Netz × price, like the sheet (2025: 4,742.64 €) (D-22, D-34) |
|
||||||
|
| Feed-in credit | Credited on all generation | Only on a meter with the grid-export role, at the feed-in price (D-34) |
|
||||||
|
| A subsection with its own meter price | Added on top | Billed at its own price and taken out of the meter above it; quantities unchanged (D-35, A-19) |
|
||||||
|
| Missing tariff | Cost 0 | "Not priced (no tariff)" with an Add tariff action; a hole in a price history is a price gap (unavailable). The quantities stay visible. An explicit 0 tariff is still a valid zero (D-38) |
|
||||||
|
| Standing charges | Per meter, per month that had readings, and copied onto every meter of the type | Per local day over the scope's service period (including reading gaps), **once per scope**. Type and global charges are their own rows; meter fees stay on their meter (D-40, A-18) |
|
||||||
|
| Price of a longer bucket | Month buckets used the price of the 15th, year buckets the price of 1 July | Every bucket is priced month by month at the price of the 15th, so a year equals the sum of its months and changing the bucket never changes a total (D-36) |
|
||||||
|
| Months in which the billed grid meter was not yet (or no longer) in service | — (0.3.0 summed every meter) | Unavailable rather than free while use was measured, with an attention item naming the grid meter and the months (A-17) |
|
||||||
|
| Tank, runtime, direct-delta or instant-rate readings more than a month apart (a tank dipped every few months, quarterly burner hours) | Booked whole in the month of the later reading, with zeros in between | The months in between read "only coarser data" (not zero). A year or longer bucket is priced when all its months share one price; otherwise it is unavailable with an attention item (A-16) |
|
||||||
|
| A period that opens after the last reading of a meter read more coarsely than it (a tank dipped once a year, last dipped shortly before the period began) | "No data for this period", beside a coverage panel listing years of data | "Only coarser data", naming the resolution: the next dipstick will book it. The card, the chart, the table and the coverage panel now say the same thing, and the panel adds a measure's own dates when they differ from the type's. A meter that books its own buckets — an hourly source gone silent, a monthly sheet asked about a later month — still reads "no data" (A-41) |
|
||||||
|
| Automatic interval with data coarser than a month | The whole chart went to years, which resolved nothing: "last 12 months" became one bar per year | Months, with the buckets marked "only coarser data" and the coarser interval one click away. A meter read on the quarter, whose intervals lie inside one year, still charts in years (A-41) |
|
||||||
|
| Manual costs | Counted in the Overview but not in the trend; a cost dated later this month counted at once | Counted once, on their start day, when that day has come, everywhere: Overview, Analysis, categories, export (D-41) |
|
||||||
|
| Cost categories | Sum of their member meters' costs | The priced non-overlapping cover of their members plus their manual costs. Seeded Strom = Netz × price. A category overlapping another is shown as a view, apart from the composition. A category whose members price nothing says so (D-42, A-22) |
|
||||||
|
| Virtual meters | No analysis; the flow summed incoming links, ignoring any formula | Full analysis from the stored formula; the Sankey uses the same values (D-27, D-30) |
|
||||||
|
| Summe Solar and other generation sums | — | Analysed as generation (seeded 2025: 4,750 kWh = Solar 1 3,123 + Solar 2 1,627) and **not costed**, because generation is never billed (A-15) |
|
||||||
|
| Readings at exactly midnight | Booked in the following day | Booked in the day they close (D-11) |
|
||||||
|
| "Last 12 months" | 13–14 buckets, including a partial future month | 12 calendar buckets ending with the current month; actuals stop at now (D-02) |
|
||||||
|
| Rows dated after now | Counted in "to date" totals | Excluded and shown as "recorded after now", e.g. a sheet row labelled the current month or a future-stamped reading. A day that holds such a row reads partial (D-04, A-14, A-20) |
|
||||||
|
| Overview "this month / this year" | Current month and year against the complete previous ones | The selected period against the same elapsed part of the comparison period, measured over what both cover (D-07) |
|
||||||
|
| "Latest month with data" | An amount without its month; consumption only | The month and its basis (meter data, manual costs or both) (D-19) |
|
||||||
|
| Percentages | A negative baseline was divided by its absolute value | "Not applicable" for a zero or negative baseline; the absolute difference is always shown (D-08) |
|
||||||
|
| Meter dates | — | Outside its install and retire dates a meter counts as a known zero; retired meters keep their history (D-24) |
|
||||||
|
| Currency | € hard-coded in most views | `MeterVault__Currency` everywhere; a tariff in another currency is reported as not fitting, never converted (D-43) |
|
||||||
|
| Continuous aggregates | Refreshed hourly, read by nothing | Dropped; rollup tables are written with each recompute (D-12, D-17) |
|
||||||
|
| Seed | — | Adds the water price of 7.00 €/m³ from 2026-01-01, and stores Summe Solar's formula (`m4 + m5`, generation, not costed). This affects new seeds; existing seeded instances get the formula through the startup conversion (D-44) |
|
||||||
|
|
||||||
|
### REST API
|
||||||
|
|
||||||
|
Every existing field keeps its name and type. What changed is only added as new fields. The numbers follow the new
|
||||||
|
engine, as listed above: actuals stop at now, virtual meters are evaluated, and costs are the bill's.
|
||||||
|
|
||||||
|
- **`GET /api/v1/consumption`:**
|
||||||
|
- New fields: `status`, `issue`, `kind`, `unit`.
|
||||||
|
- A month without data is left out instead of reported as 0.
|
||||||
|
- `from`/`to` with a UTC offset are accepted; they used to fail with a server error.
|
||||||
|
- Virtual meters return evaluated values.
|
||||||
|
- **`GET /api/v1/cost`:**
|
||||||
|
- New fields: `costStatus` (`Priced`, `Partial`, `NotPriced`, `PriceGap`, `UnitMismatch`), `costAvailability` (the
|
||||||
|
state of the quantities behind the cost, e.g. `Unresolved`, `Invalid`), `costRule`, `notCosted`, `missingPrices[]`
|
||||||
|
(component, reason, scope, first and last month, tariff, unit issue, credit), `status`, `issue`, `kind`, `unit`.
|
||||||
|
- `cost` stays numeric and is 0 when nothing could be priced; check `costStatus` and `costAvailability` before
|
||||||
|
trusting a 0.
|
||||||
|
- **Behaviour change:** generation and runtime meters, indicators and calculations that cannot be evaluated now
|
||||||
|
report `costStatus: NotPriced`, with `notCosted` giving the reason. They used to report `Priced`, and a generation
|
||||||
|
meter could carry a negative feed-in cost (A-21).
|
||||||
|
- **`GET /api/v1/dashboard/summary`:**
|
||||||
|
- New fields: `deltaPercentApplicable` per KPI, and `latestMonth` `{period, basis}`.
|
||||||
|
- The month and year windows are unchanged (the calendar month and year to now, against the whole previous ones),
|
||||||
|
but the values are the new bill. `deltaPercent` is 0 when not applicable.
|
||||||
|
|
||||||
|
### Known limitations
|
||||||
|
|
||||||
|
- **Raw retention is not enforced** (D-57). `MeterVault__RawRetentionDays` is shown but nothing deletes readings,
|
||||||
|
because every recompute rebuilds a meter from its readings.
|
||||||
|
- **Monthly data is never interpolated to days** (D-57). A day or week view of monthly data says "only coarser data"
|
||||||
|
and offers the monthly view.
|
||||||
|
- **Bonus, Discount and Tax tariffs are stored but not applied** (D-57).
|
||||||
|
- **Every live reading recomputes its meter in full** (D-57). That is fine for monthly and daily meters, but costs
|
||||||
|
about 1.2 s per reading for a meter with a year of hourly data, and grows with history.
|
||||||
|
- **Months cannot switch billing basis:** the billing basis (grid meter or household use) is chosen per energy type
|
||||||
|
for all time (A-17).
|
||||||
|
- **Batteries are not modelled.** Without a grid-export meter, Solar's feed-in is calculated, and labelled as such.
|
||||||
|
- **No CSV export on the Solar page**, because the export has no derived measures.
|
||||||
@@ -5,6 +5,13 @@
|
|||||||
> **What this is:** a self-hosted, local-first energy & utility metering platform that pulls meter data from Home Assistant, Tasmota and MQTT on a schedule, stores every reading with a timestamp, and turns it into cost dashboards. Not limited to electricity/water/oil — energy types are user-defined.
|
> **What this is:** a self-hosted, local-first energy & utility metering platform that pulls meter data from Home Assistant, Tasmota and MQTT on a schedule, stores every reading with a timestamp, and turns it into cost dashboards. Not limited to electricity/water/oil — energy types are user-defined.
|
||||||
>
|
>
|
||||||
> **Status:** design spec, pre-code. This document doubles as the build brief for Claude Code.
|
> **Status:** design spec, pre-code. This document doubles as the build brief for Claude Code.
|
||||||
|
>
|
||||||
|
> **Implementation status (0.4.0):** M0–M7 are implemented, and the dashboard/analysis rework
|
||||||
|
> ([`DASHBOARD_ANALYSIS_CHANGE_BRIEF.md`](DASHBOARD_ANALYSIS_CHANGE_BRIEF.md)) replaced the aggregation, virtual-meter,
|
||||||
|
> cost and page model. Its decisions are numbered D-01 – D-58 and A-01 – A-39 in
|
||||||
|
> [`ANALYSIS_IMPLEMENTATION_NOTE.md`](ANALYSIS_IMPLEMENTATION_NOTE.md). The original design text below is kept.
|
||||||
|
> Wherever the system now works differently, a **Deviation** or **Current behaviour** block says so in place (list:
|
||||||
|
> D-58). The results are in [`ANALYSIS_REPORT.md`](ANALYSIS_REPORT.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -104,6 +111,14 @@ Early rows (1997–2004) only carry **deliveries** (no burner hours — tracking
|
|||||||
| FR-18 | **Deploy** via Docker Compose (app + TimescaleDB); Unraid template; healthcheck endpoint; backup guidance. |
|
| FR-18 | **Deploy** via Docker Compose (app + TimescaleDB); Unraid template; healthcheck endpoint; backup guidance. |
|
||||||
| FR-19 | **Auth**: optional local accounts *and* reverse-proxy trust (honour `X-Forwarded-User` behind Authelia/Traefik). |
|
| FR-19 | **Auth**: optional local accounts *and* reverse-proxy trust (honour `X-Forwarded-User` behind Authelia/Traefik). |
|
||||||
|
|
||||||
|
> **Where the implementation differs (D-58):**
|
||||||
|
> - **FR-9:** prices are monthly (the 15th of each local month); there is no day-accurate proration mode. Bonus,
|
||||||
|
> discount and tax are stored but not applied (§7.5, D-57).
|
||||||
|
> - **FR-11:** rollups are by local day and month. Weeks and years are built from them, and there is no hourly level
|
||||||
|
> (§5.4).
|
||||||
|
> - **FR-12:** the Overview shows one selected period instead of fixed today/month/year cards (§8.1).
|
||||||
|
> - **FR-16:** raw retention is shown but not enforced (§5.5).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Architecture & tech stack
|
## 4. Architecture & tech stack
|
||||||
@@ -125,6 +140,10 @@ Early rows (1997–2004) only carry **deliveries** (no burner hours — tracking
|
|||||||
|
|
||||||
> The **domain model and DB schema are UI-agnostic.** If a future maintainer swaps Blazor for an SPA, everything from §5–§7 and the REST API in §9 is reusable.
|
> The **domain model and DB schema are UI-agnostic.** If a future maintainer swaps Blazor for an SPA, everything from §5–§7 and the REST API in §9 is reusable.
|
||||||
|
|
||||||
|
> **Deviation (D-17, D-58):** continuous aggregates are not used. They were dropped in favour of rollup tables that the
|
||||||
|
> recompute writes (§5.4). Dapper serves the hot-path reads of those tables (`AnalysisQueries`). No hosted service
|
||||||
|
> refreshes aggregates.
|
||||||
|
|
||||||
### 4.2 Components & data flow
|
### 4.2 Components & data flow
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -146,6 +165,13 @@ Early rows (1997–2004) only carry **deliveries** (no burner hours — tracking
|
|||||||
|
|
||||||
Ingestion writes **raw `reading`** rows. A normalization step derives **`consumption`** (append-only, base unit). Continuous aggregates roll consumption up to hourly/daily/monthly/yearly. The cost engine joins aggregates with time-ranged tariffs. The dashboard and API read aggregates + cost views (never scan raw for charts).
|
Ingestion writes **raw `reading`** rows. A normalization step derives **`consumption`** (append-only, base unit). Continuous aggregates roll consumption up to hourly/daily/monthly/yearly. The cost engine joins aggregates with time-ranged tariffs. The dashboard and API read aggregates + cost views (never scan raw for charts).
|
||||||
|
|
||||||
|
> **Current pipeline (D-12 – D-17, D-27, D-34):** the recompute derives `consumption`. In the same transaction it
|
||||||
|
> writes per-meter rollups by local day and local month, plus coverage runs, all in the configured zone (§5.4). One
|
||||||
|
> shared **analysis reader** (`AnalysisReader`) reads those rollups. It evaluates virtual meters on read (§7.4) and
|
||||||
|
> classifies per-type totals. One **cost engine** (`CostReader`) prices the bill from those quantities and the
|
||||||
|
> time-ranged tariffs (§7.5). The pages, the REST API and the CSV export read only these two readers. None of them
|
||||||
|
> scans `reading`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Data model & database
|
## 5. Data model & database
|
||||||
@@ -168,6 +194,18 @@ Design principles: raw readings are immutable audit truth; everything derived (c
|
|||||||
- `import_batch` — provenance + revert for CSV/manual bulk loads.
|
- `import_batch` — provenance + revert for CSV/manual bulk loads.
|
||||||
- `app_setting` — currency, locale, timezone, retention, fallbacks.
|
- `app_setting` — currency, locale, timezone, retention, fallbacks.
|
||||||
|
|
||||||
|
Added since this sketch (see §5.4 and the note, D-12):
|
||||||
|
|
||||||
|
- `meter_link` — directed flow topology (`from → to`: the downstream meter is a subsection of the upstream one;
|
||||||
|
several parents are allowed). It is topology only: it never defines a virtual meter's calculation (D-25).
|
||||||
|
- `consumption_rollup` / `consumption_rollup_month` — per-meter sums of `consumption` by local day and local month,
|
||||||
|
with per-provenance amounts, row count, flags and the latest interval end.
|
||||||
|
- `meter_coverage` — per-meter coverage runs with their resolution class.
|
||||||
|
- `meter_rollup_state` — the revision, zone, normalized unit and kind each meter's analysis rows were built with.
|
||||||
|
|
||||||
|
> **Deviation (D-57):** the principle "long-horizon retention is served by aggregates, not by keeping every raw row
|
||||||
|
> forever" is not realised. Raw readings are kept indefinitely (§5.5).
|
||||||
|
|
||||||
### 5.2 Measurement modes (`meter.mode`)
|
### 5.2 Measurement modes (`meter.mode`)
|
||||||
|
|
||||||
| Mode | Meaning | Consumption derived by |
|
| Mode | Meaning | Consumption derived by |
|
||||||
@@ -183,6 +221,10 @@ Design principles: raw readings are immutable audit truth; everything derived (c
|
|||||||
### 5.3 Schema sketch (PostgreSQL + TimescaleDB)
|
### 5.3 Schema sketch (PostgreSQL + TimescaleDB)
|
||||||
|
|
||||||
> Illustrative DDL; EF Core migrations own the relational tables, and **raw-SQL migrations** own the Timescale-specific DDL (`create_hypertable`, compression, continuous aggregates, retention). Timescale objects are *not* expressible through EF's model builder.
|
> Illustrative DDL; EF Core migrations own the relational tables, and **raw-SQL migrations** own the Timescale-specific DDL (`create_hypertable`, compression, continuous aggregates, retention). Timescale objects are *not* expressible through EF's model builder.
|
||||||
|
>
|
||||||
|
> *Current:* the Timescale DDL in use is the two hypertables (`reading`, `consumption`) and raw compression. The
|
||||||
|
> continuous aggregates were dropped by the `AnalysisRollups` migration (D-17), and no retention policy exists (D-57).
|
||||||
|
> The rollup, coverage and state tables are ordinary EF-owned tables with `ON DELETE CASCADE` to `meter` (D-12).
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
CREATE TABLE energy_type (
|
CREATE TABLE energy_type (
|
||||||
@@ -353,6 +395,48 @@ CREATE TABLE app_setting (
|
|||||||
|
|
||||||
### 5.4 Continuous aggregates & cost view
|
### 5.4 Continuous aggregates & cost view
|
||||||
|
|
||||||
|
> **Deviation (analysis rework, `docs/ANALYSIS_IMPLEMENTATION_NOTE.md` D-10 – D-17, D-36, D-58):** the continuous
|
||||||
|
> aggregates below were Berlin-only and materialized-only. They were never backfilled after historical imports, and
|
||||||
|
> no reader used them. The `AnalysisRollups` migration dropped them together with their refresh policies. The SQL
|
||||||
|
> below is kept as the original design. What replaced them:
|
||||||
|
>
|
||||||
|
> - **Rollup tables** (plain tables, FK to `meter` with `ON DELETE CASCADE`), all keyed by local dates of the
|
||||||
|
> **configured** zone:
|
||||||
|
>
|
||||||
|
> | Table | Key | Holds |
|
||||||
|
> |---|---|---|
|
||||||
|
> | `consumption_rollup` | `(meter_id, day, kind)` | amount; measured/manual/imported/estimated shares; row count; flags (baseline delta, divided); latest interval end |
|
||||||
|
> | `consumption_rollup_month` | `(meter_id, month, kind)` | the same per local month (read for month and year buckets) |
|
||||||
|
> | `meter_coverage` | `(meter_id, span_from)` | coverage runs: span, resolution class (≤ 1 h, ≤ 1 day, ≤ 7 days, ≤ 1 month, coarser), whether divided at months, gap reason, last interval start |
|
||||||
|
> | `meter_rollup_state` | `(meter_id)` | normalization revision, zone, normalized unit and kind, build time |
|
||||||
|
>
|
||||||
|
> - **Written by the recompute, by diff, in its transaction.** `NormalizationService.RecomputeMeterAsync` stages them
|
||||||
|
> through `AnalysisDataWriter` in the same transaction as the meter's `consumption`. Only changed rows are touched.
|
||||||
|
> Import, revert, events, manual readings, live ingestion and meter edits all reach the next read without a refresh
|
||||||
|
> job. No cache sits in between.
|
||||||
|
> - **Intervals.** Every normalized row carries its source interval (EF-ignored, D-10), so coverage is captured while
|
||||||
|
> normalizing instead of being guessed from sums. A non-label row that ends exactly on a local midnight is stamped one
|
||||||
|
> second earlier, inside the day it closes (D-11).
|
||||||
|
> - **Bucket status** (D-14) comes from coverage, never from the amount:
|
||||||
|
> - `Available`: covered. A zero is a true zero.
|
||||||
|
> - `Partial`: only partly covered.
|
||||||
|
> - `Missing`: nothing covers the bucket.
|
||||||
|
> - `Unresolved`: covered only by an undivided interval that crosses the bucket edge, e.g. monthly data asked by day.
|
||||||
|
> - `Invalid`: a calculation failed (virtual meters, §7.4).
|
||||||
|
> - `Pending`: the rollups are being rebuilt.
|
||||||
|
> A to-date read caps coverage at "now". Rows whose interval closes after now are reported, never counted (D-04,
|
||||||
|
> A-04, A-05, A-14, A-20).
|
||||||
|
> - **Reads.** Month and year buckets read the month table. Days and weeks read the day table. At most two partial
|
||||||
|
> edge days per range come straight from `consumption`. Every request makes one query per table, and the point and
|
||||||
|
> series limits are checked before any SQL runs (D-15).
|
||||||
|
> - **Rebuild.** Normalization revision 3 (D-16) rebuilds consumption, rollups and coverage for every meter at the
|
||||||
|
> next start (`NormalizationUpgrade`). It rebuilds again whenever the revision or the configured zone differs from
|
||||||
|
> `meter_rollup_state`. Until then a meter reads as "analysis being prepared" (`Pending`), never as "no data".
|
||||||
|
>
|
||||||
|
> **Cost is not a view either.** The cost engine (`CostReader`, §7.5) prices each bucket month by month, at the price
|
||||||
|
> valid on the 15th of each local month (D-36), so the bucket size never changes a total. There is no day-accurate
|
||||||
|
> proration mode (§14.3). A price change inside a reading interval longer than a month is reported, not guessed (A-16).
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
-- Daily normalized consumption per meter (local-tz buckets)
|
-- Daily normalized consumption per meter (local-tz buckets)
|
||||||
CREATE MATERIALIZED VIEW consumption_daily
|
CREATE MATERIALIZED VIEW consumption_daily
|
||||||
@@ -390,6 +474,20 @@ Normalized/rolled-up volumes are tiny regardless: daily consumption = 1000 × 36
|
|||||||
|
|
||||||
> Design conclusion: 1000 meters × 50 years is comfortably within TimescaleDB on modest hardware **provided** dashboards read aggregates and raw retention is bounded. Don't let the UI scan `reading` for charts.
|
> Design conclusion: 1000 meters × 50 years is comfortably within TimescaleDB on modest hardware **provided** dashboards read aggregates and raw retention is bounded. Don't let the UI scan `reading` for charts.
|
||||||
|
|
||||||
|
> **Deviation (D-57, a documented limitation):** raw retention is **not enforced**. `MeterVault__RawRetentionDays`
|
||||||
|
> (default 1095) is shown, but nothing deletes readings. Every recompute rebuilds a meter's consumption from the
|
||||||
|
> readings that remain, so dropping old readings would destroy analytical history. Bounded raw retention first needs a
|
||||||
|
> recompute that can start from stored consumption. `/admin/settings` shows "Not enforced" with that reason, and the
|
||||||
|
> meter page's Readings tab explains it.
|
||||||
|
>
|
||||||
|
> **History reads the rollups** (§5.4 deviation): `consumption_rollup_month` for month/year buckets and
|
||||||
|
> `consumption_rollup` for day/week buckets. `consumption` itself is read only for at most two partial edge days per
|
||||||
|
> range and by the meter page's Normalized data tab. No chart reads `reading`. It is read by the paged Readings tab
|
||||||
|
> (100 rows per page, keyset-ordered, date-filtered, D-50), by the manual-entry checks, and for freshness: the latest 20
|
||||||
|
> reading times per meter (D-18). That freshness query has no time bound yet (§13, report). `consumption` and the
|
||||||
|
> rollups are the analytical history. Performance was measured against a synthetic 1,000-meter × 10-year dataset
|
||||||
|
> (`docs/ANALYSIS_REPORT.md`, §13).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Ingestion
|
## 6. Ingestion
|
||||||
@@ -413,7 +511,14 @@ Normalized/rolled-up volumes are tiny regardless: daily consumption = 1000 × 36
|
|||||||
- Ship the four reference CSVs as built-in example imports and as test fixtures.
|
- Ship the four reference CSVs as built-in example imports and as test fixtures.
|
||||||
|
|
||||||
### 6.4 Secrets
|
### 6.4 Secrets
|
||||||
Tokens/passwords are **never** stored in plaintext in the DB. `ingestion_endpoint.config` holds a *reference* (env var name / Docker secret path); the app resolves at runtime. Document this clearly.
|
Tokens/passwords are **never** stored in plaintext in the DB. Two storage forms satisfy this, chosen per connector in the admin UI:
|
||||||
|
|
||||||
|
- **By reference** — `ingestion_endpoint.config` names an env var / Docker secret path (`token_env`, `password_env`); the app resolves it at runtime.
|
||||||
|
- **Encrypted at rest** — the operator types the secret into the connector dialog and it is stored encrypted (`token_enc`, `password_enc`) under the ASP.NET Core data-protection key ring.
|
||||||
|
|
||||||
|
Exactly one form survives a save; switching clears the other. The encrypted form exists because reference-only forced a file edit plus a service restart to add a connector, which in practice led to tokens being pasted into the env-var *name* field. It keeps the guarantee that matters — a `pg_dump` or JSON export carries nothing usable — but note the trust boundary: the key ring is on disk, so it protects against leaked database content, not against an attacker who already has the host. That is the same boundary as an env var, which is equally readable from `/proc`.
|
||||||
|
|
||||||
|
The key ring must be persisted outside the app directory (`MeterVault__DataProtectionKeyPath`, default `/var/lib/metervault/keys`), or a redeploy that replaces the content root will orphan every stored secret. MQTT *usernames* are not secrets and are stored as-is. JSON exports drop `*_enc` values: they are bound to the originating key ring and so are useless where an export would be restored — expect to re-enter secrets after a restore.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -422,46 +527,391 @@ Tokens/passwords are **never** stored in plaintext in the DB. `ingestion_endpoin
|
|||||||
### 7.1 Register → consumption
|
### 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).
|
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).
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
> **Current (normalization revision 3, D-10, D-11, D-16):**
|
||||||
|
> - Every normalized row carries its source interval, so coverage and resolution can be recorded (§5.4).
|
||||||
|
> - A reading that closes exactly at a local midnight is booked in the day it closes, not the next one.
|
||||||
|
> - The startup rebuild also writes the rollup and coverage tables, and records each meter's `meter_rollup_state`.
|
||||||
|
> Virtual meters store no consumption: the migration purged their rows, and a meter that becomes virtual is purged
|
||||||
|
> on its next recompute.
|
||||||
|
> - A meter whose oldest consumption predates its oldest reading or event is skipped and logged, so its history is not
|
||||||
|
> truncated.
|
||||||
|
> - Month division still applies only to cumulative and generation counters. Tank, runtime, direct-delta and
|
||||||
|
> instant-rate intervals are booked whole. Where such an interval crosses a month edge, the months on either side are
|
||||||
|
> `Unresolved` and only coarser buckets are resolved (D-14, A-16).
|
||||||
|
|
||||||
### 7.2 Runtime → consumption (burner)
|
### 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.
|
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.
|
||||||
|
|
||||||
### 7.3 Consumable/tank balance & forecast
|
### 7.3 Consumable/tank balance & forecast
|
||||||
`balance(t) = Σ deliveries(≤t) − Σ consumption(≤t)`, reconciled to physical `tank_level` events when present (cm → litres via calibration). Forecast to empty from a trailing consumption rate (e.g. last-30-day L/day) → `Vorraussichtliches Ende`. Surface low/reorder thresholds.
|
`balance(t) = Σ deliveries(≤t) − Σ consumption(≤t)`, reconciled to physical `tank_level` events when present (cm → litres via calibration). Forecast to empty from a trailing consumption rate (e.g. last-30-day L/day) → `Vorraussichtliches Ende`. Surface low/reorder thresholds.
|
||||||
|
|
||||||
|
> **Current (D-54, `TankLevels`):** the last dipstick ("Last dipstick: value on date") is kept apart from "Estimated
|
||||||
|
> now (incl. deliveries since)", which does not deduct use since the dipstick. For a period that ended before now,
|
||||||
|
> the Consumables page shows the contents at the period's end, never today's balance. The forecast is a labelled
|
||||||
|
> projection. It is hidden when the dipstick is older than 60 days, when too little time has passed, or when no use
|
||||||
|
> was measured. Deliveries before a tank's first level open a coverage gap (D-13).
|
||||||
|
|
||||||
### 7.4 Virtual meters
|
### 7.4 Virtual meters
|
||||||
For `virtual`: evaluate `config.expression` (whitelisted, sandboxed — a small safe expression evaluator over referenced meters' consumption/generation series), e.g. `self_consumption = generation − grid_feed_in`, `savings = self_consumption * unit_price`. Persist results to `consumption` (or compute on read — decide per §14). This is how PV self-consumption/savings and net figures are modelled without hardcoding.
|
For `virtual`: evaluate `config.expression` (whitelisted, sandboxed — a small safe expression evaluator over referenced meters' consumption/generation series), e.g. `self_consumption = generation − grid_feed_in`, `savings = self_consumption * unit_price`. Persist results to `consumption` (or compute on read — decide per §14). This is how PV self-consumption/savings and net figures are modelled without hardcoding.
|
||||||
|
|
||||||
|
> **Deviation (D-25 – D-33, D-39, A-08, A-12, A-15, D-58):**
|
||||||
|
>
|
||||||
|
> - **Canonical definition in `meter.meta`:** `expression`, `referencedMeterIds` (always derived from the expression
|
||||||
|
> and rewritten on save), `resultKind`, `resultUnit` and `costRule`. The result kind is one of `consumption`,
|
||||||
|
> `generation`, `net` or `indicator`. Save writes the effective (inferred) kind, unit and cost rule, so readers never
|
||||||
|
> re-infer them (A-08). Topology links (`meter_link`) never define or change a calculation. `meter_source` rows of
|
||||||
|
> type `virtual` are not used for this.
|
||||||
|
> - **Formula:**
|
||||||
|
> - Grammar: `+ − * /`, parentheses and numbers, parsed to an AST (`FormulaParser`), at most 2,000 characters and
|
||||||
|
> nesting depth 64.
|
||||||
|
> - References are `m<id>`. Any other identifier is an error; it is never read as 0.
|
||||||
|
> - The old string evaluator and `VirtualNormalizer` were removed.
|
||||||
|
> - **Validation** (`VirtualValidator`), on save and again on read for legacy data, covers:
|
||||||
|
> - syntax;
|
||||||
|
> - unknown or self references;
|
||||||
|
> - loops through nested virtual meters, reported with their path;
|
||||||
|
> - kinds and units: `+`/`−` need the same unit and kind or a declared `net`; meter × or ÷ meter needs a declared
|
||||||
|
> unit and kind `indicator`. Indicators are non-additive, never totalled and never costed.
|
||||||
|
> - **Evaluated on read, never materialized** (`VirtualEvaluator`, through `AnalysisReader`):
|
||||||
|
> - Evaluation runs per bucket from the sources' rollups, in dependency order. Each physical source is read once,
|
||||||
|
> however deeply it is nested.
|
||||||
|
> - Coverage is the intersection of the sources' coverage, and the resolution is the coarsest among them.
|
||||||
|
> - Strict: a missing source bucket makes the result missing and names the source. An observed zero is a valid input.
|
||||||
|
> - A non-finite result (division by zero) is `Invalid` with the reason. So is a loop, which is reported with its
|
||||||
|
> dependency path.
|
||||||
|
> - A period total is the formula over the sources' totals across their joint coverage. For a linear formula without
|
||||||
|
> a constant this equals the sum of its buckets; any other formula (for example a ratio) is marked non-additive,
|
||||||
|
> and its total is the ratio of totals.
|
||||||
|
> - The result carries every source's series (the "source contributions" on the meter page).
|
||||||
|
> - Nothing is written to `consumption` for a virtual meter. This decides §14.1.
|
||||||
|
> - **Totals:** a virtual meter is an *analysis view* by default and is never added on top of the meters it reads.
|
||||||
|
> The meter's `totals` override (`auto|always|never`) can make it replace its sources in its type's totals and in the
|
||||||
|
> bill (D-23).
|
||||||
|
> - **Legacy definitions (D-28):** at startup (`VirtualDefinitionUpgrade`), an expression-less virtual meter whose
|
||||||
|
> same-type incoming links name sources of one unit and kind gets the equivalent explicit sum stored. The run is
|
||||||
|
> idempotent and logged. Anything ambiguous is flagged "needs configuration" and is never guessed. The seed writes
|
||||||
|
> Summe Solar as `m4 + m5`, generation, kWh, cost rule `none`.
|
||||||
|
> - **Prices are not part of expressions** (`savings = self_consumption * unit_price` is not supported). A virtual
|
||||||
|
> meter's cost follows its cost rule (§7.5): `sourceCosts` (pure sums: the sources' own metered costs), `ownQuantity`
|
||||||
|
> (linear formulas: the evaluated quantity at its unit price) or `none`. PV savings are computed by the Solar page
|
||||||
|
> (self-consumption × the grid unit price, month by month) or through `ownQuantity`.
|
||||||
|
> - Export/import carries `meter_link` and remaps meter ids inside definitions (D-32). Deleting a meter names the
|
||||||
|
> virtual meters that read it and asks for confirmation (D-33).
|
||||||
|
|
||||||
### 7.5 Cost
|
### 7.5 Cost
|
||||||
`cost(bucket) = Σ(consumption_amount × active_unit_price) + base_price(prorated) − feed_in_credit − bonus`. Prices resolved by date from `tariff` (time-ranged). Currency from `app_setting`. Provide monthly-price and day-accurate-proration modes (§5.4). Categories roll costs up per `cost_category`; add `manual_cost` for meter-less categories (pool).
|
`cost(bucket) = Σ(consumption_amount × active_unit_price) + base_price(prorated) − feed_in_credit − bonus`. Prices resolved by date from `tariff` (time-ranged). Currency from `app_setting`. Provide monthly-price and day-accurate-proration modes (§5.4). Categories roll costs up per `cost_category`; add `manual_cost` for meter-less categories (pool).
|
||||||
|
|
||||||
|
> **Deviation: the bill (D-22, D-34 – D-43, A-15 – A-19, A-21 – A-22, A-26 – A-27).** One cost engine (`CostReader` →
|
||||||
|
> `BillRun` → the Core `CostCalculator`) prices the portfolio, an energy type, a meter or a category for one resolved
|
||||||
|
> period. Every page, the REST API and the CSV export use it. Before the rework, costs summed every meter; this is
|
||||||
|
> what the engine does now:
|
||||||
|
>
|
||||||
|
> - **What is billed (D-22, D-34):**
|
||||||
|
> - Per energy type, the `grid_import` meters are billed if the type has one. Otherwise its *use* meters are billed:
|
||||||
|
> the `total_load` meter, or else the consumption roots of the topology.
|
||||||
|
> - Generation meters are never billed.
|
||||||
|
> - The feed-in credit is the FeedIn price × the export of `grid_export` meters.
|
||||||
|
> - Runtime meters and virtual views are not billed.
|
||||||
|
> - Submeters (topology children) are breakdowns, never added. So the seeded Strom bill is Zähler Netz × price, as
|
||||||
|
> the sheet's `Kosten` is.
|
||||||
|
> - **Separately billed subsections (D-35, A-19):**
|
||||||
|
> - A containment child with its own meter-scoped unit price is billed at that price, and its quantity is taken out
|
||||||
|
> of its billed ancestor.
|
||||||
|
> - The same applies to a consumer linked directly below a billed grid meter.
|
||||||
|
> - Quantity totals do not change.
|
||||||
|
> - **Prices (D-36):**
|
||||||
|
> - The spreadsheet's monthly convention is kept: the price valid on the **15th of each local month**.
|
||||||
|
> - Every bucket is cut into its local months and each part priced at its month's price, so week, month and year
|
||||||
|
> buckets, and the period total, agree.
|
||||||
|
> - There is no day-accurate proration (§14.3).
|
||||||
|
> - A reading interval longer than a month (a tank dipped every few months, quarterly burner hours) leaves its months
|
||||||
|
> unknown. A longer bucket over such months is priced as a whole when every month in it has the same price;
|
||||||
|
> otherwise it is unavailable, with an attention item (A-16).
|
||||||
|
> - **Tariff applicability (D-37):**
|
||||||
|
> - A UnitPrice or FeedIn tariff applies only when its unit's denominator matches the meter's normalized unit.
|
||||||
|
> Known scales are converted (ct, per 100 L, per MWh).
|
||||||
|
> - A parsed unit or currency mismatch makes the cost "unavailable (unit)", and the explanation names what does not
|
||||||
|
> fit (A-28). An unparseable unit applies, with a warning.
|
||||||
|
> - BasePrice units are per day, per month (the default) or per year.
|
||||||
|
> - The tariff editor checks units on save. It states that **Bonus, Discount and Tax are stored but not applied**
|
||||||
|
> (D-57).
|
||||||
|
> - **Not priced vs price gap vs zero (D-38, A-26, A-27):**
|
||||||
|
> - A billed scope with no UnitPrice tariff at any date is **not priced (no tariff)**. That is an attention item, not
|
||||||
|
> a partial total.
|
||||||
|
> - A hole in a priced scope's tariff history makes those months a **price gap** (cost unavailable).
|
||||||
|
> - An explicit zero tariff is a **valid zero**. The tariff editor refuses to save a new tariff without a value, so a
|
||||||
|
> deep link cannot create a free period by accident.
|
||||||
|
> - A missing FeedIn price is reported only where a `grid_export` meter exists.
|
||||||
|
> - Months with no grid meter in service, while use was measured, are unavailable rather than free (A-17).
|
||||||
|
> - A bucket with nothing booked reads "No data", never "Priced" (A-26).
|
||||||
|
> - The quantity analysis stays visible whenever the cost is unavailable.
|
||||||
|
> - **Standing charges (D-40, A-18):**
|
||||||
|
> - A standing charge accrues per local day, at value ÷ days in that local month, over the scope's service period
|
||||||
|
> (install or first data to retirement or now, regardless of reading gaps).
|
||||||
|
> - Type- and global-scoped charges are their own rows, **once per scope**, never copied onto each meter.
|
||||||
|
> - Meter-scoped fees stay on their meter. A fee on a meter that no bill line prices is its own row.
|
||||||
|
> - **Manual costs (D-41):**
|
||||||
|
> - A manual cost is booked in full, **once**, on its `PeriodStart` local day when that day is in the period and not
|
||||||
|
> after today. `PeriodEnd` is informational.
|
||||||
|
> - They count in the Overview, the Analysis page, categories and the export alike.
|
||||||
|
> - **Categories (D-42, A-22):**
|
||||||
|
> - A category's cost is the priced **non-overlapping cover** of its members, plus its manual costs.
|
||||||
|
> - The disjoint categories, *Uncategorized* and the standing-charge rows form the **composition**, which reconciles
|
||||||
|
> to the bill.
|
||||||
|
> - A category that overlaps another, or covers meters outside the bill, is an **overlapping view**. It is listed
|
||||||
|
> apart, never summed, and never drawn in the donut. The donut is drawn only when every slice is ≥ 0; otherwise
|
||||||
|
> signed bars are used.
|
||||||
|
> - A category whose members price nothing (calculated views, generation, runtime) says so rather than "no data".
|
||||||
|
> - **Virtual meters (D-39, A-15):** they are costed by their named rule:
|
||||||
|
> - `sourceCosts` (pure sums only): each physical source's own metered cost, once, with no scope-level standing
|
||||||
|
> charges.
|
||||||
|
> - `ownQuantity` (linear formulas without a constant): the evaluated quantity at its unit price.
|
||||||
|
> - `none`: every other formula, and generation sums (generation is never billed), which is why Summe Solar is not
|
||||||
|
> costed.
|
||||||
|
> - The rule is named next to every virtual cost. A virtual meter enters the bill only through the `always`
|
||||||
|
> override, and then replaces its sources.
|
||||||
|
> - The REST API reports a meter without a cost rule as `NotPriced` with the reason (A-21).
|
||||||
|
> - **Currency (D-43):** the configured `MeterVault__Currency` everywhere, through `Format.Money` / `InstanceCurrency`.
|
||||||
|
> A tariff in another currency is a unit mismatch; no conversion is made.
|
||||||
|
> - **Changes (D-07, A-23):** a cost change is stated only between complete figures, by one rule on every page
|
||||||
|
> (`OverviewComparison.Between`). If both totals are complete, it compares the totals. Otherwise it compares only the
|
||||||
|
> paired buckets that are complete on both sides, with the caption "over the part both periods cover".
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. Dashboard & UX
|
## 8. Dashboard & UX
|
||||||
|
|
||||||
|
> **Deviation (analysis rework, brief §3–§8, D-46 – D-55, D-58):** the panels below were rebuilt as one set of pages
|
||||||
|
> that share one period contract, one reader pair and one set of components. §8.0 describes what they share; the
|
||||||
|
> notes under §8.1 – §8.7 say what each page now is.
|
||||||
|
|
||||||
|
### 8.0 Shared analysis contract (current)
|
||||||
|
|
||||||
|
**Pages and navigation (D-47, D-48):**
|
||||||
|
|
||||||
|
| Sidebar entry | Route | What it is |
|
||||||
|
|---|---|---|
|
||||||
|
| Overview | `/` | One selected period (default month to date): the cost with its composition, a card per energy type (quantities in their own units, cost with billing basis, change, freshness), the history chart, "What changed", attention items |
|
||||||
|
| Analysis | `/trends` (route kept) | Explore any scope: `portfolio`, energy `type`, cost `category`, one `meter`, or up to six `meters` side by side, by quantity or cost |
|
||||||
|
| Meters | `/meters` | All meters with period values, status, resolution, "data up to", how each counts, quick entry, filter by type |
|
||||||
|
| Energy types → *type* | `/energy/{id}` | Tabs `overview`, `history` (total or `view=meters`), `flow` (Sankey + table + Manage connections), `meters` |
|
||||||
|
| Specialized views | `/solar`, `/consumables` | Always listed; each shows a setup state when unsupported (no generation meter, no tank) |
|
||||||
|
| Data import | `/import`, `/import/wizard` | Import batches with revert; the CSV mapping wizard |
|
||||||
|
| Configuration | `/admin/*` | Energy type *definitions*, tariffs, cost categories, connectors, settings |
|
||||||
|
|
||||||
|
- The meter page `/meters/{id}` is the per-meter hub. Its tabs are `analysis`, `readings`, `normalized`, `events`,
|
||||||
|
`tariffs`, `sources`. A virtual meter shows `analysis`, `events`, `tariffs`, `calculation`: no Readings or
|
||||||
|
Normalized data, and Calculation instead of Sources.
|
||||||
|
- Old links still work: `tab=consumption` opens `normalized`, and on a virtual meter `sources` opens `calculation` and
|
||||||
|
`readings` opens `analysis`. Tabs are resolved by key and meter mode, never by index.
|
||||||
|
- One-shot `action=` links (reading, swap, reset, delivery, tank level, note, edit, source) open their dialog once and
|
||||||
|
are then dropped from the address.
|
||||||
|
- Breadcrumbs (Overview → type → meter) carry the period. Expanded sidebar groups persist in a cookie (`mv-nav`), and
|
||||||
|
the group of the current page is always open. If the energy types cannot be loaded, the nav shows an error with
|
||||||
|
Retry instead of dropping the group.
|
||||||
|
- The app-bar "Find a meter" (a text button from the md breakpoint, an icon below) opens a meter's Analysis tab with
|
||||||
|
the current period, next to the quick-entry action.
|
||||||
|
|
||||||
|
**Period and URL state (D-01 – D-08, D-46, A-13, `AnalysisQuery`):**
|
||||||
|
|
||||||
|
| Key | Values |
|
||||||
|
|---|---|
|
||||||
|
| `scope`, `id`, `ids` | `portfolio`, `type`, `category`, `meter`, `meters` (`ids`, at most 6) |
|
||||||
|
| `metric` | `consumption`, `generation`, `export`, `runtime`, `net`, `cost`, `balance` |
|
||||||
|
| `period` | `mtd`, `last-month`, `ytd`, `prev-year`, `12m`, `24m`, `all`, `custom` (with `from`/`to`, local, inclusive) |
|
||||||
|
| `bucket` | `auto`, `day`, `week` (Monday), `month`, `year` |
|
||||||
|
| `compare` | `none`, `prev-period`, `prev-year`, `year:YYYY` |
|
||||||
|
|
||||||
|
- The URL is the state: reload, share and Back reproduce the page. Defaults are never written: the Overview uses
|
||||||
|
`mtd`, history pages `12m` (12 calendar buckets ending with the current partial month), and every page compares with
|
||||||
|
the previous year (A-13).
|
||||||
|
- Toolbar and tab changes replace the history entry; drill-downs push. An invalid token falls back to the default
|
||||||
|
with a notice.
|
||||||
|
- `all` spans the scope's available data, never a fixed century.
|
||||||
|
- `auto` picks one bucket, at most 400 points per series. A finer explicit bucket is refused with a coarser
|
||||||
|
suggestion.
|
||||||
|
- A period resolves once per request against a captured "now" in the configured zone (§10). Quantities, costs,
|
||||||
|
comparisons and the export use the same half-open bounds.
|
||||||
|
- Comparisons shift in calendar units. The change figure is measured only over the range both periods cover, and both
|
||||||
|
exact ranges are shown (D-07). The percentage is "not applicable" for a zero or negative baseline; the absolute
|
||||||
|
difference is always shown (D-08). Colours depend on the metric: more generation is good, more consumption is not.
|
||||||
|
- Projections are separate, labelled with their method, and suppressed where coverage is insufficient (D-09).
|
||||||
|
- Pages load through a `LoadSequencer`, so a superseded load never overwrites a later one. Panels show a
|
||||||
|
loading/refresh/error-with-Retry state.
|
||||||
|
- Other page-specific keys: the energy page's `tab` and `view`, the Overview's `chart`, the record tabs' `from`/`to`.
|
||||||
|
|
||||||
|
**Missing vs zero vs not priced (brief §4.3, D-14, D-38, A-24 – A-28):**
|
||||||
|
- Every figure carries its bucket status (§5.4) and provenance (measured, manual, imported, estimated, derived,
|
||||||
|
opening balance), worded next to it (`FigureText`).
|
||||||
|
- A true zero is a number and a bar on the baseline.
|
||||||
|
- An unknown bucket is a gap in the chart, marked "–", and "—" with its reason in the table: no data, only coarser
|
||||||
|
data, cannot be calculated, being prepared.
|
||||||
|
- Qualified values (partial, estimated) are marked "*".
|
||||||
|
- A period without data says "No data for this period", names the available dates and offers "Go to latest data". A
|
||||||
|
future range says it has not started yet.
|
||||||
|
- A chart never says "no data" when the reason is a coarser resolution (it names the resolution and offers the
|
||||||
|
interval that shows it) or a missing price (it names the cost status).
|
||||||
|
- Drill-downs only go where finer data exists. A monthly bucket never opens 31 unknown days. A physical meter's finest
|
||||||
|
bucket opens its Normalized data. A virtual meter's opens its own analysis over that bucket, whose source list links
|
||||||
|
on to the sources' records.
|
||||||
|
- Missing prices, stale sources, invalid calculations, rows dated after now and possible overlaps become attention
|
||||||
|
items, each with one targeted action (D-53). For example, "Add tariff" opens
|
||||||
|
`/admin/tariffs?scope=&id=&component=&from=&action=new` prefilled with the first uncovered month (D-52).
|
||||||
|
|
||||||
|
**Shared components (`Components/Shared/Analysis`):** `PageHeader`, `AnalysisBreadcrumbs`, `PeriodToolbar`,
|
||||||
|
`AnalysisChart` (ApexCharts; one axis per unit; signed values around a real zero line; follows the light/dark theme
|
||||||
|
in the current circuit), `AnalysisTable` (the accessible equivalent of every chart), `MetricCard`, `ChangeChip`,
|
||||||
|
`ValueStatus`, `EmptyPeriodState`, `PendingState`, `PanelError`, `LoadPanel`, `ProjectionNote`, `ComparisonSummary`,
|
||||||
|
`AttentionList`, `SeriesContributions`.
|
||||||
|
|
||||||
|
**CSV export (D-55):** `GET /export/analysis.csv` takes the same URL keys as the pages; every toolbar has "Export CSV".
|
||||||
|
It writes one row per bucket and series, with these columns:
|
||||||
|
- `series_id`, `series_name`, `kind`, `unit`;
|
||||||
|
- `bucket_start`, `bucket_end` (local ISO with offset; the end is exclusive, and a to-date bucket ends at now),
|
||||||
|
`timezone`;
|
||||||
|
- `value` (invariant, full precision; empty when unknown), `status`, `provenance`;
|
||||||
|
- `cost`, `cost_status`, `currency`, `comparison_value`.
|
||||||
|
|
||||||
|
A bucket with nothing booked is `Missing`, never `Available`. Invalid requests (a notice, too many buckets, an unknown
|
||||||
|
scope) get a 400 with a plain-text reason. The endpoint is a UI endpoint, like the pages, and needs no API key.
|
||||||
|
|
||||||
### 8.1 Overview
|
### 8.1 Overview
|
||||||
- KPI cards: **Today**, **This month**, **This year** cost — each with Δ (absolute + %) vs the previous comparable period and an ↑/↓ indicator.
|
- KPI cards: **Today**, **This month**, **This year** cost — each with Δ (absolute + %) vs the previous comparable period and an ↑/↓ indicator.
|
||||||
- "Cost now" total across all categories.
|
- "Cost now" total across all categories.
|
||||||
|
|
||||||
|
> **Deviation (D-58, brief §7.1):** the Overview shows **one selected period** instead of Today/This month/This year
|
||||||
|
> cards. It has a period toolbar and defaults to month to date. The page shows:
|
||||||
|
>
|
||||||
|
> - the cost of the period, split into metered use, standing charges, manual costs and feed-in credit;
|
||||||
|
> - one card per energy type, with its measures in their own units (unlike quantities are never added), the cost with
|
||||||
|
> its billing basis, the change, freshness, and a link to that type with the same period;
|
||||||
|
> - the history chart with a metric selector (`chart=`), the previous-year overlay, a table toggle and drill-down;
|
||||||
|
> - "What changed", by category or by meter, with rows linking to the scoped Analysis page with the same dates;
|
||||||
|
> - the cost composition (§7.5): a donut only for non-negative disjoint slices, otherwise signed bars. Overlapping
|
||||||
|
> views are listed apart;
|
||||||
|
> - attention items, and "Latest month with data" with its month and basis (meters, manual costs or both, D-19).
|
||||||
|
>
|
||||||
|
> Changes are measured over the coverage both periods share (D-07). A period without data offers "Go to latest data";
|
||||||
|
> the page never switches to history on its own. Missing categories or tariffs are small setup notes, never a
|
||||||
|
> prerequisite for seeing quantities. The REST summary keeps its legacy month/year windows (D-45).
|
||||||
|
|
||||||
### 8.2 Cost breakdown / "what costs most"
|
### 8.2 Cost breakdown / "what costs most"
|
||||||
- Stacked bar or donut by `cost_category` for a selectable period; ranked list (most → least).
|
- Stacked bar or donut by `cost_category` for a selectable period; ranked list (most → least).
|
||||||
- **Difference view** (explicitly requested): a table answering *"what cost more, what cost less this time"* — per category **and** per meter, **this month vs last month** and **this year vs last year**, columns `now | previous | Δ | Δ% | ↑/↓`, sorted by absolute impact.
|
- **Difference view** (explicitly requested): a table answering *"what cost more, what cost less this time"* — per category **and** per meter, **this month vs last month** and **this year vs last year**, columns `now | previous | Δ | Δ% | ↑/↓`, sorted by absolute impact.
|
||||||
|
|
||||||
|
> **Current:** both live on the Overview for the selected period and its comparison, not for fixed month/year windows.
|
||||||
|
> The composition is §7.5's. "What changed" lists categories or meters with current, previous, change and percentage
|
||||||
|
> where applicable, sorted by impact, plus a bill total row. A change is shown only between complete figures (A-23).
|
||||||
|
|
||||||
### 8.3 Trends
|
### 8.3 Trends
|
||||||
- Consumption and cost over time; **granularity toggle** day/week/month/year; per-meter or per-category; **previous-year overlay**.
|
- Consumption and cost over time; **granularity toggle** day/week/month/year; per-meter or per-category; **previous-year overlay**.
|
||||||
|
|
||||||
|
> **Current:** this is the **Analysis** page (`/trends`, brief §7.4, nav "Analysis"). It offers:
|
||||||
|
>
|
||||||
|
> - Scope: all energy types, one energy type, a cost category, one meter, or a comparison of up to six meters. A
|
||||||
|
> seventh is refused with an explanation.
|
||||||
|
> - Metric: only what the scope supports. A category is always available by cost, and by quantity only when all its
|
||||||
|
> meters share one kind and unit; otherwise the page explains why and offers the alternatives.
|
||||||
|
> - The shared toolbar, with a calendar-year select (compare with any of the five years before), the chart (overlays
|
||||||
|
> up to three series; above that the comparison stays in the table), the table, drill-down and CSV export.
|
||||||
|
> - For per-type measures, total use and grid import side by side, never added. Portfolio cost is the same bill the
|
||||||
|
> Overview shows, with manual costs once.
|
||||||
|
|
||||||
### 8.4 PV / Solar panel
|
### 8.4 PV / Solar panel
|
||||||
- Generation, self-consumption, grid feed/draw, **savings (Ersparnis)**, **autarky %**, **self-consumption %**. Time-filtered.
|
- Generation, self-consumption, grid feed/draw, **savings (Ersparnis)**, **autarky %**, **self-consumption %**. Time-filtered.
|
||||||
|
|
||||||
|
> **Current (D-54, `SolarService`):** one section per energy type that has generation. Nothing is inferred from names:
|
||||||
|
> meters are found by mode and by the effective roles `total_load`, `grid_import`, `grid_export` (A-07).
|
||||||
|
>
|
||||||
|
> - **Generation** is the type's generation measure, so a virtual sum such as Summe Solar is listed as a view and never
|
||||||
|
> added twice.
|
||||||
|
> - **Self-consumption** is total load − grid import, or else generation − grid export.
|
||||||
|
> - **Feed-in** is the grid export meter, or else generation − self-consumption (labelled as calculated; batteries are
|
||||||
|
> not modelled).
|
||||||
|
> - **Site use** is the total load meter, or else self-consumption + grid import.
|
||||||
|
> - **Savings** are self-consumption × the grid unit price, month by month through the cost calculator.
|
||||||
|
> - Autarky % and self-consumption % are shown when the roles allow.
|
||||||
|
>
|
||||||
|
> Every figure shows its status and how it was obtained. Units come from the meters; mixed units give "cannot be
|
||||||
|
> calculated". A missing role gets a setup card with the candidate meters, which lead into the meter editor (no raw
|
||||||
|
> role tags). The page has no CSV export, because the export has no derived measures.
|
||||||
|
|
||||||
### 8.5 Oil / consumable panel
|
### 8.5 Oil / consumable panel
|
||||||
- Tank level (cm + L), balance vs capacity gauge, deliveries log, burner runtime, effective **L/h** (fixed/empirical), **forecast to empty**, monthly cost.
|
- Tank level (cm + L), balance vs capacity gauge, deliveries log, burner runtime, effective **L/h** (fixed/empirical), **forecast to empty**, monthly cost.
|
||||||
|
|
||||||
|
> **Current (D-54, `ConsumableService`):** "Now" and "Selected period" are separate parts.
|
||||||
|
>
|
||||||
|
> - **Now:** the last dipstick with its date (and cm reading), an estimate that includes the deliveries since then,
|
||||||
|
> the fill bar (only when the level is known), and the forecast as a labelled projection (§7.3).
|
||||||
|
> - **Selected period:** use from the analysis reader, the deliveries of the period only, burner runtime of the type's
|
||||||
|
> runtime meters, and the burn rate (fixed, or empirical when runtime is in hours).
|
||||||
|
> - A period that ended shows the contents at its end.
|
||||||
|
> - The cost stays unknown, never 0 €, when the tank has no tariff or its months cannot be placed (A-16).
|
||||||
|
|
||||||
### 8.6 Meter detail
|
### 8.6 Meter detail
|
||||||
- Raw readings, normalized consumption, source status (last-seen, last value), tariff timeline, events (swaps/deliveries/corrections), measured-vs-estimated markers.
|
- Raw readings, normalized consumption, source status (last-seen, last value), tariff timeline, events (swaps/deliveries/corrections), measured-vs-estimated markers.
|
||||||
|
|
||||||
|
> **Current (brief §7.2, D-47, D-50):** the header carries identity, the energy type, mode and retirement chips, and
|
||||||
|
> the actions: primary entry by mode (Add reading / Record tank level), the "Record event" menu and Edit. The tab bar
|
||||||
|
> sits directly below the header.
|
||||||
|
>
|
||||||
|
> - **Analysis** tab:
|
||||||
|
> - The quantity card in the normalized unit (D-20), with any projection shown separately inside it.
|
||||||
|
> - The cost card, with its rule named or "Not costed" plus the reason, and the cost change (A-23).
|
||||||
|
> - The previous-year overlay, a full chart, the table, drill-down and CSV export.
|
||||||
|
> - Events and tariff changes inside the range, listed as context under the chart.
|
||||||
|
> - A "Data quality and coverage" section: resolution, data range, freshness, opening balance with "Set install
|
||||||
|
> date", and rows recorded after now.
|
||||||
|
> - For virtual meters, the source contributions.
|
||||||
|
> - **Readings**, **Normalized data** and **Events** tabs:
|
||||||
|
> - Server-side paging, 100 rows per page, keyset-ordered, filtered by the page's `period`/`from`/`to`.
|
||||||
|
> - Their toolbar shows the whole range listed, and rows dated after now carry an "After now" mark (A-29).
|
||||||
|
> - The Readings tab explains that raw readings are the audit record and that raw retention is not enforced.
|
||||||
|
> - **Tariffs** lists meter, type and global tariffs with their effective end and the one that applies now, plus "Add
|
||||||
|
> tariff for this meter".
|
||||||
|
> - **Sources** links each source's connector to its editor (the connector detour keeps the typed draft).
|
||||||
|
> - A virtual meter's **Calculation** tab shows the status, the formula with meter names beside each `m<id>`, result
|
||||||
|
> kind and unit, cost rule, the meters read (also through nested calculations), and any problem with its dependency
|
||||||
|
> path.
|
||||||
|
> - The manual-entry dialog runs its own queries for the entered time, so its verdict never depends on a page of rows.
|
||||||
|
|
||||||
### 8.7 Admin / config
|
### 8.7 Admin / config
|
||||||
- CRUD for energy types, meters, sources, tariffs, cost categories, connectors; retention & locale/currency settings; import wizard; API keys.
|
- CRUD for energy types, meters, sources, tariffs, cost categories, connectors; retention & locale/currency settings; import wizard; API keys.
|
||||||
|
|
||||||
|
> **Current (D-21, D-23, D-31, D-37, D-52, A-27, A-30):**
|
||||||
|
>
|
||||||
|
> - **Meter editor** (shared `MeterEditor`):
|
||||||
|
> - Roles use friendly names and one-line meanings, and are offered only for compatible modes. Saving a role moves it
|
||||||
|
> and names the meter that held it.
|
||||||
|
> - The totals override (Automatic / Always / Never) states its meaning and "In the totals now: …". A conflicting
|
||||||
|
> "Always" is refused, naming the other meter.
|
||||||
|
> - A virtual meter gets the calculation editor: Sum, Difference or Formula mode; sources picked by name, with unit,
|
||||||
|
> kind and dates; only valid cost rules offered, each with its reason.
|
||||||
|
> - A live preview uses the page's period (every preset, custom dates, all history) and shows per-source values and
|
||||||
|
> the incomplete months.
|
||||||
|
> - Saving a Sum can bring the incoming links in line with its sources, but links never change a calculation.
|
||||||
|
> - **Tariffs:** the deep link opens a prefilled dialog once, scoped to what can price that meter or type. Units are
|
||||||
|
> checked live, a new tariff needs a value, and Bonus/Discount/Tax are marked "not applied".
|
||||||
|
> - **Energy types** under Configuration edit the definitions ("Energy type definitions"); analysis lives under the
|
||||||
|
> Energy types nav group.
|
||||||
|
> - **Settings** is read-only. It shows the zone, the currency, raw retention ("Not enforced"), the normalization
|
||||||
|
> revision and zone, how many meters have current analysis data or are waiting for a rebuild, and calculated meters
|
||||||
|
> by status.
|
||||||
|
> - Flow topology is edited from the energy type's Flow tab ("Manage connections"): cycle-, type- and
|
||||||
|
> duplicate-checked, and it never touches a stored formula. Physical meters can also set their upstream meters in
|
||||||
|
> the editor.
|
||||||
|
|
||||||
> **Legacy monthly history:** imported data is monthly-granular. Offer per-import choice: keep native monthly buckets, or **linearly interpolate to daily** (energietracker-style) so old and new data render on the same axes. Interpolated points are marked `quality = interpolated`.
|
> **Legacy monthly history:** imported data is monthly-granular. Offer per-import choice: keep native monthly buckets, or **linearly interpolate to daily** (energietracker-style) so old and new data render on the same axes. Interpolated points are marked `quality = interpolated`.
|
||||||
|
>
|
||||||
|
> **Current behaviour (D-14, D-57):** monthly data stays monthly and is never interpolated. A day or week bucket over
|
||||||
|
> it reads "only coarser data", the chart names the data's resolution and offers the interval that shows it, and a
|
||||||
|
> drill-down never opens days a monthly import cannot resolve (D-51).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -483,17 +933,54 @@ OpenAPI/Swagger published. API-key auth for automation endpoints; UI uses the se
|
|||||||
| `GET /api/v1/sources/status` | Connector/source health. |
|
| `GET /api/v1/sources/status` | Connector/source health. |
|
||||||
| `GET /healthz` | Liveness/readiness (for Gatus). |
|
| `GET /healthz` | Liveness/readiness (for Gatus). |
|
||||||
|
|
||||||
|
> **Current contract (D-45, A-16, A-21; pinned by `ApiContractTests`):** `/consumption`, `/cost` and
|
||||||
|
> `/dashboard/summary` keep every existing field, name and type. Their numbers now come from the analysis reader and
|
||||||
|
> the cost engine, so they match the pages. What changed is only added as new fields:
|
||||||
|
>
|
||||||
|
> | Endpoint | Behaviour | Added fields |
|
||||||
|
> |---|---|---|
|
||||||
|
> | `GET /api/v1/consumption?meter=&from=&to=` | Monthly rows as before. Instants with any offset are accepted (converted to UTC; an offset used to be a 500). Actuals stop at now. A month without data is absent, not a 0. A virtual meter is evaluated from its formula. | `status` (BucketStatus), `issue` (why a value is not plain), `kind`, `unit` (normalized) |
|
||||||
|
> | `GET /api/v1/cost?meter=&from=&to=` | `cost` stays numeric: 0 when nothing could be priced, with the reason beside it. The meter is priced by its rule (the bill line, a subsection at its unit price, a virtual meter by its cost rule). Generation and runtime meters, and meters that cannot be evaluated, are not costed. | `costStatus` (`Priced`, `Partial`, `NotPriced`, `PriceGap`, `UnitMismatch`), `costAvailability` (the status of the quantities behind the cost, e.g. `Unresolved`, `Invalid`), `costRule`, `notCosted` (the reason), `missingPrices[]` (component, reason, scope, first/last month, tariff, unit issue, credit), `status`, `issue`, `kind`, `unit` |
|
||||||
|
> | `GET /api/v1/dashboard/summary` | Keeps its legacy windows (calendar month and year to now against the whole previous ones) but prices the bill (§7.5). | `deltaPercentApplicable` per KPI (the percentage is 0 and not applicable for a zero or negative baseline), `latestMonth` `{period, basis}` |
|
||||||
|
>
|
||||||
|
> `/consumption` and `/cost` take one `meter` and answer by calendar month, as before the rework; the table's `scope`
|
||||||
|
> and `bucket` parameters are not implemented. The UI's analysis CSV (`GET /export/analysis.csv`, §8.0) is not part of
|
||||||
|
> `/api/v1` and needs no API key.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. Non-functional
|
## 10. Non-functional
|
||||||
|
|
||||||
- **Time & DST:** store UTC; bucket and display in the instance timezone (default `Europe/Berlin`). "Daily cost" boundaries are local-midnight — use `time_bucket(..., 'Europe/Berlin')`.
|
- **Time & DST:** store UTC; bucket and display in the instance timezone (default `Europe/Berlin`). "Daily cost" boundaries are local-midnight — use `time_bucket(..., 'Europe/Berlin')`.
|
||||||
|
*Deviation (D-01 – D-06, D-58):*
|
||||||
|
- **Zone:** the **configured** zone (`MeterVault__TimeZone`, normalized to its IANA id) is used everywhere:
|
||||||
|
normalization, rollups, readers, periods, the export. Berlin is never hard-coded, and a zone change rebuilds the
|
||||||
|
rollups.
|
||||||
|
- **Bounds:** a period resolves into a local inclusive date range for display and a **half-open** UTC range
|
||||||
|
`[from, to)` for queries. `to` is the local midnight after the end date, or the captured "now" for to-date
|
||||||
|
periods.
|
||||||
|
- **Calendar:** days start at local midnight, and a DST day really has 23 or 25 hours. Weeks start on Monday. Months
|
||||||
|
and years are local.
|
||||||
|
- **Clock:** "now" is read once per request from the registered `TimeProvider` (`InstanceClock` for pages).
|
||||||
|
Services never read the clock; tests freeze it.
|
||||||
|
- **After now:** rows whose interval ends after now are never counted as actuals; they are reported as "recorded
|
||||||
|
after now" (D-04).
|
||||||
- **Auth:** optional built-in local accounts; **reverse-proxy trust** mode honouring `X-Forwarded-User`/`Remote-User` behind Authelia/Traefik; API keys for machine access. Default: single admin user + one ingest API key.
|
- **Auth:** optional built-in local accounts; **reverse-proxy trust** mode honouring `X-Forwarded-User`/`Remote-User` behind Authelia/Traefik; API keys for machine access. Default: single admin user + one ingest API key.
|
||||||
- **Observability:** `/healthz`, structured logs (Serilog), optional Prometheus `/metrics`.
|
- **Observability:** `/healthz`, structured logs (Serilog), optional Prometheus `/metrics`.
|
||||||
- **Config:** environment variables + a settings UI; secrets via env/Docker secrets (never in DB plaintext).
|
- **Config:** environment variables + a settings UI; secrets via env/Docker secrets or encrypted at rest (never in DB plaintext) — see §6.4.
|
||||||
- **i18n:** `en` (default for OSS) + `de`; locale-aware number/currency/date. Ship a German locale that matches the source data conventions.
|
- **i18n:** `en` (default for OSS) + `de`; locale-aware number/currency/date. Ship a German locale that matches the source data conventions.
|
||||||
- **Backup:** document `pg_dump`/Timescale backup; provide a full **JSON export/import** for portability.
|
- **Backup:** document `pg_dump`/Timescale backup; provide a full **JSON export/import** for portability.
|
||||||
- **Performance:** dashboards read aggregates only; raw reads paginated and time-bounded.
|
- **Performance:** dashboards read aggregates only; raw reads paginated and time-bounded.
|
||||||
|
*Current:* dashboards read the rollup tables (§5.4). A request sends a constant handful of statements, whatever the
|
||||||
|
meter count. The point limit (400 per series) and the series limit (6 on a chart) are enforced before any SQL runs.
|
||||||
|
The brief's target is a 10-year monthly request for 100 meters in under 2 s; it was measured against a synthetic
|
||||||
|
1,000-meter × 10-year dataset (§13, `docs/ANALYSIS_REPORT.md`). Raw record tabs are paged (100 rows, keyset) and
|
||||||
|
date-filtered.
|
||||||
|
- **Accessibility & layout (brief §8):** every chart has a table equivalent; colour is never the only cue (words,
|
||||||
|
arrows, signed values, dashed overlays); visible focus rings; keyboard-reachable drill links; no page-wide overflow
|
||||||
|
at 360 px; charts follow the light/dark theme (cookie `mv-theme`) within the circuit. MudBlazor's own labels are
|
||||||
|
localized (`MeterVaultMudLocalizer`).
|
||||||
|
- **Currency:** `MeterVault__Currency` (default `EUR`) for every amount (D-43).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -515,6 +1002,18 @@ OpenAPI/Swagger published. API-key auth for automation endpoints; UI uses the se
|
|||||||
/docs architecture, setup, HA/Tasmota wiring, API, screenshots
|
/docs architecture, setup, HA/Tasmota wiring, API, screenshots
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> *Current layout:* the analysis rework added these folders.
|
||||||
|
> - `src/Core/Analysis`: pure rules for time and periods, coverage, rollups, quantities and units, totals and category
|
||||||
|
> cover, virtual formulas, and the cost calculator.
|
||||||
|
> - `src/Infrastructure/Analysis`: the reader, the catalog and the virtual upgrade.
|
||||||
|
> - `src/Infrastructure/Costing`: `CostReader` and `BillRun`.
|
||||||
|
> - `src/App/Analysis`: the URL contract and the chart, table and attention models.
|
||||||
|
> - `src/App/Components/Shared/Analysis`: the shared components.
|
||||||
|
> - Page folders under `src/App/Components/Pages`.
|
||||||
|
>
|
||||||
|
> The fixtures live in `sampledata/`. CI is Gitea Actions (`.gitea/workflows/`), publishing to the Gitea container
|
||||||
|
> registry. `CLAUDE.md` holds the maintained layout.
|
||||||
|
|
||||||
- **CI (GitHub Actions):** build → test (spin Timescale) → publish Docker image to **GHCR** (amd64; add arm64 if desired) on tag.
|
- **CI (GitHub Actions):** build → test (spin Timescale) → publish Docker image to **GHCR** (amd64; add arm64 if desired) on tag.
|
||||||
- **License:** pick before release — **MIT** (max adoption; matches energietracker/your prior assets) or **AGPL-3.0** (keeps hosted forks open). Default suggestion: **MIT**, unless keeping SaaS forks open-source matters to you.
|
- **License:** pick before release — **MIT** (max adoption; matches energietracker/your prior assets) or **AGPL-3.0** (keeps hosted forks open). Default suggestion: **MIT**, unless keeping SaaS forks open-source matters to you.
|
||||||
- **Docs:** a "wire up HA/Tasmota" guide is the highest-leverage doc for adoption.
|
- **Docs:** a "wire up HA/Tasmota" guide is the highest-leverage doc for adoption.
|
||||||
@@ -532,6 +1031,16 @@ OpenAPI/Swagger published. API-key auth for automation endpoints; UI uses the se
|
|||||||
- **M6 — API & auth.** REST + OpenAPI; API keys; reverse-proxy trust. *Exit:* HA can push via `POST /readings`; Swagger published.
|
- **M6 — API & auth.** REST + OpenAPI; API keys; reverse-proxy trust. *Exit:* HA can push via `POST /readings`; Swagger published.
|
||||||
- **M7 — Release polish.** i18n (de/en); retention settings; JSON export/import; Unraid template; CI → GHCR; README + wiring guide. *Exit:* `docker compose up` from a clean host yields a working, documented instance.
|
- **M7 — Release polish.** i18n (de/en); retention settings; JSON export/import; Unraid template; CI → GHCR; README + wiring guide. *Exit:* `docker compose up` from a clean host yields a working, documented instance.
|
||||||
|
|
||||||
|
> *After M7:* the dashboard/analysis rework was built in five phases:
|
||||||
|
> 1. shared semantics and fixtures;
|
||||||
|
> 2. virtual evaluation and migration;
|
||||||
|
> 3. history and navigation;
|
||||||
|
> 4. Overview and specialized pages;
|
||||||
|
> 5. integration, performance and documentation.
|
||||||
|
>
|
||||||
|
> It replaced M4's continuous aggregates and "monthly + prorated" cost view with rollup tables and the month-by-month
|
||||||
|
> bill (§5.4, §7.5), and M5's panels with the pages of §8.0. M7's "retention settings" remain display-only (D-57).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 13. Testing strategy
|
## 13. Testing strategy
|
||||||
@@ -541,21 +1050,89 @@ OpenAPI/Swagger published. API-key auth for automation endpoints; UI uses the se
|
|||||||
- **Golden fixtures:** the four CSVs with expected monthly consumption/cost tables. A regression test asserts computed ≈ spreadsheet (define tolerance for rounding; the sheet rounds to cents / whole kWh).
|
- **Golden fixtures:** the four CSVs with expected monthly consumption/cost tables. A regression test asserts computed ≈ spreadsheet (define tolerance for rounding; the sheet rounds to cents / whole kWh).
|
||||||
- **Load smoke (optional):** synthetic 1000-meter × N-year generator to validate aggregate query latency and compression ratio.
|
- **Load smoke (optional):** synthetic 1000-meter × N-year generator to validate aggregate query latency and compression ratio.
|
||||||
|
|
||||||
|
> **Current (analysis rework, D-56):** `Core.Tests` has 1,733 tests and `Integration.Tests` 746, all passing at the end
|
||||||
|
> of the rework. The continuous-aggregate refresh test was replaced by
|
||||||
|
> `CostReconciliationTests.Monthly_rollup_equals_the_consumption_it_sums` (water Dec 2022 = 14 m³). `SchemaTests`
|
||||||
|
> pins that the aggregates and their jobs are gone and that the analysis tables cascade with their meter.
|
||||||
|
>
|
||||||
|
> - **Frozen clock, pure (Core):**
|
||||||
|
> - `PeriodResolverTests`, `ComparisonResolverTests`, `BucketPlannerTests`: local New Year, Berlin DST in spring
|
||||||
|
> and autumn (23 h and 25 h days, the repeated hour), 29 Feb, 31 March against February, shorter months, New York
|
||||||
|
> (a zone behind UTC).
|
||||||
|
> - Coverage, rollups, provenance and freshness: `Coverage*Tests`, `RollupBuilderTests`, `MatchedCoverageTests`,
|
||||||
|
> `ProvenanceRulesTests`, `FreshnessRulesTests`.
|
||||||
|
> - Totals and categories: `TotalsPolicyTests` (the seeded classification, D-22), `CategoryCoverTests`,
|
||||||
|
> `SeparatelyBilledSubmeterTests`.
|
||||||
|
> - Virtual formulas: `FormulaParserTests`, `VirtualValidatorTests`, `VirtualEvaluatorTests`, `DependencyGraphTests`,
|
||||||
|
> `LegacyVirtualDerivationTests`: A+B, A−B, missing vs zero, nested, loop, division by zero.
|
||||||
|
> - Costing: `CostCalculator*Tests`, `CostingTariffBookTests`, `TariffUnitTests`.
|
||||||
|
> - Units and changes: `UnitsTests`, `ChangeTests`.
|
||||||
|
> - **Reader and costs (Testcontainers):**
|
||||||
|
> - `AnalysisReaderTests`: the worked examples, local days across DST and in New York, rows after now, new readings
|
||||||
|
> and corrections visible on the next read.
|
||||||
|
> - `AnalysisDataTests`: rollups written by diff, rows removed behind the tracker's back.
|
||||||
|
> - `CostReaderTests`: missing vs zero tariff, price gaps, bucket-independent totals, standing charges once per
|
||||||
|
> scope, manual costs once, virtual cost rules. `CostReviewFixTests` adds a category whose members price nothing.
|
||||||
|
> - `SeededBillTests`: the D-44 goldens. The yearly bill equals the sheet's `Jahreskosten` within ±0.02 € for 2022,
|
||||||
|
> 2025 and 2026; 2023 and 2024 are pinned to the documented 3.78 € / 0.46 € differences. It also pins the
|
||||||
|
> composition and a manual-cost-only instance.
|
||||||
|
> - `CostConsistencyTests`: the same cost change on four pages, and matching standing charges.
|
||||||
|
> - `CoverageOfFixturesTests`, and the unchanged reconciliation suites (electricity, water, oil, costs; Netz
|
||||||
|
> Einsparung through the new evaluator, D-29).
|
||||||
|
> - **Contracts and export:** `ApiContractTests` (fields and types of `/consumption`, `/cost`, `/dashboard/summary`,
|
||||||
|
> offsets, not-costed meters), `AnalysisExportEndpointTests`, `AnalysisCsvWriterTests`, and `ExportRoundTripTests`
|
||||||
|
> (links and virtual definitions remapped, a deleted meter's tariffs not restored).
|
||||||
|
> - **Pages without a browser:**
|
||||||
|
> - Loaders against a database: `MeterAnalysisLoaderTests`, `AnalysisPageLoaderTests`, `EnergyTypePageTests`,
|
||||||
|
> `OverviewDataTests`, `SolarServiceTests`, `ConsumableServiceTests`, `MeterDraftPreviewTests`,
|
||||||
|
> `VirtualManagementTests`, `MeterDetailServiceTests` (keyset paging, half-open filters).
|
||||||
|
> - Pure UI models: `AnalysisQueryTests`, `AnalysisNavigationTests`, `AnalysisChartModelTests`,
|
||||||
|
> `AnalysisTableModelTests`, `AttentionItemsTests`, `LoadSequencerTests`, `MeterPageLogicTests`,
|
||||||
|
> `EnergyPageTests`, `OverviewLogicTests`, `MeterEditorLogicTests`, `TariffEditingTests`.
|
||||||
|
> - Server-rendered HTML in EN and DE: `DashboardRenderTests`, `OverviewPageTests`, `AnalysisComponentRenderTests`,
|
||||||
|
> `AdminPagesRenderTests`, `MeterSourcesRenderTests`.
|
||||||
|
> - `StringResourceTests`, `EnumDisplayNameTests` and `FormatCultureTests` pin both languages and the currency.
|
||||||
|
> - **Browser checks:** interactive ApexCharts, browser history, theme switching and responsive layout were checked
|
||||||
|
> against the seeded instance. CDP scripts drove Chrome in EN/DE, light/dark, 1440/390/360 px. That acceptance walk
|
||||||
|
> is recorded in `docs/ANALYSIS_REPORT.md`. No bUnit or Playwright suite is in the repository.
|
||||||
|
> - **Performance** (`tests/Integration.Tests/Performance`, trait `Category=Performance`): skipped unless
|
||||||
|
> `METERVAULT_PERF=1`. `SyntheticLoadTests` loads a deterministic 1,000-meter × 10-year dataset (≈1.34 M readings;
|
||||||
|
> monthly, daily and hourly meters, tanks, roles, links, 20 virtual meters nested up to three levels) through the
|
||||||
|
> real pipeline. `ReaderTimingTests` times the reader and cost scenarios (the brief's target: 100 meters, 10 years
|
||||||
|
> monthly, under 2 s), counts SQL statements, records query plans, the startup rebuild and per-meter recompute
|
||||||
|
> cost. Results are in `docs/ANALYSIS_REPORT.md`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 14. Open questions & defaults
|
## 14. Open questions & defaults
|
||||||
|
|
||||||
Pick the **default** and flag it if unsure; only ask when a question isn't listed here.
|
Pick the **default** and flag it if unsure; only ask when a question isn't listed here.
|
||||||
|
|
||||||
1. **Virtual meters: compute-on-write or compute-on-read?** *Default:* compute-on-read for dashboards, materialize to `consumption` only if a virtual meter is referenced by cost. (Avoids recompute storms; revisit if slow.)
|
1. **Virtual meters: compute-on-write or compute-on-read?** *Default:* compute-on-read for dashboards, materialize to `consumption` only if a virtual meter is referenced by cost. (Avoids recompute storms; revisit if slow.) *Decided (D-58):* computed on read everywhere, costs included; nothing is materialized.
|
||||||
2. **Legacy monthly import → daily interpolation or native monthly?** *Default:* offer both per import; interpolation off by default, points marked `interpolated`.
|
2. **Legacy monthly import → daily interpolation or native monthly?** *Default:* offer both per import; interpolation off by default, points marked `interpolated`. *Current (D-57):* native monthly only; interpolation is not offered.
|
||||||
3. **Cost proration when price changes mid-month.** *Default:* day-accurate proration available, but the **displayed** monthly figure uses the month's dominant price to match the spreadsheet unless the user opts into proration.
|
3. **Cost proration when price changes mid-month.** *Default:* day-accurate proration available, but the **displayed** monthly figure uses the month's dominant price to match the spreadsheet unless the user opts into proration. *Decided (D-36, A-16):* one price per local month, the one valid on the 15th (the sheet's convention); no proration mode. Every bucket is priced month by month, so bucket size never changes a total. A reading interval longer than a month is priced as a whole only when its months share one price; otherwise it is unavailable, with an attention item. Standing charges accrue per day (D-40).
|
||||||
4. **Instant-rate (power/flow) integration in v1?** *Default:* schema-supported, worker deferred to post-v1 (Tasmota already gives cumulative `ENERGY.Total`, so it's rarely needed).
|
4. **Instant-rate (power/flow) integration in v1?** *Default:* schema-supported, worker deferred to post-v1 (Tasmota already gives cumulative `ENERGY.Total`, so it's rarely needed). *Current:* the `instant_rate` normalizer integrates the rate over time (trapezoidal), and a gap longer than max(1 h, 10 × the median sample interval) opens a coverage gap (D-13). There is no rate-specific ingestion worker: rate values arrive through the ordinary sources like any other reading.
|
||||||
5. **Multi-user?** *Default:* single admin + reverse-proxy trust; full accounts post-v1.
|
5. **Multi-user?** *Default:* single admin + reverse-proxy trust; full accounts post-v1.
|
||||||
6. **.NET version pin.** *Default:* current LTS at implementation time; keep `TargetFramework` in one place.
|
6. **.NET version pin.** *Default:* current LTS at implementation time; keep `TargetFramework` in one place.
|
||||||
7. **License.** *Default:* MIT unless you want AGPL's copyleft on hosted forks.
|
7. **License.** *Default:* MIT unless you want AGPL's copyleft on hosted forks.
|
||||||
8. **Name.** `MeterVault` is a placeholder — decide before first public tag.
|
8. **Name.** `MeterVault` is a placeholder — decide before first public tag.
|
||||||
|
|
||||||
|
The analysis rework settled these further questions. Each is recorded in `ANALYSIS_IMPLEMENTATION_NOTE.md`.
|
||||||
|
|
||||||
|
9. **Raw retention (§5.5).** *Decided (D-57):* not enforced until recompute can start from stored consumption. The
|
||||||
|
setting is shown as "Not enforced".
|
||||||
|
10. **What a type's total and bill count.** *Decided (D-22, D-34):* the non-overlapping topology roots, with
|
||||||
|
consumption and generation apart. The bill counts grid import where there is one, otherwise use. Submeters and
|
||||||
|
virtual views are never added; the `always` override lets a virtual meter replace its sources (D-23).
|
||||||
|
11. **Virtual costs.** *Decided (D-39, A-15):* a named cost rule. `sourceCosts` for pure sums, `ownQuantity` for
|
||||||
|
linear formulas, `none` otherwise and for generation sums. The default for new virtual meters is "analysis only"
|
||||||
|
in the totals.
|
||||||
|
12. **Default comparison.** *Decided (A-13):* the previous year, at the same elapsed point, measured over what both
|
||||||
|
periods cover (D-07).
|
||||||
|
13. **Default periods.** *Decided (D-02):* the Overview uses month to date. History pages use the last 12 months: 12
|
||||||
|
calendar buckets, the current one partial.
|
||||||
|
14. **Missing tariff.** *Decided (D-38):* "not priced", never 0. An explicit zero tariff is a valid zero.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Appendix A — CSV import dialect (from the reference files)
|
## Appendix A — CSV import dialect (from the reference files)
|
||||||
@@ -576,7 +1153,7 @@ Pick the **default** and flag it if unsure; only ask when a question isn't liste
|
|||||||
|------------|---------------|
|
|------------|---------------|
|
||||||
| Zähler (Haus/Netz/Auto/Solar) | `meter` (`cumulative_`/`generation_counter`) |
|
| Zähler (Haus/Netz/Auto/Solar) | `meter` (`cumulative_`/`generation_counter`) |
|
||||||
| Verbrauch / Erzeugung | `consumption.amount` (kind 0/1) |
|
| Verbrauch / Erzeugung | `consumption.amount` (kind 0/1) |
|
||||||
| Solar Erzeugung / Eigenverbrauch / Ersparnis / Netz Einsparung | `virtual` meters via expressions |
|
| Solar Erzeugung / Eigenverbrauch / Ersparnis / Netz Einsparung | `virtual` meters via expressions (quantities only: Solar Erzeugung = `m(Solar 1) + m(Solar 2)`, Netz Einsparung = `m(Haus) − m(Netz)`). Ersparnis is a price × quantity, which expressions do not support (§7.4). It is the Solar page's savings, or a virtual meter's `ownQuantity` cost. |
|
||||||
| €/kWh, €/m³, €/100l | `tariff.unit_price` (time-ranged) |
|
| €/kWh, €/m³, €/100l | `tariff.unit_price` (time-ranged) |
|
||||||
| Grundpreis / Abschlag | `tariff.base_price` |
|
| Grundpreis / Abschlag | `tariff.base_price` |
|
||||||
| Betriebststunden | `runtime_counter` meter |
|
| Betriebststunden | `runtime_counter` meter |
|
||||||
|
|||||||
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 270 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 264 KiB |
|
After Width: | Height: | Size: 278 KiB |
|
After Width: | Height: | Size: 204 KiB |
|
After Width: | Height: | Size: 229 KiB |
|
After Width: | Height: | Size: 241 KiB |
@@ -55,11 +55,13 @@ it as an MQTT source (above). No HA endpoint needed.
|
|||||||
and a `HomeAssistant` source on the meter:
|
and a `HomeAssistant` source on the meter:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "entityId": "sensor.house_power", "attribute": null, "pollSeconds": 60 }
|
{ "entityId": "sensor.house_power", "attribute": null, "pollMinutes": 60 }
|
||||||
```
|
```
|
||||||
|
|
||||||
Set `HA_TOKEN` (a long-lived access token) in the environment. Numeric state (or a named
|
Set `HA_TOKEN` (a long-lived access token) in the environment, or type the token into the connector
|
||||||
`attribute`) is read every `pollSeconds`; `unavailable`/`unknown` states are skipped.
|
dialog to have it encrypted at rest (SDD §6.4). Numeric state (or a named `attribute`) is read every
|
||||||
|
`pollMinutes` — default 60, because monthly totals and cost are identical whether a meter is sampled
|
||||||
|
hourly or per-second. `unavailable`/`unknown` states are skipped.
|
||||||
|
|
||||||
**C — HA pushes to the REST API.** POST to `/api/v1/readings` with an `X-Api-Key` header (see the
|
**C — HA pushes to the REST API.** POST to `/api/v1/readings` with an `X-Api-Key` header (see the
|
||||||
README). Good when HA should drive the cadence.
|
README). Good when HA should drive the cadence.
|
||||||
|
|||||||
@@ -0,0 +1,570 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using MeterVault.App.Localization;
|
||||||
|
using MeterVault.App.Theme;
|
||||||
|
using MeterVault.Core.Analysis;
|
||||||
|
using MeterVault.Core.Analysis.Costing;
|
||||||
|
using MeterVault.Infrastructure.Analysis;
|
||||||
|
using MudBlazor;
|
||||||
|
using MudBlazor.Utilities;
|
||||||
|
|
||||||
|
namespace MeterVault.App.Analysis;
|
||||||
|
|
||||||
|
/// <summary>How a chart series is drawn.</summary>
|
||||||
|
public enum ChartSeriesStyle
|
||||||
|
{
|
||||||
|
Bar,
|
||||||
|
Line,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Why a chart value has no number (brief §4.3): the chart words an empty plot by it, never as "no data" alone.</summary>
|
||||||
|
public enum ChartGap
|
||||||
|
{
|
||||||
|
/// <summary>The value is known.</summary>
|
||||||
|
None,
|
||||||
|
|
||||||
|
/// <summary>No data, a calculation that cannot be evaluated, data being prepared.</summary>
|
||||||
|
NoData,
|
||||||
|
|
||||||
|
/// <summary>The data exists only at a coarser resolution than the bucket (D-14 unresolved).</summary>
|
||||||
|
Unresolved,
|
||||||
|
|
||||||
|
/// <summary>A cost whose quantities are known but whose price is not (no tariff, a tariff gap, a unit mismatch, D-38).</summary>
|
||||||
|
NotPriced,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One value as the chart draws it: the number (null for an unknown bucket, which is a gap — never a zero), whether it is
|
||||||
|
/// qualified (partial, estimated, not fully priced), and the words the tooltip adds to it.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ChartValue(double? Value, bool IsQualified, string? Note)
|
||||||
|
{
|
||||||
|
/// <summary>Why there is no number; <see cref="ChartGap.None"/> when there is one.</summary>
|
||||||
|
public ChartGap Gap { get; init; }
|
||||||
|
|
||||||
|
/// <summary>The status in words ("Only coarser data", "Not priced (no tariff)").</summary>
|
||||||
|
public string? Status { get; init; }
|
||||||
|
|
||||||
|
/// <summary>A quantity bucket (<see cref="FigureText.Of(BucketValue, Func{int, string?}?)"/>).</summary>
|
||||||
|
public static ChartValue Of(BucketValue value, Func<int, string?>? meterName = null)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(value);
|
||||||
|
|
||||||
|
var status = FigureText.Of(value, meterName);
|
||||||
|
var gap = status.IsKnown ? ChartGap.None : value.Status == BucketStatus.Unresolved ? ChartGap.Unresolved : ChartGap.NoData;
|
||||||
|
return new ChartValue(status.IsKnown ? value.Value : null, status.IsQualified, status.IsQualified ? status.Full : null)
|
||||||
|
{
|
||||||
|
Gap = gap,
|
||||||
|
Status = status.Status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A cost figure (<see cref="FigureText.Of(CostAmount)"/>).</summary>
|
||||||
|
public static ChartValue Of(CostAmount amount)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(amount);
|
||||||
|
|
||||||
|
var status = FigureText.Of(amount);
|
||||||
|
var gap = status.IsKnown ? ChartGap.None
|
||||||
|
: amount.Status is CostStatus.NotPriced or CostStatus.PriceGap or CostStatus.UnitMismatch ? ChartGap.NotPriced
|
||||||
|
: amount.Availability == BucketStatus.Unresolved ? ChartGap.Unresolved
|
||||||
|
: ChartGap.NoData;
|
||||||
|
return new ChartValue(status.IsKnown ? amount.Cost : null, status.IsQualified, status.IsQualified ? status.Full : null)
|
||||||
|
{
|
||||||
|
Gap = gap,
|
||||||
|
Status = status.Status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Why a chart has nothing to draw (<see cref="AnalysisChartPlan.EmptyReason"/>).</summary>
|
||||||
|
public enum ChartEmptyReason
|
||||||
|
{
|
||||||
|
/// <summary>Something can be drawn.</summary>
|
||||||
|
None,
|
||||||
|
|
||||||
|
/// <summary>No value is known: no data, or nothing that can be evaluated.</summary>
|
||||||
|
NoData,
|
||||||
|
|
||||||
|
/// <summary>The data is only resolved coarser than the buckets (a monthly import in days): a coarser interval shows it.</summary>
|
||||||
|
Unresolved,
|
||||||
|
|
||||||
|
/// <summary>The quantities are known but not priced: the cost is unavailable until a tariff covers it.</summary>
|
||||||
|
NotPriced,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A series the analysis chart draws (D-49, brief §8): a stable key, a display name (user data is never translated), the
|
||||||
|
/// unit or currency its values are in, one value per bucket of the plan, bar or line, and whether it is the comparison
|
||||||
|
/// overlay of another series.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A comparison overlay's values are paired with the current buckets by index (<see cref="BucketPair"/>, A-10): value i
|
||||||
|
/// belongs to the image of bucket i. It shares the colour of <see cref="BaseKey"/> and is drawn dashed (a line) or
|
||||||
|
/// faded (bars), so the pairing is readable without colour.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed record AnalysisChartSeries
|
||||||
|
{
|
||||||
|
/// <summary>A quantity series.</summary>
|
||||||
|
/// <param name="key">A stable key (<see cref="SeriesKey.Id"/>, or any invariant token).</param>
|
||||||
|
/// <param name="name">The name shown in the legend and tooltip.</param>
|
||||||
|
/// <param name="unit">The normalized unit of every value (D-20).</param>
|
||||||
|
/// <param name="values">One value per bucket.</param>
|
||||||
|
public AnalysisChartSeries(string key, string name, string? unit, IReadOnlyList<BucketValue> values)
|
||||||
|
: this(key, name, unit, values, null)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A quantity series whose derived values name the meter they miss (<see cref="FigureText.Of(BucketValue, Func{int, string?}?)"/>).</summary>
|
||||||
|
/// <param name="key">A stable key.</param>
|
||||||
|
/// <param name="name">The name shown in the legend and tooltip.</param>
|
||||||
|
/// <param name="unit">The normalized unit of every value (D-20).</param>
|
||||||
|
/// <param name="values">One value per bucket.</param>
|
||||||
|
/// <param name="meterName">Names a meter id a value's dependency path ends at; "#id" without it.</param>
|
||||||
|
public AnalysisChartSeries(string key, string name, string? unit, IReadOnlyList<BucketValue> values, Func<int, string?>? meterName)
|
||||||
|
: this(key, name, unit, null, [.. (values ?? throw new ArgumentNullException(nameof(values))).Select(v => ChartValue.Of(v, meterName))])
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
private AnalysisChartSeries(string key, string name, string? unit, string? currency, IReadOnlyList<ChartValue> values)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(key);
|
||||||
|
ArgumentNullException.ThrowIfNull(name);
|
||||||
|
|
||||||
|
Key = key;
|
||||||
|
Name = name;
|
||||||
|
Unit = unit;
|
||||||
|
Currency = currency;
|
||||||
|
Values = values;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Key { get; init; }
|
||||||
|
|
||||||
|
public string Name { get; init; }
|
||||||
|
|
||||||
|
/// <summary>The quantity unit; null for money.</summary>
|
||||||
|
public string? Unit { get; init; }
|
||||||
|
|
||||||
|
/// <summary>The ISO currency code when the values are money (D-43).</summary>
|
||||||
|
public string? Currency { get; init; }
|
||||||
|
|
||||||
|
public IReadOnlyList<ChartValue> Values { get; init; }
|
||||||
|
|
||||||
|
public ChartSeriesStyle Style { get; init; } = ChartSeriesStyle.Bar;
|
||||||
|
|
||||||
|
/// <summary>True for the comparison overlay of another series.</summary>
|
||||||
|
public bool IsComparison { get; init; }
|
||||||
|
|
||||||
|
/// <summary>For an overlay, the key of the series it compares; it takes that series' colour.</summary>
|
||||||
|
public string? BaseKey { get; init; }
|
||||||
|
|
||||||
|
/// <summary>True when the values are money.</summary>
|
||||||
|
public bool IsMoney => Currency is not null;
|
||||||
|
|
||||||
|
/// <summary>What the axis of this series is labelled with: the currency symbol for money, else the unit.</summary>
|
||||||
|
public string AxisUnit => Currency is { } currency ? Format.CurrencySymbol(currency) : Unit?.Trim() ?? string.Empty;
|
||||||
|
|
||||||
|
/// <summary>A cost series: one figure per bucket, in <paramref name="currency"/>.</summary>
|
||||||
|
public static AnalysisChartSeries ForCost(string key, string name, string currency, IReadOnlyList<CostAmount> amounts)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(amounts);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(currency);
|
||||||
|
|
||||||
|
return new AnalysisChartSeries(key, name, null, currency, [.. amounts.Select(ChartValue.Of)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A reader series (a meter or a measure total). <paramref name="name"/> defaults to the meter's name, or for a
|
||||||
|
/// measure to the measure's wording.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="series">The series.</param>
|
||||||
|
/// <param name="name">The legend name; the meter's name or the measure's wording by default.</param>
|
||||||
|
/// <param name="style">Bars by default.</param>
|
||||||
|
/// <param name="meterName">Names the meter a derived value misses (a source without data, a loop); "#id" without it.</param>
|
||||||
|
public static AnalysisChartSeries ForSeries(
|
||||||
|
AnalysisSeries series, string? name = null, ChartSeriesStyle style = ChartSeriesStyle.Bar, Func<int, string?>? meterName = null)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(series);
|
||||||
|
|
||||||
|
return new AnalysisChartSeries(series.Key.Id, name ?? NameOf(series), series.Unit, series.Values, meterName) { Style = style };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The comparison overlay of a reader series (<see cref="AnalysisSeries.Comparison"/>), paired with its buckets by
|
||||||
|
/// index; null when no comparison was read.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="series">The series.</param>
|
||||||
|
/// <param name="name">The overlay's name, e.g. <see cref="ComparisonName"/>.</param>
|
||||||
|
/// <param name="style">Line by default: a dashed line over the bars.</param>
|
||||||
|
/// <param name="meterName">Names the meter a derived value misses; "#id" without it.</param>
|
||||||
|
public static AnalysisChartSeries? ComparisonOf(
|
||||||
|
AnalysisSeries series, string name, ChartSeriesStyle style = ChartSeriesStyle.Line, Func<int, string?>? meterName = null)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(series);
|
||||||
|
|
||||||
|
return series.Comparison is { } comparison
|
||||||
|
? new AnalysisChartSeries(series.Key.Id + ":cmp", name, series.Unit, comparison.Values, meterName)
|
||||||
|
{
|
||||||
|
Style = style,
|
||||||
|
IsComparison = true,
|
||||||
|
BaseKey = series.Key.Id,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The comparison overlay of a cost series, priced in the paired buckets (<see cref="CostComparisonRequest"/>).</summary>
|
||||||
|
public static AnalysisChartSeries ComparisonForCost(
|
||||||
|
string baseKey, string name, string currency, IReadOnlyList<CostAmount> amounts, ChartSeriesStyle style = ChartSeriesStyle.Line) =>
|
||||||
|
ForCost(baseKey + ":cmp", name, currency, amounts) with { Style = style, IsComparison = true, BaseKey = baseKey };
|
||||||
|
|
||||||
|
/// <summary>"Haus (same period last year)": a series name with the comparison it shows.</summary>
|
||||||
|
public static string ComparisonName(string name, ComparisonRequest comparison)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(comparison);
|
||||||
|
|
||||||
|
return name + " (" + comparison.Display() + ")";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The name of a reader series: the meter's name, or the measure's wording for a total.</summary>
|
||||||
|
public static string NameOf(AnalysisSeries series)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(series);
|
||||||
|
|
||||||
|
return series.Name.Length > 0 || series.Key.Measure is not { } measure ? series.Name : measure.Display();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The chart colours of one theme mode, taken from the MudBlazor palette (D-49): the series hues in a fixed order, a
|
||||||
|
/// muted hue for overlays without a base, and the text, grid and zero-line colours. Colour follows the series, in the
|
||||||
|
/// order the series are given, so a meter keeps its hue when others are added after it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The order — primary, secondary, info, then error, warning and success for a fourth to sixth meter — is the palette
|
||||||
|
/// order whose neighbours stay distinguishable under protan and deutan vision (checked with an OKLab ΔE validator: ≥ 9.5
|
||||||
|
/// in dark mode, ≥ 6.3 in light mode, where the legend and the table are the secondary encoding).
|
||||||
|
/// </remarks>
|
||||||
|
public sealed record ChartPalette(bool IsDark, IReadOnlyList<string> Series, string Muted, string Text, string Grid, string Baseline, string Surface)
|
||||||
|
{
|
||||||
|
/// <summary>The colours of the light or dark palette of <see cref="MeterVaultTheme"/>.</summary>
|
||||||
|
public static ChartPalette For(bool isDark)
|
||||||
|
{
|
||||||
|
Palette palette = isDark ? MeterVaultTheme.Instance.PaletteDark : MeterVaultTheme.Instance.PaletteLight;
|
||||||
|
return new ChartPalette(
|
||||||
|
isDark,
|
||||||
|
[Hex(palette.Primary), Hex(palette.Secondary), Hex(palette.Info), Hex(palette.Error), Hex(palette.Warning), Hex(palette.Success)],
|
||||||
|
palette.GrayDefault,
|
||||||
|
Rgba(palette.TextSecondary),
|
||||||
|
Rgba(palette.LinesDefault),
|
||||||
|
Rgba(palette.TextSecondary),
|
||||||
|
Hex(palette.Surface));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The hue of the <paramref name="index"/>-th series (0-based).</summary>
|
||||||
|
public string SeriesColor(int index) => Series[((index % Series.Count) + Series.Count) % Series.Count];
|
||||||
|
|
||||||
|
/// <summary><c>#RRGGBB</c>: the chart library does its own colour arithmetic and expects plain hex.</summary>
|
||||||
|
public static string Hex(MudColor color)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(color);
|
||||||
|
|
||||||
|
return string.Create(CultureInfo.InvariantCulture, $"#{color.R:X2}{color.G:X2}{color.B:X2}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary><c>rgba(r,g,b,a)</c> with the colour's own alpha.</summary>
|
||||||
|
public static string Rgba(MudColor color)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(color);
|
||||||
|
|
||||||
|
return string.Create(CultureInfo.InvariantCulture, $"rgba({color.R},{color.G},{color.B},{Math.Round(color.APercentage, 3)})");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A <c>#RRGGBB</c> colour at <paramref name="alpha"/> as <c>rgba(…)</c>; anything else is returned unchanged.</summary>
|
||||||
|
public static string WithAlpha(string hex, double alpha)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(hex);
|
||||||
|
|
||||||
|
if (hex.Length != 7 || hex[0] != '#'
|
||||||
|
|| !int.TryParse(hex.AsSpan(1), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var rgb))
|
||||||
|
{
|
||||||
|
return hex;
|
||||||
|
}
|
||||||
|
|
||||||
|
var a = Math.Clamp(alpha, 0, 1);
|
||||||
|
return string.Create(CultureInfo.InvariantCulture, $"rgba({(rgb >> 16) & 0xFF},{(rgb >> 8) & 0xFF},{rgb & 0xFF},{Math.Round(a, 3)})");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One point of a chart series, as the chart library receives it: the bucket's index and label, the value (null for a
|
||||||
|
/// gap), its fill (faded when qualified) and the tooltip text, formatted here with the reader's culture, unit and
|
||||||
|
/// currency and the status in words.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ChartPoint(int Index, string Label, decimal? Value, string? FillColor, string Tooltip, bool IsQualified);
|
||||||
|
|
||||||
|
/// <summary>One drawn series of a <see cref="ChartPanel"/>.</summary>
|
||||||
|
public sealed record ChartPanelSeries(
|
||||||
|
string Key,
|
||||||
|
string Name,
|
||||||
|
ChartSeriesStyle Style,
|
||||||
|
bool IsComparison,
|
||||||
|
string Color,
|
||||||
|
int StrokeWidth,
|
||||||
|
int DashSpace,
|
||||||
|
IReadOnlyList<ChartPoint> Points);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One chart with one y-axis: the series of one unit or currency. Series of different units are never drawn against
|
||||||
|
/// two scales on one plot; they get a panel each.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Unit">The axis unit ("kWh", "€"); empty when the values have none.</param>
|
||||||
|
/// <param name="HasNegative">A value is below zero: the zero line is drawn.</param>
|
||||||
|
/// <param name="HasPositive">A value is above zero.</param>
|
||||||
|
/// <param name="HasMarked">
|
||||||
|
/// A current (not comparison) point has a value that is qualified (partial, estimated, not fully priced): the note under
|
||||||
|
/// the chart explains <see cref="AnalysisChartPlan.Marker"/>.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="HasValues">At least one point has a value.</param>
|
||||||
|
/// <param name="Labels">
|
||||||
|
/// The panel's axis labels: the bucket labels, with <see cref="AnalysisChartPlan.Marker"/> where one of the panel's own
|
||||||
|
/// current series has a qualified value and <see cref="AnalysisChartPlan.GapMarker"/> where one has none — a gap in the
|
||||||
|
/// cost panel does not mark the quantity panel's months.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="HasGaps">A current point has no value: the note under the chart explains <see cref="AnalysisChartPlan.GapMarker"/>.</param>
|
||||||
|
public sealed record ChartPanel(
|
||||||
|
string Unit,
|
||||||
|
IReadOnlyList<ChartPanelSeries> Series,
|
||||||
|
bool HasNegative,
|
||||||
|
bool HasPositive,
|
||||||
|
bool HasMarked,
|
||||||
|
bool HasValues,
|
||||||
|
IReadOnlyList<string> Labels,
|
||||||
|
bool HasGaps = false);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What the analysis chart draws (D-49): the bucket labels, which buckets are marked as qualified, and the panels —
|
||||||
|
/// computed without the chart library, so the rules are testable: unknown values stay gaps, qualified buckets are marked
|
||||||
|
/// in the label (not by colour alone), overlays pair by index, labels carry the year across years.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Labels">One label per bucket, unmarked (each panel marks its own, <see cref="ChartPanel.Labels"/>).</param>
|
||||||
|
/// <param name="Marked">Per bucket: some current series is qualified there.</param>
|
||||||
|
/// <param name="Panels">One per unit, in the order the units first appear.</param>
|
||||||
|
public sealed record AnalysisChartPlan(IReadOnlyList<string> Labels, IReadOnlyList<bool> Marked, IReadOnlyList<ChartPanel> Panels)
|
||||||
|
{
|
||||||
|
/// <summary>The mark added to the label of a bucket with a qualified value; the note under the chart explains it.</summary>
|
||||||
|
public const string Marker = " *";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The mark added to the label of a bucket without a value (no data, not priced, only coarser data): it is a gap, not
|
||||||
|
/// a zero — a true zero is drawn on the baseline — and the note under the chart says so.
|
||||||
|
/// </summary>
|
||||||
|
public const string GapMarker = " –";
|
||||||
|
|
||||||
|
/// <summary>The bar outline: a true zero is drawn as this line on the baseline, a gap draws nothing.</summary>
|
||||||
|
public const int BarStrokeWidth = 2;
|
||||||
|
|
||||||
|
/// <summary>Why nothing can be drawn; <see cref="ChartEmptyReason.None"/> when something can.</summary>
|
||||||
|
public ChartEmptyReason EmptyReason { get; init; }
|
||||||
|
|
||||||
|
/// <summary>For <see cref="ChartEmptyReason.NotPriced"/>: the cost's status in words ("Not priced (no tariff)").</summary>
|
||||||
|
public string? EmptyStatus { get; init; }
|
||||||
|
|
||||||
|
/// <summary>The fill alpha of a qualified bar.</summary>
|
||||||
|
public const double QualifiedAlpha = 0.45;
|
||||||
|
|
||||||
|
/// <summary>The fill alpha of a comparison bar.</summary>
|
||||||
|
public const double ComparisonAlpha = 0.4;
|
||||||
|
|
||||||
|
/// <summary>True when anything can be drawn.</summary>
|
||||||
|
public bool HasValues => Panels.Any(p => p.HasValues);
|
||||||
|
|
||||||
|
/// <summary>Plans the chart.</summary>
|
||||||
|
/// <param name="buckets">The buckets of the plan (<see cref="BucketPlan.Buckets"/>), oldest first.</param>
|
||||||
|
/// <param name="series">The series; values beyond the buckets are ignored, missing ones are gaps.</param>
|
||||||
|
/// <param name="palette">The theme's colours.</param>
|
||||||
|
/// <param name="pairs">The comparison buckets paired with <paramref name="buckets"/> (A-10), to name an overlay's own bucket in its tooltip.</param>
|
||||||
|
public static AnalysisChartPlan Build(
|
||||||
|
IReadOnlyList<AnalysisBucket> buckets,
|
||||||
|
IReadOnlyList<AnalysisChartSeries> series,
|
||||||
|
ChartPalette palette,
|
||||||
|
IReadOnlyList<BucketPair>? pairs = null)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(buckets);
|
||||||
|
ArgumentNullException.ThrowIfNull(series);
|
||||||
|
ArgumentNullException.ThrowIfNull(palette);
|
||||||
|
|
||||||
|
var labels = BucketLabels(buckets);
|
||||||
|
var marked = new bool[buckets.Count];
|
||||||
|
foreach (var current in series.Where(s => !s.IsComparison))
|
||||||
|
{
|
||||||
|
for (var i = 0; i < buckets.Count; i++)
|
||||||
|
{
|
||||||
|
marked[i] |= ValueAt(current, i).IsQualified;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Colour follows the series in the order given, never its rank; an overlay takes its base's colour.
|
||||||
|
var colours = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
var next = 0;
|
||||||
|
foreach (var current in series.Where(s => !s.IsComparison))
|
||||||
|
{
|
||||||
|
if (!colours.ContainsKey(current.Key))
|
||||||
|
{
|
||||||
|
colours[current.Key] = palette.SeriesColor(next++);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var names = UniqueNames(series);
|
||||||
|
var panels = new List<ChartPanel>();
|
||||||
|
foreach (var group in series.Select((s, i) => (Series: s, Name: names[i])).GroupBy(x => x.Series.AxisUnit, StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
// A value that is known but qualified gets "*", a bucket without a value "–": the reader tells a partial month
|
||||||
|
// from an empty one without colour, and a true zero carries no mark at all.
|
||||||
|
var shown = labels
|
||||||
|
.Select((label, i) =>
|
||||||
|
{
|
||||||
|
var current = group.Where(x => !x.Series.IsComparison).Select(x => ValueAt(x.Series, i)).ToList();
|
||||||
|
var mark = (current.Any(v => v.Value is not null && v.IsQualified) ? Marker : string.Empty)
|
||||||
|
+ (current.Any(v => v.Value is null) ? GapMarker : string.Empty);
|
||||||
|
return label + mark;
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
var drawn = new List<ChartPanelSeries>();
|
||||||
|
bool negative = false, positive = false, markedHere = false, gapsHere = false, values = false;
|
||||||
|
foreach (var (item, name) in group)
|
||||||
|
{
|
||||||
|
var colour = item.IsComparison
|
||||||
|
? item.BaseKey is { } baseKey && colours.TryGetValue(baseKey, out var baseColour) ? baseColour : palette.Muted
|
||||||
|
: colours[item.Key];
|
||||||
|
|
||||||
|
var points = new List<ChartPoint>(buckets.Count);
|
||||||
|
for (var i = 0; i < buckets.Count; i++)
|
||||||
|
{
|
||||||
|
var value = ValueAt(item, i);
|
||||||
|
var number = ToDecimal(value.Value);
|
||||||
|
negative |= number < 0;
|
||||||
|
positive |= number > 0;
|
||||||
|
values |= number is not null;
|
||||||
|
markedHere |= !item.IsComparison && value.IsQualified && number is not null;
|
||||||
|
gapsHere |= !item.IsComparison && number is null;
|
||||||
|
|
||||||
|
var pairLabel = item.IsComparison && pairs is not null && i < pairs.Count
|
||||||
|
? Format.BucketLabel(pairs[i].Comparison, includeYear: true)
|
||||||
|
: null;
|
||||||
|
points.Add(new ChartPoint(i, shown[i], number, FillOf(item, value, colour), TooltipOf(item, value, pairLabel), value.IsQualified));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bars are outlined in their colour, so a true zero is a line on the baseline — an actual point (brief §4.3)
|
||||||
|
// — while a gap draws nothing. An overlay's outline is thinner, like its fill is fainter.
|
||||||
|
var line = item.Style == ChartSeriesStyle.Line;
|
||||||
|
var stroke = line ? 2 : item.IsComparison ? 1 : BarStrokeWidth;
|
||||||
|
drawn.Add(new ChartPanelSeries(
|
||||||
|
item.Key, name, item.Style, item.IsComparison, colour, stroke, line && item.IsComparison ? 5 : 0, points));
|
||||||
|
}
|
||||||
|
|
||||||
|
panels.Add(new ChartPanel(group.Key, drawn, negative, positive, markedHere, values, shown, gapsHere));
|
||||||
|
}
|
||||||
|
|
||||||
|
var plan = new AnalysisChartPlan(labels, marked, panels);
|
||||||
|
if (plan.HasValues)
|
||||||
|
{
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing to draw: say why. A price that is missing is the reason when there is one — the quantities are there —
|
||||||
|
// then data that is only coarser than the buckets; otherwise there is no data.
|
||||||
|
var gaps = series.Where(s => !s.IsComparison)
|
||||||
|
.SelectMany(s => Enumerable.Range(0, buckets.Count).Select(i => ValueAt(s, i)))
|
||||||
|
.ToList();
|
||||||
|
var notPriced = gaps.FirstOrDefault(v => v.Gap == ChartGap.NotPriced);
|
||||||
|
var reason = notPriced is not null ? ChartEmptyReason.NotPriced
|
||||||
|
: gaps.Any(v => v.Gap == ChartGap.Unresolved) ? ChartEmptyReason.Unresolved
|
||||||
|
: ChartEmptyReason.NoData;
|
||||||
|
return plan with { EmptyReason = reason, EmptyStatus = notPriced?.Status };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The axis label of each bucket (<see cref="Format.BucketLabel"/>), with the year across years
|
||||||
|
/// (<see cref="Format.SpansYears(IReadOnlyList{AnalysisBucket})"/>). Labels are the chart's categories, so they are
|
||||||
|
/// kept distinct: should two still collide, both get their year, and a remaining duplicate its position.
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyList<string> BucketLabels(IReadOnlyList<AnalysisBucket> buckets)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(buckets);
|
||||||
|
|
||||||
|
var labels = buckets.Select(b => Format.BucketLabel(b, Format.SpansYears(buckets))).ToList();
|
||||||
|
if (labels.Distinct(StringComparer.Ordinal).Count() != labels.Count)
|
||||||
|
{
|
||||||
|
labels = [.. buckets.Select(b => Format.BucketLabel(b, includeYear: true))];
|
||||||
|
}
|
||||||
|
|
||||||
|
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
for (var i = 0; i < labels.Count; i++)
|
||||||
|
{
|
||||||
|
if (!seen.Add(labels[i]))
|
||||||
|
{
|
||||||
|
labels[i] = labels[i] + " (" + (i + 1).ToString(CultureInfo.CurrentCulture) + ")";
|
||||||
|
seen.Add(labels[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return labels;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The value at a bucket; a series shorter than the plan is unknown there, never zero.</summary>
|
||||||
|
private static ChartValue ValueAt(AnalysisChartSeries series, int index) =>
|
||||||
|
index < series.Values.Count
|
||||||
|
? series.Values[index]
|
||||||
|
: new ChartValue(null, true, BucketStatus.Missing.Display()) { Gap = ChartGap.NoData, Status = BucketStatus.Missing.Display() };
|
||||||
|
|
||||||
|
private static decimal? ToDecimal(double? value) =>
|
||||||
|
value is { } number && double.IsFinite(number) && Math.Abs(number) < 7.9e27 ? (decimal)number : null;
|
||||||
|
|
||||||
|
private static string? FillOf(AnalysisChartSeries series, ChartValue value, string colour)
|
||||||
|
{
|
||||||
|
if (series.Style != ChartSeriesStyle.Bar)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (series.IsComparison)
|
||||||
|
{
|
||||||
|
return ChartPalette.WithAlpha(colour, value.IsQualified ? ComparisonAlpha / 2 : ComparisonAlpha);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.IsQualified ? ChartPalette.WithAlpha(colour, QualifiedAlpha) : colour;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string TooltipOf(AnalysisChartSeries series, ChartValue value, string? pairLabel)
|
||||||
|
{
|
||||||
|
var text = series.IsMoney ? Format.Money(value.Value, series.Currency) : Format.Quantity(value.Value, series.Unit);
|
||||||
|
if (pairLabel is not null)
|
||||||
|
{
|
||||||
|
text = pairLabel + ": " + text;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.Note is { Length: > 0 } note ? text + " · " + note : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Series names made distinct (the chart library keys series by name): two meters may share a name, which is user
|
||||||
|
/// data; the second gets its position.
|
||||||
|
/// </summary>
|
||||||
|
private static List<string> UniqueNames(IReadOnlyList<AnalysisChartSeries> series)
|
||||||
|
{
|
||||||
|
var names = new List<string>(series.Count);
|
||||||
|
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
for (var i = 0; i < series.Count; i++)
|
||||||
|
{
|
||||||
|
var name = string.IsNullOrWhiteSpace(series[i].Name) ? series[i].Key : series[i].Name;
|
||||||
|
if (!seen.Add(name))
|
||||||
|
{
|
||||||
|
name = name + " (" + (i + 1).ToString(CultureInfo.CurrentCulture) + ")";
|
||||||
|
seen.Add(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
names.Add(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text.Json;
|
||||||
|
using ApexCharts;
|
||||||
|
|
||||||
|
namespace MeterVault.App.Analysis;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The chart library's options for one <see cref="ChartPanel"/> (D-49): a transparent background in the theme's mode,
|
||||||
|
/// straight lines that break at unknown buckets, a y-axis that reaches zero, a solid zero line when values are signed,
|
||||||
|
/// units or currency in the axis and tooltip formatters, no animation (so nothing moves for a reader who asked for
|
||||||
|
/// reduced motion) and no toolbar.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// On Blazor Server the library's .NET label formatters are unavailable, so formatting happens twice by design: the
|
||||||
|
/// tooltip text of every point is formatted in .NET (<see cref="ChartPoint.Tooltip"/>, carried as the point's
|
||||||
|
/// <c>extra</c>), and the axis uses a small JavaScript formatter with the reader's locale and the unit
|
||||||
|
/// (<see cref="ChartFormatters"/>).
|
||||||
|
/// </remarks>
|
||||||
|
public static class AnalysisChartOptions
|
||||||
|
{
|
||||||
|
/// <summary>Builds fresh options for <paramref name="panel"/>; the chart component re-keys its chart whenever they change.</summary>
|
||||||
|
/// <param name="panel">The panel.</param>
|
||||||
|
/// <param name="palette">The theme's colours.</param>
|
||||||
|
/// <param name="culture">The reader's culture, for the axis numbers.</param>
|
||||||
|
public static ApexChartOptions<ChartPoint> Build(ChartPanel panel, ChartPalette palette, CultureInfo culture)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(panel);
|
||||||
|
ArgumentNullException.ThrowIfNull(palette);
|
||||||
|
ArgumentNullException.ThrowIfNull(culture);
|
||||||
|
|
||||||
|
var mode = palette.IsDark ? Mode.Dark : Mode.Light;
|
||||||
|
var discrete = new List<MarkersDiscrete>();
|
||||||
|
for (var s = 0; s < panel.Series.Count; s++)
|
||||||
|
{
|
||||||
|
var series = panel.Series[s];
|
||||||
|
if (series.Style != ChartSeriesStyle.Line)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A qualified point on a line is a hollow square: its shape says "not a plain value", not only its colour.
|
||||||
|
foreach (var point in series.Points.Where(p => p.IsQualified && p.Value is not null))
|
||||||
|
{
|
||||||
|
discrete.Add(new MarkersDiscrete
|
||||||
|
{
|
||||||
|
SeriesIndex = s,
|
||||||
|
DataPointIndex = point.Index,
|
||||||
|
Shape = MarkerShape.Square,
|
||||||
|
Size = 5,
|
||||||
|
FillColor = palette.Surface,
|
||||||
|
StrokeColor = series.Color,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var axis = new YAxis
|
||||||
|
{
|
||||||
|
ForceNiceScale = true,
|
||||||
|
Labels = new YAxisLabels { Formatter = ChartFormatters.Axis(panel.Unit, culture) },
|
||||||
|
};
|
||||||
|
|
||||||
|
// A real zero baseline: an all-positive series is measured from zero, an all-negative one up to zero.
|
||||||
|
if (!panel.HasNegative && panel.HasPositive)
|
||||||
|
{
|
||||||
|
axis.Min = 0;
|
||||||
|
}
|
||||||
|
else if (panel.HasNegative && !panel.HasPositive)
|
||||||
|
{
|
||||||
|
axis.Max = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ApexChartOptions<ChartPoint>
|
||||||
|
{
|
||||||
|
Chart = new Chart
|
||||||
|
{
|
||||||
|
Background = "transparent",
|
||||||
|
ForeColor = palette.Text,
|
||||||
|
Toolbar = new Toolbar { Show = false },
|
||||||
|
Zoom = new Zoom { Enabled = false },
|
||||||
|
Animations = new Animations { Enabled = false },
|
||||||
|
RedrawOnParentResize = true,
|
||||||
|
},
|
||||||
|
Theme = new ApexCharts.Theme { Mode = mode },
|
||||||
|
DataLabels = new DataLabels { Enabled = false },
|
||||||
|
Legend = new Legend { Position = LegendPosition.Top, HorizontalAlign = Align.Left },
|
||||||
|
Grid = new Grid { BorderColor = palette.Grid, StrokeDashArray = 0 },
|
||||||
|
Stroke = new Stroke { Curve = Curve.Straight },
|
||||||
|
Markers = new Markers
|
||||||
|
{
|
||||||
|
Size = panel.Series.Select(s => s.Style == ChartSeriesStyle.Line ? 3d : 0d).ToList(),
|
||||||
|
StrokeColors = palette.Surface,
|
||||||
|
StrokeWidth = 2,
|
||||||
|
Discrete = discrete,
|
||||||
|
Hover = new MarkersHover { SizeOffset = 2 },
|
||||||
|
},
|
||||||
|
PlotOptions = new PlotOptions { Bar = new PlotOptionsBar { ColumnWidth = "70%", BorderRadius = 2 } },
|
||||||
|
States = new States
|
||||||
|
{
|
||||||
|
Active = new StatesActive
|
||||||
|
{
|
||||||
|
AllowMultipleDataPointsSelection = false,
|
||||||
|
Filter = new StatesFilter { Type = StatesFilterType.none },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Tooltip = new Tooltip
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
Shared = true,
|
||||||
|
Intersect = false,
|
||||||
|
Theme = mode,
|
||||||
|
Y = new TooltipY { Formatter = ChartFormatters.Tooltip },
|
||||||
|
},
|
||||||
|
Xaxis = new XAxis
|
||||||
|
{
|
||||||
|
Labels = new XAxisLabels { Rotate = -45, HideOverlappingLabels = true, Trim = false },
|
||||||
|
Tooltip = new AxisTooltip { Enabled = false },
|
||||||
|
},
|
||||||
|
Yaxis = [axis],
|
||||||
|
Annotations = panel.HasNegative
|
||||||
|
? new Annotations
|
||||||
|
{
|
||||||
|
Yaxis =
|
||||||
|
[
|
||||||
|
new AnnotationsYAxis { Y = 0, BorderColor = palette.Baseline, BorderWidth = 1, StrokeDashArray = 0 },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The extra data a chart point carries into the browser: its tooltip text, formatted in .NET.</summary>
|
||||||
|
public sealed record ChartPointExtra(string Text);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// JavaScript formatter functions for the chart library (strings it evaluates). Everything interpolated into them —
|
||||||
|
/// locale, unit — is written as a JSON string literal, so a unit can never break out of its string.
|
||||||
|
/// </summary>
|
||||||
|
public static class ChartFormatters
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The tooltip value of a point: the text formatted in .NET (<see cref="ChartPointExtra"/>), which names the unit or
|
||||||
|
/// currency and the status in words; a plain number only if that is missing.
|
||||||
|
/// </summary>
|
||||||
|
public const string Tooltip =
|
||||||
|
"function (value, opts) { "
|
||||||
|
+ "var s = opts && opts.w && opts.w.config && opts.w.config.series ? opts.w.config.series[opts.seriesIndex] : null; "
|
||||||
|
+ "var p = s && s.data ? s.data[opts.dataPointIndex] : null; "
|
||||||
|
+ "if (p && p.extra && p.extra.text) { return p.extra.text; } "
|
||||||
|
+ "return value === null || value === undefined ? '—' : String(value); }";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An axis label formatter: the number in the reader's locale (at most two decimals) and the unit or currency
|
||||||
|
/// symbol; blank for a missing value.
|
||||||
|
/// </summary>
|
||||||
|
public static string Axis(string? unit, CultureInfo culture)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(culture);
|
||||||
|
|
||||||
|
var locale = string.IsNullOrEmpty(culture.Name) ? "en" : culture.Name;
|
||||||
|
var suffix = string.IsNullOrWhiteSpace(unit) ? string.Empty : " " + unit.Trim();
|
||||||
|
return "function (value) { if (value === null || value === undefined || !isFinite(value)) { return ''; } "
|
||||||
|
+ "return new Intl.NumberFormat(" + Literal(locale) + ", { maximumFractionDigits: 2 }).format(value) + " + Literal(suffix) + "; }";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A JavaScript string literal (JSON-escaped, HTML-sensitive characters included).</summary>
|
||||||
|
public static string Literal(string text) => JsonSerializer.Serialize(text ?? string.Empty);
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace MeterVault.App.Analysis;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One row of the analysis CSV (D-55): one series in one bucket.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="SeriesId">The stable series identity (<c>m12</c>, <c>t3:use:kWh</c>, <c>portfolio</c>, <c>c4</c>).</param>
|
||||||
|
/// <param name="SeriesName">The series' name: a meter's, a type's or a category's (user data), or a worded measure.</param>
|
||||||
|
/// <param name="Kind">What the value measures, as its invariant identifier (<c>Consumption</c>, <c>Cost</c>).</param>
|
||||||
|
/// <param name="Unit">The value's unit (normalized, D-20), or the currency code for a cost series.</param>
|
||||||
|
/// <param name="BucketStart">The bucket's first instant, in the instance zone's local time with its offset.</param>
|
||||||
|
/// <param name="BucketEnd">The bucket's end (exclusive): the next local midnight, or now for a bucket cut at now.</param>
|
||||||
|
/// <param name="TimeZone">The instance zone id the bounds are local to.</param>
|
||||||
|
/// <param name="Value">The value; null when it is unavailable (missing, unresolved, invalid, being prepared).</param>
|
||||||
|
/// <param name="Status">The value's availability (<c>Available</c>, <c>Partial</c>, …).</param>
|
||||||
|
/// <param name="Provenance">Where the value comes from, as flag identifiers joined by <c>|</c> (<c>Measured|Estimated</c>); empty when none.</param>
|
||||||
|
/// <param name="Cost">The cost in the bucket; null when not priced or not costed.</param>
|
||||||
|
/// <param name="CostStatus">The cost's price coverage (<c>Priced</c>, <c>NotPriced</c>, …); null when the series has no cost.</param>
|
||||||
|
/// <param name="Currency">The currency of <paramref name="Cost"/>; null when the series has no cost.</param>
|
||||||
|
/// <param name="ComparisonValue">The value in the paired comparison bucket (D-06); null without a comparison or when unavailable.</param>
|
||||||
|
public sealed record AnalysisCsvRow(
|
||||||
|
string SeriesId,
|
||||||
|
string SeriesName,
|
||||||
|
string Kind,
|
||||||
|
string Unit,
|
||||||
|
DateTimeOffset BucketStart,
|
||||||
|
DateTimeOffset BucketEnd,
|
||||||
|
string TimeZone,
|
||||||
|
double? Value,
|
||||||
|
string Status,
|
||||||
|
string Provenance,
|
||||||
|
double? Cost,
|
||||||
|
string? CostStatus,
|
||||||
|
string? Currency,
|
||||||
|
double? ComparisonValue);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes the analysis table as CSV (D-55): RFC 4180 quoting, a header of invariant column names, invariant numbers at
|
||||||
|
/// full precision, ISO-8601 local bucket bounds with their offset, and empty cells for unavailable values — a spreadsheet
|
||||||
|
/// or a script reads the same figures the page shows, never a fabricated zero.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Names and units are user data. A cell that starts with <c>=</c>, <c>+</c>, <c>-</c>, <c>@</c> or a control character
|
||||||
|
/// is prefixed with an apostrophe, so a spreadsheet shows it as text instead of running it as a formula; numbers are
|
||||||
|
/// written by this class and never need it.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Chronological, unlike the pages (A-42).</b> The rows stay oldest first, in the plan's own order. The tables on
|
||||||
|
/// screen read newest first because a reader starts at the top; a CSV is not read, it is sorted, charted and
|
||||||
|
/// differenced, and every tool that does so — a spreadsheet's chart, a running total, a diff against last month's
|
||||||
|
/// file — expects time to run forwards. Reversing it here would only make every consumer sort it back.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class AnalysisCsvWriter
|
||||||
|
{
|
||||||
|
/// <summary>The header, in column order.</summary>
|
||||||
|
public static IReadOnlyList<string> Columns { get; } =
|
||||||
|
[
|
||||||
|
"series_id", "series_name", "kind", "unit", "bucket_start", "bucket_end", "timezone",
|
||||||
|
"value", "status", "provenance", "cost", "cost_status", "currency", "comparison_value",
|
||||||
|
];
|
||||||
|
|
||||||
|
private const string InstantFormat = "yyyy-MM-dd'T'HH:mm:sszzz";
|
||||||
|
|
||||||
|
/// <summary>Writes the header and one line per row.</summary>
|
||||||
|
public static async Task WriteAsync(TextWriter writer, IEnumerable<AnalysisCsvRow> rows, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(writer);
|
||||||
|
ArgumentNullException.ThrowIfNull(rows);
|
||||||
|
|
||||||
|
await writer.WriteAsync(Line(Columns).AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||||
|
foreach (var row in rows)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
await writer.WriteAsync(Line(Fields(row)).AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
await writer.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The whole CSV as a string (tests, small exports).</summary>
|
||||||
|
public static string Write(IEnumerable<AnalysisCsvRow> rows)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(rows);
|
||||||
|
|
||||||
|
var builder = new StringBuilder();
|
||||||
|
builder.Append(Line(Columns));
|
||||||
|
foreach (var row in rows)
|
||||||
|
{
|
||||||
|
builder.Append(Line(Fields(row)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One CSV field: quoted when it holds a comma, a quote or a line break, with quotes doubled.</summary>
|
||||||
|
public static string Escape(string? field)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(field))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var needsQuotes = field.AsSpan().IndexOfAny(",\"\r\n") >= 0;
|
||||||
|
return needsQuotes ? "\"" + field.Replace("\"", "\"\"", StringComparison.Ordinal) + "\"" : field;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A number at full precision in invariant form; empty when unknown or not finite.</summary>
|
||||||
|
public static string Number(double? value) =>
|
||||||
|
value is { } number && double.IsFinite(number) ? number.ToString("R", CultureInfo.InvariantCulture) : string.Empty;
|
||||||
|
|
||||||
|
/// <summary>An instant as local ISO-8601 with its offset (<c>2026-09-01T00:00:00+02:00</c>).</summary>
|
||||||
|
public static string Instant(DateTimeOffset value) => value.ToString(InstantFormat, CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
/// <summary>User text made safe to open in a spreadsheet: a leading formula character becomes literal text.</summary>
|
||||||
|
public static string Text(string? value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(value))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value[0] is '=' or '+' or '-' or '@' or '\t' or '\r' or '\n' ? "'" + value : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<string> Fields(AnalysisCsvRow row) =>
|
||||||
|
[
|
||||||
|
row.SeriesId,
|
||||||
|
Text(row.SeriesName),
|
||||||
|
row.Kind,
|
||||||
|
Text(row.Unit),
|
||||||
|
Instant(row.BucketStart),
|
||||||
|
Instant(row.BucketEnd),
|
||||||
|
row.TimeZone,
|
||||||
|
Number(row.Value),
|
||||||
|
row.Status,
|
||||||
|
row.Provenance,
|
||||||
|
Number(row.Cost),
|
||||||
|
row.CostStatus ?? string.Empty,
|
||||||
|
row.Currency ?? string.Empty,
|
||||||
|
Number(row.ComparisonValue),
|
||||||
|
];
|
||||||
|
|
||||||
|
private static string Line(IEnumerable<string> fields) => string.Join(',', fields.Select(Escape)) + "\r\n";
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
using MeterVault.Core.Analysis;
|
||||||
|
|
||||||
|
namespace MeterVault.App.Analysis;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What a page shows when its URL does not say (D-02, D-46): the period preset, bucket size, comparison, metric and
|
||||||
|
/// scope. A default applies only to a key that is absent; <see cref="AnalysisQuery"/> never writes a key whose value
|
||||||
|
/// equals the target page's default, so links stay short and a page never rewrites its address on load.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The Overview defaults to month to date and every history page — the meter Analysis tab, an energy type's History,
|
||||||
|
/// the Analysis page (<c>/trends</c>), Solar and Consumables — to the last 12 months (D-02). Both compare with the
|
||||||
|
/// previous year by default (amendment A-13): "this month so far against the same days last year" is the question the
|
||||||
|
/// Overview answers, and a seasonal utility compared with the months just before would read as a trend that is only
|
||||||
|
/// the season.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// A null <see cref="Metric"/> means "the scope's natural metric": a meter's own quantity kind, a type's use, the
|
||||||
|
/// portfolio's cost. The page decides it; the URL only carries a metric somebody chose.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="Scope"/> is the scope a page is about when its URL names none: the portfolio on the Overview and the
|
||||||
|
/// Analysis page, the meter on a meter page (<see cref="ForScope"/>), the type on an energy type page. A route that
|
||||||
|
/// implies its scope therefore never writes it, and a link that carries the period onward never carries the scope.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed record AnalysisDefaults
|
||||||
|
{
|
||||||
|
/// <exception cref="ArgumentException">
|
||||||
|
/// <paramref name="period"/> is <see cref="PeriodPreset.Custom"/>, which has no dates to default to, or
|
||||||
|
/// <paramref name="comparison"/> is a year comparison without its year.
|
||||||
|
/// </exception>
|
||||||
|
public AnalysisDefaults(
|
||||||
|
PeriodPreset period,
|
||||||
|
BucketSize bucket,
|
||||||
|
ComparisonRequest comparison,
|
||||||
|
AnalysisMetric? metric = null,
|
||||||
|
QueryScope? scope = null)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(comparison);
|
||||||
|
if (period == PeriodPreset.Custom || !Enum.IsDefined(period))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("A page default is a preset, never a custom range.", nameof(period));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (comparison.Kind == ComparisonKind.Year && comparison.Year is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("A year comparison needs its year.", nameof(comparison));
|
||||||
|
}
|
||||||
|
|
||||||
|
Period = period;
|
||||||
|
Bucket = bucket;
|
||||||
|
Comparison = comparison;
|
||||||
|
Metric = metric;
|
||||||
|
Scope = scope ?? QueryScope.Portfolio;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The Overview (<c>/</c>): month to date, automatic buckets, compared with the previous year.</summary>
|
||||||
|
public static AnalysisDefaults Overview { get; } =
|
||||||
|
new(PeriodPreset.MonthToDate, BucketSize.Auto, new ComparisonRequest(ComparisonKind.PreviousYear));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Every history view — the meter Analysis tab, an energy type's History, <c>/trends</c>, Solar, Consumables: the
|
||||||
|
/// last 12 months (12 calendar buckets ending with the current partial month), automatic buckets, compared with the
|
||||||
|
/// previous year.
|
||||||
|
/// </summary>
|
||||||
|
public static AnalysisDefaults History { get; } =
|
||||||
|
new(PeriodPreset.Last12Months, BucketSize.Auto, new ComparisonRequest(ComparisonKind.PreviousYear));
|
||||||
|
|
||||||
|
/// <summary>The CSV export (<c>/export/analysis.csv</c>): the history defaults, so a link written for it is explicit about everything else.</summary>
|
||||||
|
public static AnalysisDefaults Export => History;
|
||||||
|
|
||||||
|
public PeriodPreset Period { get; }
|
||||||
|
|
||||||
|
public BucketSize Bucket { get; }
|
||||||
|
|
||||||
|
public ComparisonRequest Comparison { get; }
|
||||||
|
|
||||||
|
/// <summary>The metric; null for the scope's natural one.</summary>
|
||||||
|
public AnalysisMetric? Metric { get; }
|
||||||
|
|
||||||
|
/// <summary>The scope the page is about when its URL names none.</summary>
|
||||||
|
public QueryScope Scope { get; }
|
||||||
|
|
||||||
|
/// <summary>These defaults on a page whose route implies <paramref name="scope"/> (a meter page, an energy type page).</summary>
|
||||||
|
public AnalysisDefaults ForScope(QueryScope scope) => new(Period, Bucket, Comparison, Metric, scope);
|
||||||
|
|
||||||
|
/// <summary>These defaults with another default metric.</summary>
|
||||||
|
public AnalysisDefaults WithMetric(AnalysisMetric? metric) => new(Period, Bucket, Comparison, metric, Scope);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The defaults of the page at <paramref name="path"/> (base-relative or absolute path, query ignored): the Overview's
|
||||||
|
/// for <c>/</c>, the history defaults for everything else — for components outside a page (the meter search) that
|
||||||
|
/// carry the current page's period onward.
|
||||||
|
/// </summary>
|
||||||
|
public static AnalysisDefaults ForPath(string? path)
|
||||||
|
{
|
||||||
|
var text = path ?? string.Empty;
|
||||||
|
var cut = text.IndexOfAny(['?', '#']);
|
||||||
|
if (cut >= 0)
|
||||||
|
{
|
||||||
|
text = text[..cut];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Uri.TryCreate(text, UriKind.Absolute, out var absolute) && absolute.Scheme is "http" or "https")
|
||||||
|
{
|
||||||
|
text = absolute.AbsolutePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
return text.Trim('/').Length == 0 ? Overview : History;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using MeterVault.App.Localization;
|
||||||
|
using MeterVault.Core.Analysis;
|
||||||
|
using MeterVault.Core.Analysis.Costing;
|
||||||
|
using MeterVault.Infrastructure.Analysis;
|
||||||
|
using MeterVault.Infrastructure.Costing;
|
||||||
|
using MeterVault.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace MeterVault.App.Analysis;
|
||||||
|
|
||||||
|
/// <summary>The rows of an analysis export, or why the request cannot be answered (a 400 message).</summary>
|
||||||
|
/// <param name="Error">A short invariant message for an invalid request; null when there are rows.</param>
|
||||||
|
/// <param name="FileName">The download's file name.</param>
|
||||||
|
/// <param name="Rows">One row per bucket and series.</param>
|
||||||
|
public sealed record AnalysisExportResult(string? Error, string FileName, IReadOnlyList<AnalysisCsvRow> Rows)
|
||||||
|
{
|
||||||
|
public static AnalysisExportResult Invalid(string message) => new(message, string.Empty, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the analysis table the CSV export streams (D-55) from the same URL keys, the same
|
||||||
|
/// <see cref="AnalysisQuery"/> resolution and the same readers as the pages — so the file holds exactly the figures on
|
||||||
|
/// screen: quantities from <see cref="AnalysisReader"/>, costs from <see cref="CostReader"/>, comparisons paired by
|
||||||
|
/// bucket.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Series.</b> A meter or a meter selection exports each meter's own series (whatever the metric: a meter measures
|
||||||
|
/// what it measures), with its cost by the meter's rule. An energy type or the portfolio exports the per-type measures
|
||||||
|
/// of the metric (consumption: total use and grid import, never added), or every measure without one. The cost metric —
|
||||||
|
/// and a category, which is analysed by cost — exports one cost series per scope (per meter for a selection).
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Refused.</b> Any URL key that the query could not read (a notice), a category asked for a quantity, the tank
|
||||||
|
/// balance (not a bucketed series), too many meters or too many buckets, and an unknown meter, type or category — each
|
||||||
|
/// is a 400 with a short message, never a 500 and never a silently different export.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class AnalysisExport(
|
||||||
|
AnalysisReader reader,
|
||||||
|
CostReader costs,
|
||||||
|
AnalysisPeriods periods,
|
||||||
|
IDbContextFactory<MeterVaultDbContext> contextFactory,
|
||||||
|
TimeProvider time)
|
||||||
|
{
|
||||||
|
/// <summary>Reads what <paramref name="query"/> shows, as CSV rows.</summary>
|
||||||
|
public async Task<AnalysisExportResult> PrepareAsync(AnalysisQuery query, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(query);
|
||||||
|
|
||||||
|
if (query.Notices.Count > 0)
|
||||||
|
{
|
||||||
|
return AnalysisExportResult.Invalid(string.Join(" ", query.Notices.Select(n => n.Describe())));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.Metric == AnalysisMetric.Balance)
|
||||||
|
{
|
||||||
|
return AnalysisExportResult.Invalid("The tank balance is not a bucketed series and cannot be exported; use metric=consumption.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var byCost = query.Metric == AnalysisMetric.Cost || query.Scope.Kind == QueryScopeKind.Category;
|
||||||
|
if (query.Scope.Kind == QueryScopeKind.Category && query.Metric is { } metric && metric.IsQuantity())
|
||||||
|
{
|
||||||
|
return AnalysisExportResult.Invalid("A cost category is analysed by cost; use metric=cost.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var period = await periods.ResolveAsync(query, time.GetUtcNow(), cancellationToken).ConfigureAwait(false);
|
||||||
|
var names = await Names.LoadAsync(contextFactory, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var rows = byCost
|
||||||
|
? await CostRowsAsync(query, period, names, cancellationToken).ConfigureAwait(false)
|
||||||
|
: await QuantityRowsAsync(query, period, names, cancellationToken).ConfigureAwait(false);
|
||||||
|
return rows.Error is not null ? rows : rows with { FileName = FileName(query, period) };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<AnalysisExportResult> QuantityRowsAsync(
|
||||||
|
AnalysisQuery query, ResolvedPeriod period, Names names, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (query.Scope.Kind == QueryScopeKind.EnergyType && !names.Types.ContainsKey(query.Scope.Id!.Value))
|
||||||
|
{
|
||||||
|
return AnalysisExportResult.Invalid(string.Create(CultureInfo.InvariantCulture, $"Unknown energy type: {query.Scope.Id}."));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.Scope.MeterIds.FirstOrDefault(id => !names.Meters.ContainsKey(id)) is var missing and > 0)
|
||||||
|
{
|
||||||
|
return AnalysisExportResult.Invalid(string.Create(CultureInfo.InvariantCulture, $"Unknown meter: {missing}."));
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = query.ToAnalysisRequest(period)!;
|
||||||
|
var result = await reader.ReadAsync(request, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (Refused(result.Refusal, result.Plan) is { } refusal)
|
||||||
|
{
|
||||||
|
return AnalysisExportResult.Invalid(refusal);
|
||||||
|
}
|
||||||
|
|
||||||
|
var buckets = result.Plan.Buckets;
|
||||||
|
var zone = reader.Zone;
|
||||||
|
var rows = new List<AnalysisCsvRow>();
|
||||||
|
|
||||||
|
if (query.Scope.Kind is QueryScopeKind.Meter or QueryScopeKind.Meters)
|
||||||
|
{
|
||||||
|
foreach (var series in result.Series)
|
||||||
|
{
|
||||||
|
var cost = await MeterCostAsync(series.MeterId!.Value, period, result.Plan, cancellationToken).ConfigureAwait(false);
|
||||||
|
rows.AddRange(Rows(series, series.Name, buckets, zone, cost));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AnalysisExportResult(null, string.Empty, rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
var measures = query.Metric is { } metric ? AnalysisMetrics.MeasuresOf(metric) : null;
|
||||||
|
foreach (var series in result.Measures.Where(m => measures is null || (m.Key.Measure is { } measure && measures.Contains(measure))))
|
||||||
|
{
|
||||||
|
var typeName = series.EnergyTypeId is { } typeId ? names.Types.GetValueOrDefault(typeId, string.Empty) : string.Empty;
|
||||||
|
var name = series.Key.Measure is { } measure ? typeName + " · " + measure.Display() : typeName;
|
||||||
|
rows.AddRange(Rows(series, name, buckets, zone, cost: null));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AnalysisExportResult(null, string.Empty, rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<AnalysisExportResult> CostRowsAsync(
|
||||||
|
AnalysisQuery query, ResolvedPeriod period, Names names, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var rows = new List<AnalysisCsvRow>();
|
||||||
|
BucketPlan? plan = null;
|
||||||
|
foreach (var request in query.ToCostRequests(period))
|
||||||
|
{
|
||||||
|
// Every scope of a selection is priced in the first one's buckets, so the rows line up.
|
||||||
|
var current = await costs.ReadAsync(plan is null ? request : request with { Plan = plan }, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (current.Refusal == CostRefusal.UnknownScope)
|
||||||
|
{
|
||||||
|
return AnalysisExportResult.Invalid("Unknown " + request.Scope.ToString().Replace(':', ' ') + ".");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Refused(current.Refusal == CostRefusal.TooManyPoints ? AnalysisRefusal.TooManyPoints : AnalysisRefusal.None, current.Plan) is { } refusal)
|
||||||
|
{
|
||||||
|
return AnalysisExportResult.Invalid(refusal);
|
||||||
|
}
|
||||||
|
|
||||||
|
plan ??= current.Plan;
|
||||||
|
|
||||||
|
IReadOnlyList<CostAmount>? previous = null;
|
||||||
|
if (query.Comparison.Kind != ComparisonKind.None)
|
||||||
|
{
|
||||||
|
var comparison = query.ToCostComparison(request with { Plan = current.Plan }, current.Plan);
|
||||||
|
if (comparison.Request is { } comparisonRequest)
|
||||||
|
{
|
||||||
|
previous = (await costs.ReadAsync(comparisonRequest, cancellationToken).ConfigureAwait(false)).Buckets;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (id, name) = CostSeries(request.Scope, names);
|
||||||
|
var buckets = current.Plan.Buckets;
|
||||||
|
for (var i = 0; i < buckets.Count && i < current.Buckets.Count; i++)
|
||||||
|
{
|
||||||
|
var amount = current.Buckets[i];
|
||||||
|
rows.Add(new AnalysisCsvRow(
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
nameof(QuantityKind.Cost),
|
||||||
|
current.Currency,
|
||||||
|
Local(buckets[i].From, reader.Zone),
|
||||||
|
Local(buckets[i].To, reader.Zone),
|
||||||
|
reader.Zone.Id,
|
||||||
|
amount.Cost,
|
||||||
|
// Nothing booked is unknown, never "Available" beside an empty value (§4.3, FigureText.IsNothingBooked).
|
||||||
|
(FigureText.IsNothingBooked(amount) ? BucketStatus.Missing : amount.Availability).ToString(),
|
||||||
|
string.Empty,
|
||||||
|
amount.Cost,
|
||||||
|
amount.Status.ToString(),
|
||||||
|
current.Currency,
|
||||||
|
previous is not null && i < previous.Count ? previous[i].Cost : null));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AnalysisExportResult(null, string.Empty, rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A meter's cost in the quantity buckets; null when the meter is not costed (generation, runtime, no rule).</summary>
|
||||||
|
private async Task<CostAnalysis?> MeterCostAsync(int meterId, ResolvedPeriod period, BucketPlan plan, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var analysis = await costs.ReadAsync(new CostAnalysisRequest(CostScope.ForMeter(meterId), period) { Plan = plan }, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
return analysis.Refusal != CostRefusal.None || analysis.Meter is { Rule: MeterCostRule.None } ? null : analysis;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<AnalysisCsvRow> Rows(
|
||||||
|
AnalysisSeries series, string name, IReadOnlyList<AnalysisBucket> buckets, TimeZoneInfo zone, CostAnalysis? cost)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < buckets.Count && i < series.Values.Count; i++)
|
||||||
|
{
|
||||||
|
var value = series.Values[i];
|
||||||
|
var amount = cost is not null && i < cost.Buckets.Count ? cost.Buckets[i] : null;
|
||||||
|
var previous = series.Comparison is { } comparison && i < comparison.Values.Count ? comparison.Values[i].Value : null;
|
||||||
|
yield return new AnalysisCsvRow(
|
||||||
|
series.Key.Id,
|
||||||
|
name,
|
||||||
|
series.Kind.ToString(),
|
||||||
|
series.Unit,
|
||||||
|
Local(buckets[i].From, zone),
|
||||||
|
Local(buckets[i].To, zone),
|
||||||
|
zone.Id,
|
||||||
|
value.Value,
|
||||||
|
value.Status.ToString(),
|
||||||
|
ProvenanceTokens(value.Provenance),
|
||||||
|
amount?.Cost,
|
||||||
|
amount?.Status.ToString(),
|
||||||
|
amount is null ? null : cost!.Currency,
|
||||||
|
previous);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The set flags as identifiers joined by <c>|</c> (<c>Measured|Estimated</c>); empty for none.</summary>
|
||||||
|
private static string ProvenanceTokens(Provenance provenance) =>
|
||||||
|
provenance == Provenance.None
|
||||||
|
? string.Empty
|
||||||
|
: string.Join('|', Enum.GetValues<Provenance>().Where(f => f != Provenance.None && provenance.HasFlag(f)));
|
||||||
|
|
||||||
|
private static (string Id, string Name) CostSeries(CostScope scope, Names names) => scope.Kind switch
|
||||||
|
{
|
||||||
|
CostScopeKind.EnergyType => (Token('t', scope.Id), names.Types.GetValueOrDefault(scope.Id!.Value, string.Empty)),
|
||||||
|
CostScopeKind.Meter => (Token('m', scope.Id), names.Meters.GetValueOrDefault(scope.Id!.Value, string.Empty)),
|
||||||
|
CostScopeKind.Category => (Token('c', scope.Id), names.Categories.GetValueOrDefault(scope.Id!.Value, string.Empty)),
|
||||||
|
_ => (QueryScope.Portfolio.Token, QueryScopeKind.Portfolio.Display()),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string Token(char prefix, int? id) => prefix + id!.Value.ToString(CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
private static DateTimeOffset Local(DateTimeOffset instant, TimeZoneInfo zone) => TimeZoneInfo.ConvertTime(instant, zone);
|
||||||
|
|
||||||
|
/// <summary>The 400 message of a refused request; null when it was not refused.</summary>
|
||||||
|
private static string? Refused(AnalysisRefusal refusal, BucketPlan plan) => refusal switch
|
||||||
|
{
|
||||||
|
AnalysisRefusal.TooManySeries => string.Create(
|
||||||
|
CultureInfo.InvariantCulture, $"At most {AnalysisLimits.MaxSeries} meters can be exported side by side."),
|
||||||
|
AnalysisRefusal.TooManyPoints => string.Create(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
$"bucket={AnalysisTokens.Format(plan.Size)} gives {plan.PointCount} buckets, more than {AnalysisLimits.MaxPoints}")
|
||||||
|
+ (plan.Suggested is { } suggested ? "; use bucket=" + AnalysisTokens.Format(suggested) + "." : "."),
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary><c>metervault-meter-42-consumption-2025-10-01-2026-09-19.csv</c>.</summary>
|
||||||
|
private static string FileName(AnalysisQuery query, ResolvedPeriod period)
|
||||||
|
{
|
||||||
|
var scope = query.Scope.ToString().Replace(':', '-').Replace(',', '-');
|
||||||
|
var metric = query.Metric is { } m ? AnalysisMetrics.Format(m) : query.Scope.Kind == QueryScopeKind.Category ? "cost" : "quantity";
|
||||||
|
var last = period.HasNotStarted() ? period.LastDay : period.EffectiveLastDay();
|
||||||
|
var first = period.FirstDay <= last ? period.FirstDay : last;
|
||||||
|
return $"metervault-{scope}-{metric}-{AnalysisTokens.FormatDate(first)}-{AnalysisTokens.FormatDate(last)}.csv";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The names the rows carry: meters, energy types and categories (user data, as stored).</summary>
|
||||||
|
private sealed record Names(
|
||||||
|
IReadOnlyDictionary<int, string> Meters,
|
||||||
|
IReadOnlyDictionary<int, string> Types,
|
||||||
|
IReadOnlyDictionary<int, string> Categories)
|
||||||
|
{
|
||||||
|
public static async Task<Names> LoadAsync(IDbContextFactory<MeterVaultDbContext> factory, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var db = await factory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
var meters = await db.Meters.AsNoTracking().ToDictionaryAsync(m => m.Id, m => m.Name, cancellationToken).ConfigureAwait(false);
|
||||||
|
var types = await db.EnergyTypes.AsNoTracking().ToDictionaryAsync(t => (int)t.Id, t => t.DisplayName, cancellationToken).ConfigureAwait(false);
|
||||||
|
var categories = await db.CostCategories.AsNoTracking().ToDictionaryAsync(c => c.Id, c => c.Name, cancellationToken).ConfigureAwait(false);
|
||||||
|
return new Names(meters, types, categories);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace MeterVault.App.Analysis;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>GET /export/analysis.csv</c> (D-55): the analysis table of the URL keys the pages use — <c>scope</c>/<c>id</c>/
|
||||||
|
/// <c>ids</c>, <c>metric</c>, <c>period</c>, <c>from</c>, <c>to</c>, <c>bucket</c>, <c>compare</c> — as a CSV download.
|
||||||
|
/// Build links to it with <see cref="AnalysisLinks.Export"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A UI endpoint, not part of the versioned REST API: it serves the same reader a signed-in browser already sees and
|
||||||
|
/// needs no API key, like the pages themselves. Invalid input is a 400 with a short plain-text message; nothing a user
|
||||||
|
/// can type into the URL makes it a 500.
|
||||||
|
/// </remarks>
|
||||||
|
public static class AnalysisExportEndpoints
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapAnalysisExport(this IEndpointRouteBuilder endpoints)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(endpoints);
|
||||||
|
|
||||||
|
endpoints.MapGet(AnalysisLinks.ExportPath, async (HttpContext http, AnalysisExport export, ILogger<AnalysisExport> logger, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var query = AnalysisQuery.Parse(http.Request.Query, AnalysisDefaults.Export);
|
||||||
|
|
||||||
|
AnalysisExportResult prepared;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
prepared = await export.PrepareAsync(query, ct);
|
||||||
|
}
|
||||||
|
catch (ArgumentException ex)
|
||||||
|
{
|
||||||
|
// The readers reject a combination they cannot answer with an ArgumentException; that is the request's
|
||||||
|
// fault, not the server's.
|
||||||
|
logger.LogWarning(ex, "Analysis export refused {Query}", query);
|
||||||
|
return Results.Text("This combination of keys cannot be exported.", "text/plain; charset=utf-8", Encoding.UTF8, StatusCodes.Status400BadRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prepared.Error is { } error)
|
||||||
|
{
|
||||||
|
return Results.Text(error, "text/plain; charset=utf-8", Encoding.UTF8, StatusCodes.Status400BadRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Stream(
|
||||||
|
async stream =>
|
||||||
|
{
|
||||||
|
// A byte-order mark, so spreadsheet programs read the umlauts of meter names as UTF-8.
|
||||||
|
await using var writer = new StreamWriter(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), leaveOpen: true);
|
||||||
|
await AnalysisCsvWriter.WriteAsync(writer, prepared.Rows, ct);
|
||||||
|
},
|
||||||
|
"text/csv; charset=utf-8",
|
||||||
|
prepared.FileName);
|
||||||
|
})
|
||||||
|
.ExcludeFromDescription();
|
||||||
|
|
||||||
|
return endpoints;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
using MeterVault.Core.Analysis;
|
||||||
|
using MeterVault.Core.Analysis.Totals;
|
||||||
|
|
||||||
|
namespace MeterVault.App.Analysis;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What an analysis view charts (D-47 <c>metric=consumption|generation|export|runtime|net|cost|balance</c>): a
|
||||||
|
/// quantity kind, the cost, or a tank's balance. The URL token is the stable identifier; the visible label comes from
|
||||||
|
/// <see cref="Localization.DisplayNames"/>.
|
||||||
|
/// </summary>
|
||||||
|
public enum AnalysisMetric
|
||||||
|
{
|
||||||
|
Consumption,
|
||||||
|
Generation,
|
||||||
|
Export,
|
||||||
|
Runtime,
|
||||||
|
|
||||||
|
/// <summary>A signed virtual result (a difference of kinds, D-26).</summary>
|
||||||
|
Net,
|
||||||
|
|
||||||
|
/// <summary>The cost of the scope (D-34 – D-43).</summary>
|
||||||
|
Cost,
|
||||||
|
|
||||||
|
/// <summary>A tank's level over time (consumables).</summary>
|
||||||
|
Balance,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>URL tokens and meaning of <see cref="AnalysisMetric"/>.</summary>
|
||||||
|
public static class AnalysisMetrics
|
||||||
|
{
|
||||||
|
private static readonly (AnalysisMetric Value, string Token)[] Tokens =
|
||||||
|
[
|
||||||
|
(AnalysisMetric.Consumption, "consumption"),
|
||||||
|
(AnalysisMetric.Generation, "generation"),
|
||||||
|
(AnalysisMetric.Export, "export"),
|
||||||
|
(AnalysisMetric.Runtime, "runtime"),
|
||||||
|
(AnalysisMetric.Net, "net"),
|
||||||
|
(AnalysisMetric.Cost, "cost"),
|
||||||
|
(AnalysisMetric.Balance, "balance"),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>The URL token of a metric (<c>consumption</c>, <c>cost</c>, …).</summary>
|
||||||
|
public static string Format(AnalysisMetric metric)
|
||||||
|
{
|
||||||
|
foreach (var (value, token) in Tokens)
|
||||||
|
{
|
||||||
|
if (value == metric)
|
||||||
|
{
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(metric), metric, "No URL token for this metric.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Parses a metric token, ignoring case and surrounding blanks; false for anything else.</summary>
|
||||||
|
public static bool TryParse(string? token, out AnalysisMetric metric)
|
||||||
|
{
|
||||||
|
metric = default;
|
||||||
|
if (string.IsNullOrWhiteSpace(token))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var text = token.Trim();
|
||||||
|
foreach (var (value, name) in Tokens)
|
||||||
|
{
|
||||||
|
if (string.Equals(name, text, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
metric = value;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>True for the quantity metrics (everything but cost and balance), which the analysis reader answers.</summary>
|
||||||
|
public static bool IsQuantity(this AnalysisMetric metric) => metric is not (AnalysisMetric.Cost or AnalysisMetric.Balance);
|
||||||
|
|
||||||
|
/// <summary>The quantity kind a quantity metric charts; null for cost and balance.</summary>
|
||||||
|
public static QuantityKind? QuantityKindOf(AnalysisMetric metric) => metric switch
|
||||||
|
{
|
||||||
|
AnalysisMetric.Consumption => QuantityKind.Consumption,
|
||||||
|
AnalysisMetric.Generation => QuantityKind.Generation,
|
||||||
|
AnalysisMetric.Export => QuantityKind.Export,
|
||||||
|
AnalysisMetric.Runtime => QuantityKind.Runtime,
|
||||||
|
AnalysisMetric.Net => QuantityKind.Net,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The per-type measures (D-22) a quantity metric shows for an energy type or the portfolio: consumption is the
|
||||||
|
/// household use and, separately, the billed grid import (never added to each other); net has no measure, being a
|
||||||
|
/// virtual meter's own result.
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyList<TotalsMeasure> MeasuresOf(AnalysisMetric metric) => metric switch
|
||||||
|
{
|
||||||
|
AnalysisMetric.Consumption => [TotalsMeasure.Use, TotalsMeasure.GridImport],
|
||||||
|
AnalysisMetric.Generation => [TotalsMeasure.Generation],
|
||||||
|
AnalysisMetric.Export => [TotalsMeasure.Export],
|
||||||
|
AnalysisMetric.Runtime => [TotalsMeasure.Runtime],
|
||||||
|
_ => [],
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>The metric a quantity kind is charted under; <see cref="QuantityKind.Indicator"/> has none.</summary>
|
||||||
|
public static AnalysisMetric? MetricOf(QuantityKind kind) => kind switch
|
||||||
|
{
|
||||||
|
QuantityKind.Consumption => AnalysisMetric.Consumption,
|
||||||
|
QuantityKind.Generation => AnalysisMetric.Generation,
|
||||||
|
QuantityKind.Export => AnalysisMetric.Export,
|
||||||
|
QuantityKind.Runtime => AnalysisMetric.Runtime,
|
||||||
|
QuantityKind.Net => AnalysisMetric.Net,
|
||||||
|
QuantityKind.Cost => AnalysisMetric.Cost,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
using MeterVault.App.Localization;
|
||||||
|
using MeterVault.Core.Analysis;
|
||||||
|
using MeterVault.Core.Analysis.Coverage;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
|
||||||
|
namespace MeterVault.App.Analysis;
|
||||||
|
|
||||||
|
/// <summary>One breadcrumb: its text (a name is user data) and its link; null for the current page.</summary>
|
||||||
|
public sealed record Crumb(string Text, string? Href);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Where the analysis components lead (D-46, D-48, D-51, brief §4.3): replacing the page's analysis state from the
|
||||||
|
/// toolbar, drilling into a bucket, going to the latest data, and the breadcrumb trail — each keeping the period.
|
||||||
|
/// </summary>
|
||||||
|
public static class AnalysisNavigation
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Writes <paramref name="query"/> into the current page's address, replacing the history entry (D-46: toolbar and
|
||||||
|
/// tab changes replace, drill-downs push). Keys equal to the page defaults are removed; other keys (<c>tab</c>) stay.
|
||||||
|
/// </summary>
|
||||||
|
public static void Replace(NavigationManager navigation, AnalysisQuery query, AnalysisDefaults defaults)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(navigation);
|
||||||
|
ArgumentNullException.ThrowIfNull(query);
|
||||||
|
ArgumentNullException.ThrowIfNull(defaults);
|
||||||
|
|
||||||
|
navigation.NavigateTo(navigation.GetUriWithQueryParameters(query.ToNavigationParameters(defaults)), replace: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The address of the current page showing <paramref name="query"/> (for a drill-down, which pushes a new history
|
||||||
|
/// entry: <c>Nav.NavigateTo(AnalysisNavigation.UriFor(Nav, next, Defaults))</c>).
|
||||||
|
/// </summary>
|
||||||
|
public static string UriFor(NavigationManager navigation, AnalysisQuery query, AnalysisDefaults defaults)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(navigation);
|
||||||
|
ArgumentNullException.ThrowIfNull(query);
|
||||||
|
ArgumentNullException.ThrowIfNull(defaults);
|
||||||
|
|
||||||
|
return navigation.GetUriWithQueryParameters(query.ToNavigationParameters(defaults));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The local days a bucket stands for, both inclusive: a bucket cut at now stands for its whole unit (its <see cref="AnalysisBucket.NominalEndDay"/>).</summary>
|
||||||
|
public static (DateOnly First, DateOnly Last) DaysOf(AnalysisBucket bucket)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(bucket);
|
||||||
|
|
||||||
|
var end = bucket.NominalEndDay ?? bucket.EndDay;
|
||||||
|
return (bucket.FirstDay, end > bucket.FirstDay ? end.AddDays(-1) : bucket.FirstDay);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The finer bucket sizes a drill into <paramref name="size"/> may use, most useful first: a year opens its months,
|
||||||
|
/// a month its days (or weeks, when the data resolves weeks but not days), a week its days; a day has none.
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyList<BucketSize> FinerSizes(BucketSize size) => size switch
|
||||||
|
{
|
||||||
|
BucketSize.Year => [BucketSize.Month],
|
||||||
|
BucketSize.Month => [BucketSize.Day, BucketSize.Week],
|
||||||
|
BucketSize.Week => [BucketSize.Day],
|
||||||
|
_ => [],
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The drill-down of a chart bucket (D-51): the same scope, metric and comparison over the bucket's days, in the next
|
||||||
|
/// finer bucket the data supports. Null when there is none — a day, or data too coarse for anything finer (a monthly
|
||||||
|
/// import) — and the page opens the bucket's records instead (<see cref="NormalizedData"/>).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="query">The page's analysis state.</param>
|
||||||
|
/// <param name="bucket">The clicked bucket.</param>
|
||||||
|
/// <param name="coarsestResolution">
|
||||||
|
/// The coarsest resolution among the charted series (<see cref="Infrastructure.Analysis.AnalysisSeries.Resolution"/>);
|
||||||
|
/// null when unknown, which allows any finer size.
|
||||||
|
/// </param>
|
||||||
|
/// <remarks>
|
||||||
|
/// A named-year comparison (<c>year:2024</c>) needs a calendar year; drilling below a year turns it into the same
|
||||||
|
/// period a year earlier, which is what a year comparison of a month means.
|
||||||
|
/// </remarks>
|
||||||
|
public static AnalysisQuery? DrillInto(AnalysisQuery query, AnalysisBucket bucket, ResolutionClass? coarsestResolution = null)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(query);
|
||||||
|
ArgumentNullException.ThrowIfNull(bucket);
|
||||||
|
|
||||||
|
var floor = coarsestResolution is { } resolution ? BucketPlanner.MinimumSizeFor(resolution) : BucketSize.Day;
|
||||||
|
BucketSize? finer = null;
|
||||||
|
foreach (var size in FinerSizes(bucket.Size))
|
||||||
|
{
|
||||||
|
if (size >= floor)
|
||||||
|
{
|
||||||
|
finer = size;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (first, last) = DaysOf(bucket);
|
||||||
|
if (finer is not { } next || !PeriodResolver.IsValidCustomRange(first, last))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var drilled = query.WithCustomRange(first, last).WithBucket(next);
|
||||||
|
var wholeYear = first is { Month: 1, Day: 1 } && last.Month == 12 && last.Day == 31 && first.Year == last.Year;
|
||||||
|
return query.Comparison.Kind == ComparisonKind.Year && !wholeYear
|
||||||
|
? drilled.WithComparison(new ComparisonRequest(ComparisonKind.PreviousYear))
|
||||||
|
: drilled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The meter's Normalized data tab filtered to a bucket's days (D-50, D-51), keeping the rest of the analysis state.</summary>
|
||||||
|
public static string NormalizedData(int meterId, AnalysisQuery query, AnalysisBucket bucket)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(query);
|
||||||
|
|
||||||
|
var (first, last) = DaysOf(bucket);
|
||||||
|
var target = PeriodResolver.IsValidCustomRange(first, last) ? query.WithCustomRange(first, last) : query;
|
||||||
|
return MeterLinks.Detail(meterId, MeterLinks.TabNormalized, null, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// "Go to latest data" (brief §4.3): a period of the same kind ending with the latest data — its month for a month
|
||||||
|
/// preset, its calendar year for a year preset, the 12 or 24 months up to it for those, a custom range of the same
|
||||||
|
/// length ending on the last available day. Null without availability.
|
||||||
|
/// </summary>
|
||||||
|
public static AnalysisQuery? LatestData(AnalysisQuery query, AvailableRange? availability)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(query);
|
||||||
|
|
||||||
|
if (availability is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastDay = availability.LastDay;
|
||||||
|
var month = new DateOnly(lastDay.Year, lastDay.Month, 1);
|
||||||
|
var monthEnd = month.AddMonths(1).AddDays(-1);
|
||||||
|
var (first, last) = query.Period switch
|
||||||
|
{
|
||||||
|
PeriodPreset.MonthToDate or PeriodPreset.LastMonth => (month, monthEnd),
|
||||||
|
PeriodPreset.YearToDate or PeriodPreset.PreviousYear => (new DateOnly(lastDay.Year, 1, 1), new DateOnly(lastDay.Year, 12, 31)),
|
||||||
|
PeriodPreset.Last24Months => (month.AddMonths(-23), monthEnd),
|
||||||
|
PeriodPreset.Custom when query.From is { } from && query.To is { } to && to >= from =>
|
||||||
|
(lastDay.AddDays(from.DayNumber - to.DayNumber), lastDay),
|
||||||
|
_ => (month.AddMonths(-11), monthEnd),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (first < PeriodResolver.MinSupportedDate)
|
||||||
|
{
|
||||||
|
first = PeriodResolver.MinSupportedDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
return PeriodResolver.IsValidCustomRange(first, last) ? query.WithCustomRange(first, last) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The breadcrumb trail (D-48, brief §3.1): Overview → energy type → meter, each link carrying the period, bucket,
|
||||||
|
/// comparison and metric of <paramref name="query"/>; the last crumb is the current page and has no link.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="query">The current page's analysis state; null carries nothing.</param>
|
||||||
|
/// <param name="energyType">The energy type (id, name), when the page is inside one.</param>
|
||||||
|
/// <param name="meter">The meter (id, name), when the page is a meter's.</param>
|
||||||
|
/// <param name="current">A label for the current page below them (a tab, a specialized view); null when the last of the above is the page.</param>
|
||||||
|
public static IReadOnlyList<Crumb> Breadcrumbs(
|
||||||
|
AnalysisQuery? query,
|
||||||
|
(int Id, string Name)? energyType = null,
|
||||||
|
(int Id, string Name)? meter = null,
|
||||||
|
string? current = null)
|
||||||
|
{
|
||||||
|
var trail = new List<Crumb> { new(Strings.Nav_Overview, AnalysisLinks.Overview(query)) };
|
||||||
|
if (energyType is { } type)
|
||||||
|
{
|
||||||
|
trail.Add(new Crumb(type.Name, AnalysisLinks.EnergyType(type.Id, null, query)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meter is { } m)
|
||||||
|
{
|
||||||
|
trail.Add(new Crumb(m.Name, MeterLinks.Analysis(m.Id, query)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(current))
|
||||||
|
{
|
||||||
|
trail.Add(new Crumb(current, null));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
trail[^1] = trail[^1] with { Href = null };
|
||||||
|
}
|
||||||
|
|
||||||
|
return trail;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using MeterVault.Core.Analysis;
|
||||||
|
using MeterVault.Core.Analysis.Coverage;
|
||||||
|
using MeterVault.Infrastructure.Analysis;
|
||||||
|
using MeterVault.Infrastructure.Costing;
|
||||||
|
|
||||||
|
namespace MeterVault.App.Analysis;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves an <see cref="AnalysisQuery"/> the way every page and the CSV export do (D-01, D-02, D-19): against the
|
||||||
|
/// captured now, in the zone the readers cut days in, and — for <c>all</c> only — over the scope's availability, which
|
||||||
|
/// is the one thing resolving has to read.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Availability follows what the query shows: the cost scope's (billed meters plus manual costs) for the cost metric and
|
||||||
|
/// for a category, the quantity scope's otherwise (D-19). Nothing is read for any other preset.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class AnalysisPeriods(AnalysisReader reader, CostReader costs)
|
||||||
|
{
|
||||||
|
/// <summary>The zone periods are resolved in: the readers' (<c>MeterVault__TimeZone</c>).</summary>
|
||||||
|
public TimeZoneInfo Zone => reader.Zone;
|
||||||
|
|
||||||
|
/// <summary>Resolves <paramref name="query"/> as of <paramref name="now"/>.</summary>
|
||||||
|
public async Task<ResolvedPeriod> ResolveAsync(AnalysisQuery query, DateTimeOffset now, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(query);
|
||||||
|
|
||||||
|
var availability = query.Period == PeriodPreset.AllHistory
|
||||||
|
? await AvailabilityAsync(query, now, cancellationToken).ConfigureAwait(false)
|
||||||
|
: null;
|
||||||
|
return query.Resolve(now, reader.Zone, availability);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>What the query's scope has data for as of <paramref name="now"/>, capped at now (D-19); null without any.</summary>
|
||||||
|
public async Task<AvailableRange?> AvailabilityAsync(AnalysisQuery query, DateTimeOffset now, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(query);
|
||||||
|
|
||||||
|
if (query.Metric == AnalysisMetric.Cost || query.Scope.ToAnalysisScope() is not { } scope)
|
||||||
|
{
|
||||||
|
var ranges = new List<AvailableRange?>();
|
||||||
|
foreach (var costScope in query.Scope.ToCostScopes())
|
||||||
|
{
|
||||||
|
var availability = await costs.GetAvailabilityAsync(costScope, now, cancellationToken).ConfigureAwait(false);
|
||||||
|
ranges.Add(availability.Range);
|
||||||
|
}
|
||||||
|
|
||||||
|
return AvailableRange.Union(ranges, costs.Zone);
|
||||||
|
}
|
||||||
|
|
||||||
|
var quantity = await reader.GetAvailabilityAsync(scope, now, cancellationToken).ConfigureAwait(false);
|
||||||
|
return quantity.Quantity;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,725 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
using MeterVault.Core.Analysis;
|
||||||
|
using MeterVault.Core.Analysis.Coverage;
|
||||||
|
using MeterVault.Infrastructure.Analysis;
|
||||||
|
using MeterVault.Infrastructure.Costing;
|
||||||
|
using Microsoft.AspNetCore.WebUtilities;
|
||||||
|
using Microsoft.Extensions.Primitives;
|
||||||
|
|
||||||
|
namespace MeterVault.App.Analysis;
|
||||||
|
|
||||||
|
/// <summary>The URL keys of the analysis state (D-02, D-46, D-47). Stable invariant identifiers, never localized.</summary>
|
||||||
|
public static class AnalysisUrlKeys
|
||||||
|
{
|
||||||
|
public const string Scope = "scope";
|
||||||
|
public const string Id = "id";
|
||||||
|
public const string Ids = "ids";
|
||||||
|
public const string Metric = "metric";
|
||||||
|
public const string Period = "period";
|
||||||
|
public const string From = "from";
|
||||||
|
public const string To = "to";
|
||||||
|
public const string Bucket = "bucket";
|
||||||
|
public const string Compare = "compare";
|
||||||
|
|
||||||
|
/// <summary>Every analysis key, in the order links write them.</summary>
|
||||||
|
public static IReadOnlyList<string> All { get; } = [Scope, Id, Ids, Metric, Period, From, To, Bucket, Compare];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Which parts of an <see cref="AnalysisQuery"/> a URL is written with.</summary>
|
||||||
|
[Flags]
|
||||||
|
public enum AnalysisQueryParts
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
|
||||||
|
/// <summary><c>scope</c>, <c>id</c>, <c>ids</c>.</summary>
|
||||||
|
Scope = 1,
|
||||||
|
|
||||||
|
/// <summary><c>metric</c>.</summary>
|
||||||
|
Metric = 2,
|
||||||
|
|
||||||
|
/// <summary><c>period</c>, <c>from</c>, <c>to</c>.</summary>
|
||||||
|
Period = 4,
|
||||||
|
|
||||||
|
/// <summary><c>bucket</c>.</summary>
|
||||||
|
Bucket = 8,
|
||||||
|
|
||||||
|
/// <summary><c>compare</c>.</summary>
|
||||||
|
Comparison = 16,
|
||||||
|
|
||||||
|
/// <summary>What a link carries onward to another page (D-47): everything but the scope, which the target's route names.</summary>
|
||||||
|
Carry = Metric | Period | Bucket | Comparison,
|
||||||
|
|
||||||
|
All = Scope | Carry,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The analysis state of a page, parsed from its URL (D-02, D-46, D-47, brief §4.1): period preset (or custom dates),
|
||||||
|
/// bucket size, comparison, metric and scope — immutable and compared by value, so a page reloads its analysis only
|
||||||
|
/// when this value changes.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Reading.</b> <see cref="Parse(string, AnalysisDefaults)"/> reads the keys of <see cref="AnalysisUrlKeys"/>. A key
|
||||||
|
/// that is absent takes the page default (<see cref="AnalysisDefaults"/>); a key with an invalid value takes the default
|
||||||
|
/// too and adds a <see cref="Notices">notice</see> — a hand-edited or stale link never breaks the page (D-02). Tokens
|
||||||
|
/// are read case-insensitively; <c>previous-year</c> / <c>previous-period</c> are accepted for <c>prev-year</c> /
|
||||||
|
/// <c>prev-period</c>. <c>from</c>/<c>to</c> (yyyy-MM-dd, inclusive) make a custom range when <c>period</c> is
|
||||||
|
/// <c>custom</c> or absent, and are ignored beside another preset. Explicit meter selections keep at most
|
||||||
|
/// <see cref="AnalysisLimits.MaxSeries"/> meters.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Writing.</b> <see cref="ToQueryParameters"/>, <see cref="ToNavigationParameters"/> and <see cref="AppendTo"/>
|
||||||
|
/// write the canonical tokens and omit every key equal to the <em>target</em> page's default; a custom range is written
|
||||||
|
/// as <c>from</c> and <c>to</c> alone. Links to another page carry <see cref="AnalysisQueryParts.Carry"/> (period,
|
||||||
|
/// bucket, comparison, metric), because the target's route names its scope.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Resolving.</b> <see cref="Resolve"/> turns the preset into a <see cref="ResolvedPeriod"/> through
|
||||||
|
/// <see cref="PeriodResolver"/>, once per load, against a captured now and the instance zone; <c>all</c> spans the
|
||||||
|
/// scope's availability (D-19). <see cref="ToAnalysisRequest"/>, <see cref="ToCostRequests"/> and
|
||||||
|
/// <see cref="ToCostComparison"/> build the reader requests in one place, so pages and the CSV export ask identically.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="Notices"/> are not part of the value: two URLs that resolve to the same state are equal, whatever was
|
||||||
|
/// wrong with them. The <c>With…</c> helpers return a query without notices — a choice made in the toolbar is clean.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class AnalysisQuery : IEquatable<AnalysisQuery>
|
||||||
|
{
|
||||||
|
private AnalysisQuery(
|
||||||
|
PeriodPreset period,
|
||||||
|
DateOnly? from,
|
||||||
|
DateOnly? to,
|
||||||
|
BucketSize bucket,
|
||||||
|
ComparisonRequest comparison,
|
||||||
|
AnalysisMetric? metric,
|
||||||
|
QueryScope scope,
|
||||||
|
IReadOnlyList<AnalysisQueryNotice> notices)
|
||||||
|
{
|
||||||
|
Period = period;
|
||||||
|
From = from;
|
||||||
|
To = to;
|
||||||
|
Bucket = bucket;
|
||||||
|
Comparison = comparison;
|
||||||
|
Metric = metric;
|
||||||
|
Scope = scope;
|
||||||
|
Notices = notices;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The period preset; <see cref="PeriodPreset.Custom"/> with <see cref="From"/>/<see cref="To"/>.</summary>
|
||||||
|
public PeriodPreset Period { get; }
|
||||||
|
|
||||||
|
/// <summary>The first local day of a custom range (inclusive); null for a preset.</summary>
|
||||||
|
public DateOnly? From { get; }
|
||||||
|
|
||||||
|
/// <summary>The last local day of a custom range (inclusive); null for a preset.</summary>
|
||||||
|
public DateOnly? To { get; }
|
||||||
|
|
||||||
|
public BucketSize Bucket { get; }
|
||||||
|
|
||||||
|
public ComparisonRequest Comparison { get; }
|
||||||
|
|
||||||
|
/// <summary>The chosen metric; null for the scope's natural one (the page decides).</summary>
|
||||||
|
public AnalysisMetric? Metric { get; }
|
||||||
|
|
||||||
|
public QueryScope Scope { get; }
|
||||||
|
|
||||||
|
/// <summary>What in the URL was not used as written; not part of equality.</summary>
|
||||||
|
public IReadOnlyList<AnalysisQueryNotice> Notices { get; }
|
||||||
|
|
||||||
|
public bool IsCustom => Period == PeriodPreset.Custom;
|
||||||
|
|
||||||
|
/// <summary>The page defaults as a query: what a page shows with no analysis keys in its URL.</summary>
|
||||||
|
public static AnalysisQuery Default(AnalysisDefaults defaults)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(defaults);
|
||||||
|
|
||||||
|
return new AnalysisQuery(defaults.Period, null, null, defaults.Bucket, defaults.Comparison, defaults.Metric, defaults.Scope, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses the analysis keys of a URL — absolute (<see cref="Microsoft.AspNetCore.Components.NavigationManager.Uri"/>),
|
||||||
|
/// base-relative, or a query string starting with <c>?</c>. Anything without a <c>?</c> has no keys.
|
||||||
|
/// </summary>
|
||||||
|
public static AnalysisQuery Parse(string? uriOrQuery, AnalysisDefaults defaults) =>
|
||||||
|
Parse(QueryHelpers.ParseQuery(QueryOf(uriOrQuery)), defaults);
|
||||||
|
|
||||||
|
/// <summary>Parses the analysis keys of <paramref name="uri"/>.</summary>
|
||||||
|
public static AnalysisQuery Parse(Uri uri, AnalysisDefaults defaults)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(uri);
|
||||||
|
|
||||||
|
return Parse(uri.IsAbsoluteUri ? uri.Query : uri.OriginalString, defaults);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Parses the analysis keys of a query collection (<see cref="Microsoft.AspNetCore.Http.IQueryCollection"/>, a parsed query).</summary>
|
||||||
|
public static AnalysisQuery Parse(IEnumerable<KeyValuePair<string, StringValues>> query, AnalysisDefaults defaults)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(query);
|
||||||
|
ArgumentNullException.ThrowIfNull(defaults);
|
||||||
|
|
||||||
|
var keys = new Dictionary<string, StringValues>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var (key, values) in query)
|
||||||
|
{
|
||||||
|
if (key is not null)
|
||||||
|
{
|
||||||
|
keys[key] = keys.TryGetValue(key, out var existing) ? StringValues.Concat(existing, values) : values;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var reader = new Reader(keys);
|
||||||
|
var notices = new List<AnalysisQueryNotice>();
|
||||||
|
|
||||||
|
var (period, from, to) = ParsePeriod(reader, defaults, notices);
|
||||||
|
var bucket = ParseToken(reader, AnalysisUrlKeys.Bucket, defaults.Bucket, AnalysisTokens.TryParseBucket, AnalysisQueryNoticeKind.InvalidBucket, notices);
|
||||||
|
var comparison = ParseComparison(reader, defaults, notices);
|
||||||
|
var metric = ParseMetric(reader, defaults, notices);
|
||||||
|
var scope = ParseScope(reader, defaults, notices);
|
||||||
|
|
||||||
|
return new AnalysisQuery(period, from, to, bucket, comparison, metric, scope, notices);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>This query with a preset period.</summary>
|
||||||
|
/// <exception cref="ArgumentException"><see cref="PeriodPreset.Custom"/>: use <see cref="WithCustomRange"/>.</exception>
|
||||||
|
public AnalysisQuery WithPeriod(PeriodPreset preset)
|
||||||
|
{
|
||||||
|
if (preset == PeriodPreset.Custom || !Enum.IsDefined(preset))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("A custom period needs its dates; use WithCustomRange.", nameof(preset));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AnalysisQuery(preset, null, null, Bucket, Comparison, Metric, Scope, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>This query with a custom range of local days, both inclusive.</summary>
|
||||||
|
/// <exception cref="ArgumentException">The range fails <see cref="PeriodResolver.IsValidCustomRange"/>: check it first (the toolbar applies a range only once it is valid).</exception>
|
||||||
|
public AnalysisQuery WithCustomRange(DateOnly first, DateOnly last)
|
||||||
|
{
|
||||||
|
if (!PeriodResolver.IsValidCustomRange(first, last))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("A custom range needs a first and last day in order, within the supported dates.", nameof(first));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AnalysisQuery(PeriodPreset.Custom, first, last, Bucket, Comparison, Metric, Scope, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public AnalysisQuery WithBucket(BucketSize bucket) =>
|
||||||
|
Enum.IsDefined(bucket)
|
||||||
|
? new AnalysisQuery(Period, From, To, bucket, Comparison, Metric, Scope, [])
|
||||||
|
: throw new ArgumentOutOfRangeException(nameof(bucket), bucket, "Unknown bucket size.");
|
||||||
|
|
||||||
|
/// <exception cref="ArgumentException">A <see cref="ComparisonKind.Year"/> comparison without its year, which no URL can hold.</exception>
|
||||||
|
public AnalysisQuery WithComparison(ComparisonRequest comparison)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(comparison);
|
||||||
|
if (comparison.Kind == ComparisonKind.Year && comparison.Year is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("A year comparison needs its year.", nameof(comparison));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AnalysisQuery(Period, From, To, Bucket, comparison, Metric, Scope, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>This query with a metric; null for the scope's natural one.</summary>
|
||||||
|
public AnalysisQuery WithMetric(AnalysisMetric? metric) => new(Period, From, To, Bucket, Comparison, metric, Scope, []);
|
||||||
|
|
||||||
|
public AnalysisQuery WithScope(QueryScope scope)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(scope);
|
||||||
|
|
||||||
|
return new AnalysisQuery(Period, From, To, Bucket, Comparison, Metric, scope, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The URL parameters of this query, in canonical order and tokens, leaving out every key equal to
|
||||||
|
/// <paramref name="defaults"/> (the target page's) and every part not in <paramref name="parts"/>.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<KeyValuePair<string, string>> ToQueryParameters(AnalysisDefaults defaults, AnalysisQueryParts parts = AnalysisQueryParts.All)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(defaults);
|
||||||
|
|
||||||
|
var list = new List<KeyValuePair<string, string>>(6);
|
||||||
|
if (parts.HasFlag(AnalysisQueryParts.Scope) && !Scope.Equals(defaults.Scope))
|
||||||
|
{
|
||||||
|
list.Add(new(AnalysisUrlKeys.Scope, Scope.Token));
|
||||||
|
if (Scope.Kind == QueryScopeKind.Meters)
|
||||||
|
{
|
||||||
|
list.Add(new(AnalysisUrlKeys.Ids, string.Join(',', Scope.MeterIds.Select(id => id.ToString(CultureInfo.InvariantCulture)))));
|
||||||
|
}
|
||||||
|
else if (Scope.Id is { } id)
|
||||||
|
{
|
||||||
|
list.Add(new(AnalysisUrlKeys.Id, id.ToString(CultureInfo.InvariantCulture)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.HasFlag(AnalysisQueryParts.Metric) && Metric is { } metric && metric != defaults.Metric)
|
||||||
|
{
|
||||||
|
list.Add(new(AnalysisUrlKeys.Metric, AnalysisMetrics.Format(metric)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.HasFlag(AnalysisQueryParts.Period))
|
||||||
|
{
|
||||||
|
if (IsCustom)
|
||||||
|
{
|
||||||
|
// from/to imply custom, so the preset key is left out.
|
||||||
|
list.Add(new(AnalysisUrlKeys.From, AnalysisTokens.FormatDate(From!.Value)));
|
||||||
|
list.Add(new(AnalysisUrlKeys.To, AnalysisTokens.FormatDate(To!.Value)));
|
||||||
|
}
|
||||||
|
else if (Period != defaults.Period)
|
||||||
|
{
|
||||||
|
list.Add(new(AnalysisUrlKeys.Period, AnalysisTokens.Format(Period)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.HasFlag(AnalysisQueryParts.Bucket) && Bucket != defaults.Bucket)
|
||||||
|
{
|
||||||
|
list.Add(new(AnalysisUrlKeys.Bucket, AnalysisTokens.Format(Bucket)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.HasFlag(AnalysisQueryParts.Comparison) && !Comparison.Equals(defaults.Comparison))
|
||||||
|
{
|
||||||
|
list.Add(new(AnalysisUrlKeys.Compare, AnalysisTokens.Format(Comparison)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Every analysis key of <paramref name="parts"/> for
|
||||||
|
/// <see cref="Microsoft.AspNetCore.Components.NavigationManager.GetUriWithQueryParameters(IReadOnlyDictionary{string, object?})"/>:
|
||||||
|
/// the value to write, or null to remove a key that equals the default — so updating the current page's URL keeps
|
||||||
|
/// its other keys (<c>tab</c>) and drops stale ones (<c>from</c>/<c>to</c> after leaving a custom range).
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyDictionary<string, object?> ToNavigationParameters(AnalysisDefaults defaults, AnalysisQueryParts parts = AnalysisQueryParts.All)
|
||||||
|
{
|
||||||
|
var result = new Dictionary<string, object?>(StringComparer.Ordinal);
|
||||||
|
foreach (var key in KeysOf(parts))
|
||||||
|
{
|
||||||
|
result[key] = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var (key, value) in ToQueryParameters(defaults, parts))
|
||||||
|
{
|
||||||
|
result[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <paramref name="url"/> with this query's parameters appended after its own (D-47: existing keys first), leaving
|
||||||
|
/// out what equals <paramref name="defaults"/> — the target page's. Links carry <see cref="AnalysisQueryParts.Carry"/>
|
||||||
|
/// by default: the target's route names its own scope.
|
||||||
|
/// </summary>
|
||||||
|
public string AppendTo(string url, AnalysisDefaults defaults, AnalysisQueryParts parts = AnalysisQueryParts.Carry)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(url);
|
||||||
|
|
||||||
|
var parameters = ToQueryParameters(defaults, parts);
|
||||||
|
if (parameters.Count == 0)
|
||||||
|
{
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
var builder = new StringBuilder(url);
|
||||||
|
var separator = url.Contains('?', StringComparison.Ordinal) ? '&' : '?';
|
||||||
|
foreach (var (key, value) in parameters)
|
||||||
|
{
|
||||||
|
builder.Append(separator).Append(key).Append('=').Append(Escape(value));
|
||||||
|
separator = '&';
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the period once, against the captured <paramref name="now"/> in the instance <paramref name="zone"/>
|
||||||
|
/// (D-01, D-03). <c>all</c> spans <paramref name="availability"/> (D-19) — the quantity or cost scope's, whichever
|
||||||
|
/// the page shows — and is the "no history" range without it.
|
||||||
|
/// </summary>
|
||||||
|
public ResolvedPeriod Resolve(DateTimeOffset now, TimeZoneInfo zone, AvailableRange? availability = null)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(zone);
|
||||||
|
|
||||||
|
return Period switch
|
||||||
|
{
|
||||||
|
PeriodPreset.Custom => PeriodResolver.Resolve(PeriodPreset.Custom, From, To, now, zone),
|
||||||
|
PeriodPreset.AllHistory => PeriodResolver.Resolve(
|
||||||
|
PeriodPreset.AllHistory, null, null, now, zone, availability?.FirstDay, availability?.LastDay),
|
||||||
|
_ => PeriodResolver.Resolve(Period, null, null, now, zone),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The quantity request of this query over <paramref name="period"/> (bucket and comparison included), or null for
|
||||||
|
/// a category scope, which is analysed by cost.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="period">The period from <see cref="Resolve"/>.</param>
|
||||||
|
/// <param name="includeMeterSeries">For a type or portfolio: also one series per meter ("individual meters").</param>
|
||||||
|
public AnalysisRequest? ToAnalysisRequest(ResolvedPeriod period, bool includeMeterSeries = false)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(period);
|
||||||
|
|
||||||
|
return Scope.ToAnalysisScope() is { } scope
|
||||||
|
? new AnalysisRequest(scope, period) { Bucket = Bucket, Comparison = Comparison, IncludeMeterSeries = includeMeterSeries }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The cost requests of this query over <paramref name="period"/>: one for the portfolio, a type, a meter or a
|
||||||
|
/// category, one per meter for a selection.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="period">The period from <see cref="Resolve"/>.</param>
|
||||||
|
/// <param name="plan">
|
||||||
|
/// Buckets to price in — a quantity result's <see cref="AnalysisResult.Plan"/>, or the first cost result's for the
|
||||||
|
/// rest of a selection — so cost and quantity share their buckets; null lets the cost reader plan from
|
||||||
|
/// <see cref="Bucket"/>.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="includeCategories">For the portfolio: also the category composition (D-42).</param>
|
||||||
|
public IReadOnlyList<CostAnalysisRequest> ToCostRequests(ResolvedPeriod period, BucketPlan? plan = null, bool includeCategories = false)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(period);
|
||||||
|
|
||||||
|
return
|
||||||
|
[
|
||||||
|
.. Scope.ToCostScopes().Select(scope => new CostAnalysisRequest(scope, period)
|
||||||
|
{
|
||||||
|
Bucket = Bucket,
|
||||||
|
Plan = plan,
|
||||||
|
IncludeCategories = includeCategories && scope.Kind == CostScopeKind.Portfolio,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The cost request for this query's comparison (D-06) of an already priced <paramref name="current"/> request: the
|
||||||
|
/// comparison period, priced in the images of the current buckets (<see cref="ComparisonResolver.PairBuckets"/>), so
|
||||||
|
/// bucket i of the result compares with bucket i of the current one. The cost reader has no comparison of its own;
|
||||||
|
/// quantities get theirs from the analysis reader (<see cref="AnalysisSeries.Comparison"/>).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="current">The current cost request (its scope and period).</param>
|
||||||
|
/// <param name="currentPlan">The plan the current result was priced in (<see cref="CostAnalysis.Plan"/>).</param>
|
||||||
|
public CostComparisonRequest ToCostComparison(CostAnalysisRequest current, BucketPlan currentPlan)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(current);
|
||||||
|
ArgumentNullException.ThrowIfNull(currentPlan);
|
||||||
|
|
||||||
|
var resolution = ComparisonResolver.Resolve(current.Period, Comparison);
|
||||||
|
if (!resolution.IsApplicable)
|
||||||
|
{
|
||||||
|
return new CostComparisonRequest(resolution, null, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
var pairs = ComparisonResolver.PairBuckets(current.Period, resolution.Period, currentPlan.Buckets);
|
||||||
|
var plan = new BucketPlan(currentPlan.Size, currentPlan.Size, [.. pairs.Select(p => p.Comparison)], pairs.Count, Refused: false, Suggested: null);
|
||||||
|
var request = new CostAnalysisRequest(current.Scope, resolution.Period.ToResolvedPeriod(current.Period))
|
||||||
|
{
|
||||||
|
Plan = plan,
|
||||||
|
MaxPoints = current.MaxPoints,
|
||||||
|
IncludeCategories = current.IncludeCategories,
|
||||||
|
};
|
||||||
|
return new CostComparisonRequest(resolution, request, pairs);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Equals(AnalysisQuery? other) =>
|
||||||
|
other is not null
|
||||||
|
&& Period == other.Period
|
||||||
|
&& From == other.From
|
||||||
|
&& To == other.To
|
||||||
|
&& Bucket == other.Bucket
|
||||||
|
&& Comparison.Equals(other.Comparison)
|
||||||
|
&& Metric == other.Metric
|
||||||
|
&& Scope.Equals(other.Scope);
|
||||||
|
|
||||||
|
public override bool Equals(object? obj) => Equals(obj as AnalysisQuery);
|
||||||
|
|
||||||
|
public override int GetHashCode() => HashCode.Combine(Period, From, To, Bucket, Comparison, Metric, Scope);
|
||||||
|
|
||||||
|
public static bool operator ==(AnalysisQuery? left, AnalysisQuery? right) => left is null ? right is null : left.Equals(right);
|
||||||
|
|
||||||
|
public static bool operator !=(AnalysisQuery? left, AnalysisQuery? right) => !(left == right);
|
||||||
|
|
||||||
|
/// <summary>Every key written, defaults included — for logs.</summary>
|
||||||
|
public override string ToString()
|
||||||
|
{
|
||||||
|
var period = IsCustom
|
||||||
|
? AnalysisTokens.FormatDate(From!.Value) + ".." + AnalysisTokens.FormatDate(To!.Value)
|
||||||
|
: AnalysisTokens.Format(Period);
|
||||||
|
var metric = Metric is { } m ? AnalysisMetrics.Format(m) : "natural";
|
||||||
|
return string.Create(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
$"{Scope} {metric} {period} {AnalysisTokens.Format(Bucket)} {AnalysisTokens.Format(Comparison)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (PeriodPreset Period, DateOnly? From, DateOnly? To) ParsePeriod(Reader reader, AnalysisDefaults defaults, List<AnalysisQueryNotice> notices)
|
||||||
|
{
|
||||||
|
var periodToken = reader.First(AnalysisUrlKeys.Period);
|
||||||
|
var fromToken = reader.First(AnalysisUrlKeys.From);
|
||||||
|
var toToken = reader.First(AnalysisUrlKeys.To);
|
||||||
|
|
||||||
|
if (periodToken is not null)
|
||||||
|
{
|
||||||
|
if (!AnalysisTokens.TryParsePeriod(periodToken, out var preset))
|
||||||
|
{
|
||||||
|
notices.Add(new AnalysisQueryNotice(AnalysisQueryNoticeKind.InvalidPeriod, AnalysisUrlKeys.Period, periodToken));
|
||||||
|
return (defaults.Period, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Beside another preset, from/to mean nothing and are ignored.
|
||||||
|
if (preset != PeriodPreset.Custom)
|
||||||
|
{
|
||||||
|
return (preset, null, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (fromToken is null && toToken is null)
|
||||||
|
{
|
||||||
|
return (defaults.Period, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (AnalysisTokens.TryParseCustomRange(fromToken, toToken, out var first, out var last))
|
||||||
|
{
|
||||||
|
return (PeriodPreset.Custom, first, last);
|
||||||
|
}
|
||||||
|
|
||||||
|
notices.Add(new AnalysisQueryNotice(
|
||||||
|
AnalysisQueryNoticeKind.InvalidRange, AnalysisUrlKeys.From + "/" + AnalysisUrlKeys.To, (fromToken ?? string.Empty) + "/" + (toToken ?? string.Empty)));
|
||||||
|
return (defaults.Period, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ComparisonRequest ParseComparison(Reader reader, AnalysisDefaults defaults, List<AnalysisQueryNotice> notices)
|
||||||
|
{
|
||||||
|
var token = reader.First(AnalysisUrlKeys.Compare);
|
||||||
|
if (token is null)
|
||||||
|
{
|
||||||
|
return defaults.Comparison;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (AnalysisTokens.TryParseComparison(token, out var comparison))
|
||||||
|
{
|
||||||
|
return comparison;
|
||||||
|
}
|
||||||
|
|
||||||
|
notices.Add(new AnalysisQueryNotice(AnalysisQueryNoticeKind.InvalidComparison, AnalysisUrlKeys.Compare, token));
|
||||||
|
return defaults.Comparison;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AnalysisMetric? ParseMetric(Reader reader, AnalysisDefaults defaults, List<AnalysisQueryNotice> notices)
|
||||||
|
{
|
||||||
|
var token = reader.First(AnalysisUrlKeys.Metric);
|
||||||
|
if (token is null)
|
||||||
|
{
|
||||||
|
return defaults.Metric;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (AnalysisMetrics.TryParse(token, out var metric))
|
||||||
|
{
|
||||||
|
return metric;
|
||||||
|
}
|
||||||
|
|
||||||
|
notices.Add(new AnalysisQueryNotice(AnalysisQueryNoticeKind.InvalidMetric, AnalysisUrlKeys.Metric, token));
|
||||||
|
return defaults.Metric;
|
||||||
|
}
|
||||||
|
|
||||||
|
private delegate bool TokenParser<T>(string? token, out T value);
|
||||||
|
|
||||||
|
private static T ParseToken<T>(
|
||||||
|
Reader reader, string key, T fallback, TokenParser<T> parse, AnalysisQueryNoticeKind invalid, List<AnalysisQueryNotice> notices)
|
||||||
|
{
|
||||||
|
var token = reader.First(key);
|
||||||
|
if (token is null)
|
||||||
|
{
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parse(token, out var value))
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
notices.Add(new AnalysisQueryNotice(invalid, key, token));
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static QueryScope ParseScope(Reader reader, AnalysisDefaults defaults, List<AnalysisQueryNotice> notices)
|
||||||
|
{
|
||||||
|
// Without a scope key, ids mean nothing: the route (or the page default) names the scope.
|
||||||
|
var token = reader.First(AnalysisUrlKeys.Scope);
|
||||||
|
if (token is null)
|
||||||
|
{
|
||||||
|
return defaults.Scope;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!QueryScope.TryParseKind(token, out var kind))
|
||||||
|
{
|
||||||
|
notices.Add(new AnalysisQueryNotice(AnalysisQueryNoticeKind.InvalidScope, AnalysisUrlKeys.Scope, token));
|
||||||
|
return defaults.Scope;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (kind)
|
||||||
|
{
|
||||||
|
case QueryScopeKind.Portfolio:
|
||||||
|
return QueryScope.Portfolio;
|
||||||
|
|
||||||
|
case QueryScopeKind.Meters:
|
||||||
|
return ParseSelection(reader, token, notices) ?? defaults.Scope;
|
||||||
|
|
||||||
|
default:
|
||||||
|
var idToken = reader.First(AnalysisUrlKeys.Id);
|
||||||
|
if (TryParseId(idToken, out var id))
|
||||||
|
{
|
||||||
|
return kind switch
|
||||||
|
{
|
||||||
|
QueryScopeKind.EnergyType => QueryScope.ForEnergyType(id),
|
||||||
|
QueryScopeKind.Category => QueryScope.ForCategory(id),
|
||||||
|
_ => QueryScope.ForMeter(id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
notices.Add(idToken is null
|
||||||
|
? new AnalysisQueryNotice(AnalysisQueryNoticeKind.InvalidScope, AnalysisUrlKeys.Scope, token)
|
||||||
|
: new AnalysisQueryNotice(AnalysisQueryNoticeKind.InvalidId, AnalysisUrlKeys.Id, idToken));
|
||||||
|
return defaults.Scope;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>ids=3,5,9</c> (or repeated <c>ids</c>, or a single <c>id</c>): the valid ids in order, distinct, at most
|
||||||
|
/// <see cref="AnalysisLimits.MaxSeries"/>; null when none is valid.
|
||||||
|
/// </summary>
|
||||||
|
private static QueryScope? ParseSelection(Reader reader, string scopeToken, List<AnalysisQueryNotice> notices)
|
||||||
|
{
|
||||||
|
var tokens = reader.All(AnalysisUrlKeys.Ids)
|
||||||
|
.SelectMany(v => v.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
.ToList();
|
||||||
|
if (tokens.Count == 0 && reader.First(AnalysisUrlKeys.Id) is { } single)
|
||||||
|
{
|
||||||
|
tokens.Add(single.Trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
var ids = new List<int>();
|
||||||
|
var invalid = new List<string>();
|
||||||
|
foreach (var item in tokens)
|
||||||
|
{
|
||||||
|
if (!TryParseId(item, out var id))
|
||||||
|
{
|
||||||
|
invalid.Add(item);
|
||||||
|
}
|
||||||
|
else if (!ids.Contains(id))
|
||||||
|
{
|
||||||
|
ids.Add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invalid.Count > 0)
|
||||||
|
{
|
||||||
|
notices.Add(new AnalysisQueryNotice(AnalysisQueryNoticeKind.InvalidId, AnalysisUrlKeys.Ids, string.Join(',', invalid)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ids.Count > AnalysisLimits.MaxSeries)
|
||||||
|
{
|
||||||
|
notices.Add(new AnalysisQueryNotice(
|
||||||
|
AnalysisQueryNoticeKind.TooManyMeters, AnalysisUrlKeys.Ids, ids.Count.ToString(CultureInfo.InvariantCulture)));
|
||||||
|
ids = ids.Take(AnalysisLimits.MaxSeries).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ids.Count == 0)
|
||||||
|
{
|
||||||
|
if (invalid.Count == 0)
|
||||||
|
{
|
||||||
|
notices.Add(new AnalysisQueryNotice(AnalysisQueryNoticeKind.InvalidScope, AnalysisUrlKeys.Scope, scopeToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return QueryScope.ForMeters(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseId(string? token, out int id)
|
||||||
|
{
|
||||||
|
id = 0;
|
||||||
|
return token is not null
|
||||||
|
&& int.TryParse(token.AsSpan().Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out id)
|
||||||
|
&& id > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<string> KeysOf(AnalysisQueryParts parts)
|
||||||
|
{
|
||||||
|
if (parts.HasFlag(AnalysisQueryParts.Scope))
|
||||||
|
{
|
||||||
|
yield return AnalysisUrlKeys.Scope;
|
||||||
|
yield return AnalysisUrlKeys.Id;
|
||||||
|
yield return AnalysisUrlKeys.Ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.HasFlag(AnalysisQueryParts.Metric))
|
||||||
|
{
|
||||||
|
yield return AnalysisUrlKeys.Metric;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.HasFlag(AnalysisQueryParts.Period))
|
||||||
|
{
|
||||||
|
yield return AnalysisUrlKeys.Period;
|
||||||
|
yield return AnalysisUrlKeys.From;
|
||||||
|
yield return AnalysisUrlKeys.To;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.HasFlag(AnalysisQueryParts.Bucket))
|
||||||
|
{
|
||||||
|
yield return AnalysisUrlKeys.Bucket;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.HasFlag(AnalysisQueryParts.Comparison))
|
||||||
|
{
|
||||||
|
yield return AnalysisUrlKeys.Compare;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The query part of a URL (from its <c>?</c>, without a fragment), or empty.</summary>
|
||||||
|
private static string QueryOf(string? uriOrQuery)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(uriOrQuery))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var start = uriOrQuery.IndexOf('?', StringComparison.Ordinal);
|
||||||
|
if (start < 0)
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var end = uriOrQuery.IndexOf('#', start);
|
||||||
|
return end < 0 ? uriOrQuery[start..] : uriOrQuery[start..end];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Escapes a value, keeping the <c>:</c> of <c>year:2025</c> and the commas of an id list readable.</summary>
|
||||||
|
private static string Escape(string value) =>
|
||||||
|
Uri.EscapeDataString(value).Replace("%3A", ":", StringComparison.Ordinal).Replace("%2C", ",", StringComparison.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>Case-insensitive access to a parsed query: the first non-blank value of a key, or all of them.</summary>
|
||||||
|
private sealed class Reader(Dictionary<string, StringValues> keys)
|
||||||
|
{
|
||||||
|
public string? First(string key)
|
||||||
|
{
|
||||||
|
if (!keys.TryGetValue(key, out var values))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var value in values)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IEnumerable<string> All(string key) =>
|
||||||
|
keys.TryGetValue(key, out var values) ? values.Where(v => !string.IsNullOrWhiteSpace(v)).Select(v => v!) : [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace MeterVault.App.Analysis;
|
||||||
|
|
||||||
|
/// <summary>Why part of an analysis URL was not used as written (D-02: an invalid token falls back to the default with a notice).</summary>
|
||||||
|
public enum AnalysisQueryNoticeKind
|
||||||
|
{
|
||||||
|
/// <summary><c>period</c> is not a known preset.</summary>
|
||||||
|
InvalidPeriod,
|
||||||
|
|
||||||
|
/// <summary>A custom range whose <c>from</c>/<c>to</c> are missing, malformed, out of order or outside the supported dates.</summary>
|
||||||
|
InvalidRange,
|
||||||
|
|
||||||
|
/// <summary><c>bucket</c> is not a known size.</summary>
|
||||||
|
InvalidBucket,
|
||||||
|
|
||||||
|
/// <summary><c>compare</c> is not a known comparison.</summary>
|
||||||
|
InvalidComparison,
|
||||||
|
|
||||||
|
/// <summary><c>metric</c> is not a known metric.</summary>
|
||||||
|
InvalidMetric,
|
||||||
|
|
||||||
|
/// <summary><c>scope</c> is not a known scope, or names no usable id.</summary>
|
||||||
|
InvalidScope,
|
||||||
|
|
||||||
|
/// <summary>An <c>id</c>/<c>ids</c> entry is not a positive whole number.</summary>
|
||||||
|
InvalidId,
|
||||||
|
|
||||||
|
/// <summary>More meters than can be charted side by side were selected; the first ones are kept.</summary>
|
||||||
|
TooManyMeters,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One part of an analysis URL that was not used as written: what was wrong, under which key, and the raw value. The
|
||||||
|
/// page shows it (localized through <see cref="Localization.DisplayNames"/>); the CSV export answers it with a 400.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Kind">What was wrong.</param>
|
||||||
|
/// <param name="Key">The URL key (<c>period</c>, <c>ids</c>, …).</param>
|
||||||
|
/// <param name="Value">The raw value as it appeared (data, never shown untrusted as markup).</param>
|
||||||
|
public sealed record AnalysisQueryNotice(AnalysisQueryNoticeKind Kind, string Key, string? Value)
|
||||||
|
{
|
||||||
|
/// <summary>A short invariant English sentence, for logs and the export's 400 responses.</summary>
|
||||||
|
public string Describe() => Kind switch
|
||||||
|
{
|
||||||
|
AnalysisQueryNoticeKind.InvalidPeriod => Quote("Unknown period"),
|
||||||
|
AnalysisQueryNoticeKind.InvalidRange => Quote("Invalid custom range (from/to must be yyyy-MM-dd dates in order)"),
|
||||||
|
AnalysisQueryNoticeKind.InvalidBucket => Quote("Unknown bucket"),
|
||||||
|
AnalysisQueryNoticeKind.InvalidComparison => Quote("Unknown comparison"),
|
||||||
|
AnalysisQueryNoticeKind.InvalidMetric => Quote("Unknown metric"),
|
||||||
|
AnalysisQueryNoticeKind.InvalidScope => Quote("Unknown or incomplete scope"),
|
||||||
|
AnalysisQueryNoticeKind.InvalidId => Quote("Invalid id"),
|
||||||
|
AnalysisQueryNoticeKind.TooManyMeters => string.Create(
|
||||||
|
CultureInfo.InvariantCulture, $"At most {Infrastructure.Analysis.AnalysisLimits.MaxSeries} meters can be compared ('{Key}')."),
|
||||||
|
_ => Quote("Invalid value"),
|
||||||
|
};
|
||||||
|
|
||||||
|
private string Quote(string text) => string.Create(CultureInfo.InvariantCulture, $"{text}: {Key}='{Value}'.");
|
||||||
|
}
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
using MeterVault.App.Localization;
|
||||||
|
using MeterVault.Core.Analysis;
|
||||||
|
using MeterVault.Core.Analysis.Costing;
|
||||||
|
using MeterVault.Infrastructure.Analysis;
|
||||||
|
|
||||||
|
namespace MeterVault.App.Analysis;
|
||||||
|
|
||||||
|
/// <summary>One formatted figure of a table: the number, its text (with unit or currency, "—" when unknown) and its status.</summary>
|
||||||
|
public sealed record TableFigure(double? Value, string Text, FigureStatus Status)
|
||||||
|
{
|
||||||
|
/// <summary>A quantity with its unit.</summary>
|
||||||
|
public static TableFigure Of(BucketValue value, string? unit, Func<int, string?>? meterName = null)
|
||||||
|
{
|
||||||
|
var status = FigureText.Of(value, meterName);
|
||||||
|
var number = status.IsKnown ? value.Value : null;
|
||||||
|
return new TableFigure(number, Format.Quantity(number, unit), status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A cost in <paramref name="currency"/>.</summary>
|
||||||
|
public static TableFigure Of(CostAmount amount, string currency)
|
||||||
|
{
|
||||||
|
var status = FigureText.Of(amount);
|
||||||
|
var number = status.IsKnown ? amount.Cost : null;
|
||||||
|
return new TableFigure(number, Format.Money(number, currency), status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A series of the analysis table (brief §7.2): its values per bucket and in total, and optionally its cost and its
|
||||||
|
/// comparison — each formatted once, in the reader's culture, with its status in words.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record AnalysisTableSeries
|
||||||
|
{
|
||||||
|
private AnalysisTableSeries(string key, string name, IReadOnlyList<TableFigure> values, TableFigure? total, bool isMoney)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(key);
|
||||||
|
ArgumentNullException.ThrowIfNull(name);
|
||||||
|
|
||||||
|
Key = key;
|
||||||
|
Name = name;
|
||||||
|
Values = values;
|
||||||
|
Total = total;
|
||||||
|
IsMoney = isMoney;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Key { get; init; }
|
||||||
|
|
||||||
|
/// <summary>The column header (a meter's name is user data, never translated).</summary>
|
||||||
|
public string Name { get; init; }
|
||||||
|
|
||||||
|
/// <summary>One figure per bucket.</summary>
|
||||||
|
public IReadOnlyList<TableFigure> Values { get; init; }
|
||||||
|
|
||||||
|
/// <summary>The period total from the reader; null to show none (it is never added up here: a formula may not be additive).</summary>
|
||||||
|
public TableFigure? Total { get; init; }
|
||||||
|
|
||||||
|
/// <summary>True when the values are money, so no separate cost column applies.</summary>
|
||||||
|
public bool IsMoney { get; init; }
|
||||||
|
|
||||||
|
/// <summary>The cost per bucket, when priced (a meter's cost by its rule).</summary>
|
||||||
|
public IReadOnlyList<TableFigure>? Costs { get; init; }
|
||||||
|
|
||||||
|
public TableFigure? CostTotal { get; init; }
|
||||||
|
|
||||||
|
/// <summary>The comparison per paired bucket (A-10), when one was requested.</summary>
|
||||||
|
public IReadOnlyList<TableFigure>? Comparison { get; init; }
|
||||||
|
|
||||||
|
public TableFigure? ComparisonTotal { get; init; }
|
||||||
|
|
||||||
|
/// <summary>The change over the matched coverage (D-07) for the total row; computed from the totals when absent.</summary>
|
||||||
|
public Change? TotalChange { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Formats the size of a change (with unit or currency).</summary>
|
||||||
|
public Func<double, string> FormatDifference { get; init; } = v => Format.Number(v, 2);
|
||||||
|
|
||||||
|
/// <summary>Whether a rise is good news (D-08).</summary>
|
||||||
|
public ChangePolarity Polarity { get; init; } = ChangePolarity.HigherIsWorse;
|
||||||
|
|
||||||
|
/// <summary>False when the buckets do not add up to the total (a ratio, a formula with a constant, D-27).</summary>
|
||||||
|
public bool IsAdditive { get; init; } = true;
|
||||||
|
|
||||||
|
/// <summary>A quantity series with its unit.</summary>
|
||||||
|
/// <param name="key">A stable key.</param>
|
||||||
|
/// <param name="name">The column header.</param>
|
||||||
|
/// <param name="unit">The unit of every value.</param>
|
||||||
|
/// <param name="values">One value per bucket.</param>
|
||||||
|
/// <param name="total">The period total; null to show none.</param>
|
||||||
|
/// <param name="meterName">Names the meter a derived value misses (a source without data, a loop); "#id" without it.</param>
|
||||||
|
public static AnalysisTableSeries ForValues(
|
||||||
|
string key, string name, string? unit, IReadOnlyList<BucketValue> values, BucketValue? total = null, Func<int, string?>? meterName = null)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(values);
|
||||||
|
|
||||||
|
return new AnalysisTableSeries(
|
||||||
|
key, name, [.. values.Select(v => TableFigure.Of(v, unit, meterName))], total is null ? null : TableFigure.Of(total, unit, meterName), isMoney: false)
|
||||||
|
{
|
||||||
|
FormatDifference = v => Format.Quantity(v, unit),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A cost series (metric cost, a category): the values are money.</summary>
|
||||||
|
public static AnalysisTableSeries ForCosts(string key, string name, string currency, IReadOnlyList<CostAmount> amounts, CostAmount? total = null)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(amounts);
|
||||||
|
|
||||||
|
return new AnalysisTableSeries(key, name, [.. amounts.Select(a => TableFigure.Of(a, currency))], total is null ? null : TableFigure.Of(total, currency), isMoney: true)
|
||||||
|
{
|
||||||
|
FormatDifference = v => Format.Money(v, currency),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A reader series with its total, polarity, additivity and — when one was read — its comparison and the change over
|
||||||
|
/// the matched coverage.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="series">The series.</param>
|
||||||
|
/// <param name="name">The column header; the meter's name or the measure's wording by default.</param>
|
||||||
|
/// <param name="meterName">Names the meter a derived value misses (a source without data, a loop); "#id" without it.</param>
|
||||||
|
public static AnalysisTableSeries ForSeries(AnalysisSeries series, string? name = null, Func<int, string?>? meterName = null)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(series);
|
||||||
|
|
||||||
|
var table = ForValues(series.Key.Id, name ?? AnalysisChartSeries.NameOf(series), series.Unit, series.Values, series.Total, meterName) with
|
||||||
|
{
|
||||||
|
Polarity = ChangePolarities.For(series.Kind),
|
||||||
|
IsAdditive = series.IsAdditive,
|
||||||
|
};
|
||||||
|
|
||||||
|
return series.Comparison is { } comparison
|
||||||
|
? table.WithComparison(comparison.Values, comparison.Total, series.Unit, comparison.Change, meterName)
|
||||||
|
: table;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>This series with a comparison in the same unit.</summary>
|
||||||
|
public AnalysisTableSeries WithComparison(
|
||||||
|
IReadOnlyList<BucketValue> values, BucketValue? total, string? unit, Change? totalChange = null, Func<int, string?>? meterName = null)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(values);
|
||||||
|
|
||||||
|
return this with
|
||||||
|
{
|
||||||
|
Comparison = [.. values.Select(v => TableFigure.Of(v, unit, meterName))],
|
||||||
|
ComparisonTotal = total is null ? null : TableFigure.Of(total, unit, meterName),
|
||||||
|
TotalChange = totalChange,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>This (money) series with its comparison priced in the paired buckets.</summary>
|
||||||
|
public AnalysisTableSeries WithComparisonCosts(IReadOnlyList<CostAmount> amounts, CostAmount? total, string currency)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(amounts);
|
||||||
|
|
||||||
|
return this with
|
||||||
|
{
|
||||||
|
Comparison = [.. amounts.Select(a => TableFigure.Of(a, currency))],
|
||||||
|
ComparisonTotal = total is null ? null : TableFigure.Of(total, currency),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>This quantity series with its cost per bucket (a meter's cost by its rule).</summary>
|
||||||
|
public AnalysisTableSeries WithCosts(IReadOnlyList<CostAmount> amounts, CostAmount? total, string currency)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(amounts);
|
||||||
|
|
||||||
|
return this with
|
||||||
|
{
|
||||||
|
Costs = [.. amounts.Select(a => TableFigure.Of(a, currency))],
|
||||||
|
CostTotal = total is null ? null : TableFigure.Of(total, currency),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>What a table column shows.</summary>
|
||||||
|
public enum AnalysisTableColumnKind
|
||||||
|
{
|
||||||
|
Value,
|
||||||
|
Status,
|
||||||
|
Cost,
|
||||||
|
CostStatus,
|
||||||
|
Comparison,
|
||||||
|
Change,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A column: its series, what it shows, its header, and (with several series) the series it belongs to.</summary>
|
||||||
|
public sealed record AnalysisTableColumn(string SeriesKey, AnalysisTableColumnKind Kind, string Header, string? SubHeader)
|
||||||
|
{
|
||||||
|
/// <summary>Numbers are right-aligned.</summary>
|
||||||
|
public bool IsNumeric => Kind is AnalysisTableColumnKind.Value or AnalysisTableColumnKind.Cost
|
||||||
|
or AnalysisTableColumnKind.Comparison or AnalysisTableColumnKind.Change;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A cell: its text, an optional second line (the reason, the compared bucket), a CSS class and whether it is unknown.</summary>
|
||||||
|
public sealed record AnalysisTableCell(string Text, string? Secondary = null, string? CssClass = null, bool IsUnknown = false);
|
||||||
|
|
||||||
|
/// <summary>A row: one bucket (or the total), its label and one cell per column.</summary>
|
||||||
|
public sealed record AnalysisTableRow(AnalysisBucket? Bucket, string Label, IReadOnlyList<AnalysisTableCell> Cells, bool IsTotal, bool IsQualified);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The analysis table (brief §7.2) as data: one row per bucket and a total row, each series with its value, status in
|
||||||
|
/// words, optional cost and cost status, optional comparison and change. It is the chart's accessible alternative, so it
|
||||||
|
/// says in words what the chart only marks.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// A change is stated per bucket only where both figures are complete — a partial bucket against a whole one is not a
|
||||||
|
/// like-for-like change (D-07); the total row takes the reader's change over the matched coverage. Unknown values read
|
||||||
|
/// "—", never 0.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Newest first (A-42).</b> The rows are the reverse of the plan: the latest bucket is the first row, and the total
|
||||||
|
/// row sits above them all. The chart stays chronological — it is read left to right — but a table is read top down,
|
||||||
|
/// and what a reader wants first is the period they are in. The plan itself is never reordered: <see cref="Build"/>
|
||||||
|
/// takes the buckets oldest first (as the reader returns them, paired with the comparison by index) and turns them
|
||||||
|
/// round once, here, so nothing that indexes the plan has to know.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed record AnalysisTableModel(IReadOnlyList<AnalysisTableColumn> Columns, IReadOnlyList<AnalysisTableRow> Rows)
|
||||||
|
{
|
||||||
|
/// <summary>Builds the table: the total row first, then the buckets newest first (A-42).</summary>
|
||||||
|
/// <param name="buckets">The buckets, oldest first (the plan's own order).</param>
|
||||||
|
/// <param name="series">The series.</param>
|
||||||
|
/// <param name="pairs">The comparison buckets paired with <paramref name="buckets"/>, to name each row's compared bucket.</param>
|
||||||
|
/// <param name="includeTotal">Adds the total row.</param>
|
||||||
|
public static AnalysisTableModel Build(
|
||||||
|
IReadOnlyList<AnalysisBucket> buckets,
|
||||||
|
IReadOnlyList<AnalysisTableSeries> series,
|
||||||
|
IReadOnlyList<BucketPair>? pairs = null,
|
||||||
|
bool includeTotal = true)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(buckets);
|
||||||
|
ArgumentNullException.ThrowIfNull(series);
|
||||||
|
|
||||||
|
var several = series.Count > 1;
|
||||||
|
var columns = new List<AnalysisTableColumn>();
|
||||||
|
foreach (var item in series)
|
||||||
|
{
|
||||||
|
var sub = several ? item.Name : null;
|
||||||
|
columns.Add(new(item.Key, AnalysisTableColumnKind.Value, item.Name.Length > 0 ? item.Name : Strings.Common_Value, null));
|
||||||
|
columns.Add(new(item.Key, AnalysisTableColumnKind.Status, Strings.Common_Status, sub));
|
||||||
|
if (item.Costs is not null)
|
||||||
|
{
|
||||||
|
columns.Add(new(item.Key, AnalysisTableColumnKind.Cost, Strings.AnalysisTable_Cost, sub));
|
||||||
|
columns.Add(new(item.Key, AnalysisTableColumnKind.CostStatus, Strings.AnalysisTable_PriceCoverage, sub));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.Comparison is not null)
|
||||||
|
{
|
||||||
|
columns.Add(new(item.Key, AnalysisTableColumnKind.Comparison, Strings.AnalysisTable_Comparison, sub));
|
||||||
|
columns.Add(new(item.Key, AnalysisTableColumnKind.Change, Strings.AnalysisTable_Change, sub));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var labels = AnalysisChartPlan.BucketLabels(buckets);
|
||||||
|
var rows = new List<AnalysisTableRow>(buckets.Count + 1);
|
||||||
|
for (var i = 0; i < buckets.Count; i++)
|
||||||
|
{
|
||||||
|
var cells = new List<AnalysisTableCell>(columns.Count);
|
||||||
|
var qualified = false;
|
||||||
|
var pairLabel = pairs is not null && i < pairs.Count ? Format.BucketLabel(pairs[i].Comparison, includeYear: true) : null;
|
||||||
|
foreach (var item in series)
|
||||||
|
{
|
||||||
|
var value = At(item.Values, i);
|
||||||
|
qualified |= value.Status.IsQualified;
|
||||||
|
AddCells(cells, item, value, AtOrNull(item.Costs, i), AtOrNull(item.Comparison, i), pairLabel, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.Add(new AnalysisTableRow(buckets[i], labels[i], cells, IsTotal: false, qualified));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A-42: the latest period is the first row. The pairing above is by index against the plan, so the order is
|
||||||
|
// turned round only once everything is built.
|
||||||
|
rows.Reverse();
|
||||||
|
|
||||||
|
if (includeTotal && buckets.Count > 0)
|
||||||
|
{
|
||||||
|
var cells = new List<AnalysisTableCell>(columns.Count);
|
||||||
|
var qualified = false;
|
||||||
|
foreach (var item in series)
|
||||||
|
{
|
||||||
|
var total = item.Total ?? Unknown;
|
||||||
|
qualified |= item.Total is not null && total.Status.IsQualified;
|
||||||
|
AddCells(cells, item, total, item.Costs is null ? null : item.CostTotal ?? Unknown, item.Comparison is null ? null : item.ComparisonTotal ?? Unknown, null, item.TotalChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Above the rows, where a total belongs once they descend: it sums what follows it, not what precedes it.
|
||||||
|
rows.Insert(0, new AnalysisTableRow(null, Strings.AnalysisTable_Total, cells, IsTotal: true, qualified));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AnalysisTableModel(columns, rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Half a cent: a money difference that displays as zero is no change.</summary>
|
||||||
|
private const double MoneyTolerance = 0.005;
|
||||||
|
|
||||||
|
/// <summary>A figure nothing is known about.</summary>
|
||||||
|
private static TableFigure Unknown { get; } =
|
||||||
|
new(null, Format.Unknown, new FigureStatus(false, false, true, BucketStatus.Missing.Display(), null, string.Empty));
|
||||||
|
|
||||||
|
private static TableFigure At(IReadOnlyList<TableFigure> figures, int index) => index < figures.Count ? figures[index] : Unknown;
|
||||||
|
|
||||||
|
private static TableFigure? AtOrNull(IReadOnlyList<TableFigure>? figures, int index) => figures is null ? null : At(figures, index);
|
||||||
|
|
||||||
|
private static void AddCells(
|
||||||
|
List<AnalysisTableCell> cells,
|
||||||
|
AnalysisTableSeries series,
|
||||||
|
TableFigure value,
|
||||||
|
TableFigure? cost,
|
||||||
|
TableFigure? comparison,
|
||||||
|
string? pairLabel,
|
||||||
|
Change? change)
|
||||||
|
{
|
||||||
|
cells.Add(new AnalysisTableCell(value.Text, null, value.Status.IsQualified ? "mv-qualified" : null, value.Value is null));
|
||||||
|
cells.Add(new AnalysisTableCell(value.Status.Summary, value.Status.Detail));
|
||||||
|
if (series.Costs is not null)
|
||||||
|
{
|
||||||
|
var figure = cost ?? Unknown;
|
||||||
|
cells.Add(new AnalysisTableCell(figure.Text, null, figure.Status.IsQualified ? "mv-qualified" : null, figure.Value is null));
|
||||||
|
cells.Add(new AnalysisTableCell(figure.Status.Status, figure.Status.Detail));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (series.Comparison is not null)
|
||||||
|
{
|
||||||
|
var figure = comparison ?? Unknown;
|
||||||
|
cells.Add(new AnalysisTableCell(figure.Text, pairLabel, figure.Status.IsQualified ? "mv-qualified" : null, figure.Value is null));
|
||||||
|
|
||||||
|
var stated = change is { IsAvailable: true } ? change : ChangeOf(value, figure, series.IsMoney ? MoneyTolerance : Change.Tolerance);
|
||||||
|
var polarity = series.IsMoney ? ChangePolarities.ForCost(value.Value, figure.Value) : series.Polarity;
|
||||||
|
var tone = ChangeDisplay.Tone(stated, polarity);
|
||||||
|
cells.Add(new AnalysisTableCell(Format.ChangeText(stated, series.FormatDifference), null, ChangeDisplay.CssClass(tone), !stated.IsAvailable));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The change between two complete figures; unavailable when either is incomplete or unknown.</summary>
|
||||||
|
private static Change ChangeOf(TableFigure current, TableFigure previous, double tolerance) =>
|
||||||
|
current.Status.IsComplete && previous.Status.IsComplete
|
||||||
|
? Change.Between(current.Value, previous.Value, tolerance)
|
||||||
|
: Change.Unavailable;
|
||||||
|
}
|
||||||
@@ -0,0 +1,462 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using MeterVault.App.Localization;
|
||||||
|
using MeterVault.Core.Analysis;
|
||||||
|
using MeterVault.Core.Analysis.Costing;
|
||||||
|
using MeterVault.Core.Analysis.Quantities;
|
||||||
|
using MeterVault.Core.Analysis.Totals;
|
||||||
|
using MeterVault.Core.Analysis.Virtual;
|
||||||
|
using MeterVault.Core.Domain;
|
||||||
|
using MeterVault.Infrastructure.Analysis;
|
||||||
|
using MeterVault.Infrastructure.Costing;
|
||||||
|
|
||||||
|
namespace MeterVault.App.Analysis;
|
||||||
|
|
||||||
|
/// <summary>How urgent an attention item is. Shown with an icon and words, never colour alone.</summary>
|
||||||
|
public enum AttentionSeverity
|
||||||
|
{
|
||||||
|
/// <summary>Worth knowing (analysis being prepared, rows after now, a calculation to confirm).</summary>
|
||||||
|
Info,
|
||||||
|
|
||||||
|
/// <summary>A figure is incomplete until it is fixed (a missing price, a stale source).</summary>
|
||||||
|
Warning,
|
||||||
|
|
||||||
|
/// <summary>A figure cannot be computed until it is fixed (an invalid calculation).</summary>
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One attention item (D-53): a localized one-liner and at most one targeted action.</summary>
|
||||||
|
/// <param name="Key">An invariant identity (kind, meter, text) — duplicates from the quantity and the cost reader collapse.</param>
|
||||||
|
/// <param name="Severity">How urgent it is.</param>
|
||||||
|
/// <param name="Text">The one-liner, in the reader's language; user data (names) as it is.</param>
|
||||||
|
/// <param name="ActionText">The action's label, or null when there is nothing to do here.</param>
|
||||||
|
/// <param name="ActionHref">Where the action leads.</param>
|
||||||
|
public sealed record AttentionItem(string Key, AttentionSeverity Severity, string Text, string? ActionText, string? ActionHref);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The names attention items speak of: meters, energy types and cost categories by id (user data, never translated), with a neutral
|
||||||
|
/// fallback ("Meter #12") for an id nobody named.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AttentionNames
|
||||||
|
{
|
||||||
|
private readonly IReadOnlyDictionary<int, string> _meters;
|
||||||
|
private readonly IReadOnlyDictionary<int, string> _energyTypes;
|
||||||
|
private readonly IReadOnlyDictionary<int, string> _categories;
|
||||||
|
|
||||||
|
public AttentionNames(
|
||||||
|
IReadOnlyDictionary<int, string>? meters = null,
|
||||||
|
IReadOnlyDictionary<int, string>? energyTypes = null,
|
||||||
|
IReadOnlyDictionary<int, string>? categories = null)
|
||||||
|
{
|
||||||
|
_meters = meters ?? new Dictionary<int, string>();
|
||||||
|
_energyTypes = energyTypes ?? new Dictionary<int, string>();
|
||||||
|
_categories = categories ?? new Dictionary<int, string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The meter names a quantity result and a cost result carry — series, classification, virtual sources at any depth,
|
||||||
|
/// priced lines — plus the given energy type names.
|
||||||
|
/// </summary>
|
||||||
|
public static AttentionNames From(AnalysisResult? result, CostAnalysis? costs = null, IReadOnlyDictionary<int, string>? energyTypes = null)
|
||||||
|
{
|
||||||
|
var meters = new Dictionary<int, string>();
|
||||||
|
if (result is not null)
|
||||||
|
{
|
||||||
|
foreach (var series in result.Series)
|
||||||
|
{
|
||||||
|
Add(meters, series.MeterId, series.Name);
|
||||||
|
AddContributions(meters, series.Contributions);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var entry in result.Classification)
|
||||||
|
{
|
||||||
|
Add(meters, entry.MeterId, entry.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var categories = new Dictionary<int, string>();
|
||||||
|
if (costs is not null)
|
||||||
|
{
|
||||||
|
foreach (var line in costs.Lines)
|
||||||
|
{
|
||||||
|
Add(meters, line.MeterId, line.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var category in (costs.Composition?.Categories ?? []).Concat(costs.Category is { } own ? [own] : []))
|
||||||
|
{
|
||||||
|
Add(categories, category.CategoryId, category.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AttentionNames(meters, energyTypes, categories);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The meter's name, or "Meter #id".</summary>
|
||||||
|
public string Meter(int? id) =>
|
||||||
|
id is { } key && _meters.TryGetValue(key, out var name) && !string.IsNullOrWhiteSpace(name)
|
||||||
|
? name
|
||||||
|
: Loc.F(Strings.Attention_MeterFallback, id?.ToString(CultureInfo.CurrentCulture) ?? "?");
|
||||||
|
|
||||||
|
/// <summary>The energy type's name, or "Energy type #id".</summary>
|
||||||
|
public string EnergyType(int? id) =>
|
||||||
|
id is { } key && _energyTypes.TryGetValue(key, out var name) && !string.IsNullOrWhiteSpace(name)
|
||||||
|
? name
|
||||||
|
: Loc.F(Strings.Attention_EnergyTypeFallback, id?.ToString(CultureInfo.CurrentCulture) ?? "?");
|
||||||
|
|
||||||
|
/// <summary>The cost category's name, or "Category #id".</summary>
|
||||||
|
public string Category(int? id) =>
|
||||||
|
id is { } key && _categories.TryGetValue(key, out var name) && !string.IsNullOrWhiteSpace(name)
|
||||||
|
? name
|
||||||
|
: Loc.F(Strings.Attention_CategoryFallback, id?.ToString(CultureInfo.CurrentCulture) ?? "?");
|
||||||
|
|
||||||
|
/// <summary>A meter id's name for value details, or null when unknown.</summary>
|
||||||
|
public string? MeterOrNull(int id) => _meters.TryGetValue(id, out var name) ? name : null;
|
||||||
|
|
||||||
|
private static void AddContributions(Dictionary<int, string> meters, IReadOnlyList<SeriesContribution> contributions)
|
||||||
|
{
|
||||||
|
foreach (var contribution in contributions)
|
||||||
|
{
|
||||||
|
Add(meters, contribution.MeterId, contribution.Name);
|
||||||
|
AddContributions(meters, contribution.Nested);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Add(Dictionary<int, string> meters, int? id, string? name)
|
||||||
|
{
|
||||||
|
if (id is { } key && !string.IsNullOrWhiteSpace(name))
|
||||||
|
{
|
||||||
|
meters.TryAdd(key, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Turns the readers' attention codes (D-53) — <see cref="AnalysisProblem"/> and <see cref="CostAttention"/> — into
|
||||||
|
/// one-liners with one targeted action each: a missing price opens the tariff editor prefilled for its scope, component
|
||||||
|
/// and first uncovered month (D-52); a calculation to fix opens the meter's Calculation tab; a stale source its Sources
|
||||||
|
/// tab; rows after now its Normalized data around those days; a possible overlap the energy type's Meters tab; a
|
||||||
|
/// configuration conflict the meter editor. A kind this code does not know still gets its worded kind, without action.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Items are ordered by severity (errors first), then as the readers reported them; items with the same text for the same
|
||||||
|
/// meter collapse (the cost reader repeats the quantity reader's problems).
|
||||||
|
/// </remarks>
|
||||||
|
public static class AttentionItems
|
||||||
|
{
|
||||||
|
/// <summary>Builds the items.</summary>
|
||||||
|
/// <param name="problems">The quantity reader's problems (<see cref="AnalysisResult.Problems"/>).</param>
|
||||||
|
/// <param name="costAttention">The cost reader's items (<see cref="CostAnalysis.Attention"/>) — and its <see cref="CostAnalysis.QuantityProblems"/> go into <paramref name="problems"/>.</param>
|
||||||
|
/// <param name="names">The names to speak of.</param>
|
||||||
|
/// <param name="query">The page's analysis state; links into meter pages carry its period.</param>
|
||||||
|
public static IReadOnlyList<AttentionItem> Build(
|
||||||
|
IEnumerable<AnalysisProblem>? problems,
|
||||||
|
IEnumerable<CostAttention>? costAttention,
|
||||||
|
AttentionNames names,
|
||||||
|
AnalysisQuery? query = null)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(names);
|
||||||
|
|
||||||
|
var items = new List<AttentionItem>();
|
||||||
|
foreach (var problem in problems ?? [])
|
||||||
|
{
|
||||||
|
if (problem is not null)
|
||||||
|
{
|
||||||
|
items.Add(ForProblem(problem, names, query));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var attention in costAttention ?? [])
|
||||||
|
{
|
||||||
|
if (attention is not null)
|
||||||
|
{
|
||||||
|
items.Add(ForCost(attention, names, query));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
return
|
||||||
|
[
|
||||||
|
.. items
|
||||||
|
.Select((item, index) => (Item: item, Index: index))
|
||||||
|
.Where(x => seen.Add(x.Item.Key))
|
||||||
|
.OrderByDescending(x => x.Item.Severity)
|
||||||
|
.ThenBy(x => x.Index)
|
||||||
|
.Select(x => x.Item),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One quantity problem.</summary>
|
||||||
|
public static AttentionItem ForProblem(AnalysisProblem problem, AttentionNames names, AnalysisQuery? query = null)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(problem);
|
||||||
|
ArgumentNullException.ThrowIfNull(names);
|
||||||
|
|
||||||
|
var meter = names.Meter(problem.MeterId);
|
||||||
|
switch (problem.Kind)
|
||||||
|
{
|
||||||
|
case AnalysisProblemKind.AnalysisPending:
|
||||||
|
return Item(problem.Kind, problem.MeterId, AttentionSeverity.Info, Loc.F(Strings.Attention_AnalysisPending, meter));
|
||||||
|
|
||||||
|
case AnalysisProblemKind.UnknownMeter:
|
||||||
|
return Item(problem.Kind, problem.MeterId, AttentionSeverity.Warning, Loc.F(Strings.Attention_UnknownMeter, meter));
|
||||||
|
|
||||||
|
case AnalysisProblemKind.InvalidDefinition:
|
||||||
|
// With the validator's finding the item says what is wrong (D-26), not only that something is.
|
||||||
|
var invalid = problem.Virtual is { } finding
|
||||||
|
? Loc.F(Strings.Attention_InvalidDefinitionBecause, meter, VirtualReason(finding, names))
|
||||||
|
: Loc.F(Strings.Attention_InvalidDefinition, meter);
|
||||||
|
return Item(
|
||||||
|
problem.Kind, problem.MeterId, AttentionSeverity.Error, invalid,
|
||||||
|
Strings.Attention_EditCalculation, CalculationLink(problem.MeterId, query));
|
||||||
|
|
||||||
|
case AnalysisProblemKind.MalformedDefinition:
|
||||||
|
return Item(
|
||||||
|
problem.Kind, problem.MeterId, AttentionSeverity.Error, Loc.F(Strings.Attention_MalformedDefinition, meter),
|
||||||
|
Strings.Attention_EditCalculation, CalculationLink(problem.MeterId, query));
|
||||||
|
|
||||||
|
case AnalysisProblemKind.LegacyDefinition:
|
||||||
|
return Item(
|
||||||
|
problem.Kind, problem.MeterId, AttentionSeverity.Info, Loc.F(Strings.Attention_LegacyDefinition, meter),
|
||||||
|
Strings.Attention_ConfirmCalculation, CalculationLink(problem.MeterId, query));
|
||||||
|
|
||||||
|
case AnalysisProblemKind.LegacyNeedsConfiguration:
|
||||||
|
return Item(
|
||||||
|
problem.Kind, problem.MeterId, AttentionSeverity.Error, Loc.F(Strings.Attention_LegacyNeedsConfiguration, meter),
|
||||||
|
Strings.Attention_SetUpCalculation, CalculationLink(problem.MeterId, query));
|
||||||
|
|
||||||
|
case AnalysisProblemKind.RecordedAfterNow:
|
||||||
|
return RecordedAfterNow(problem, meter, query);
|
||||||
|
|
||||||
|
case AnalysisProblemKind.StaleSource:
|
||||||
|
return Item(
|
||||||
|
problem.Kind, problem.MeterId, AttentionSeverity.Warning, Loc.F(Strings.Attention_StaleSource, meter),
|
||||||
|
Strings.Attention_CheckSource, MeterLink(problem.MeterId, MeterLinks.TabSources, null, query));
|
||||||
|
|
||||||
|
case AnalysisProblemKind.TotalsProblem:
|
||||||
|
var other = problem.Totals?.OtherMeterId ?? (problem.MeterIds.Count > 0 ? problem.MeterIds[0] : null);
|
||||||
|
var reason = problem.Totals is { } totals ? TotalsReason(totals) : null;
|
||||||
|
var text = (other, reason) switch
|
||||||
|
{
|
||||||
|
(null, null) => Loc.F(Strings.Attention_TotalsProblem, meter),
|
||||||
|
(null, { } why) => Loc.F(Strings.Attention_TotalsProblemBecause, meter, why),
|
||||||
|
({ } id, null) => Loc.F(Strings.Attention_TotalsProblemWith, meter, names.Meter(id)),
|
||||||
|
({ } id, { } why) => Loc.F(Strings.Attention_TotalsProblemWithBecause, meter, names.Meter(id), why),
|
||||||
|
};
|
||||||
|
return Item(
|
||||||
|
problem.Kind, problem.MeterId, AttentionSeverity.Warning, text,
|
||||||
|
Strings.Attention_EditMeter, MeterLink(problem.MeterId, MeterLinks.TabAnalysis, MeterLinks.ActionEdit, query));
|
||||||
|
|
||||||
|
case AnalysisProblemKind.PossibleOverlap:
|
||||||
|
return PossibleOverlap(problem, names, meter, query);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return Item(problem.Kind, problem.MeterId, AttentionSeverity.Info, problem.MeterId is null ? problem.Kind.Display() : meter + ": " + problem.Kind.Display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One cost attention item.</summary>
|
||||||
|
public static AttentionItem ForCost(CostAttention attention, AttentionNames names, AnalysisQuery? query = null)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(attention);
|
||||||
|
ArgumentNullException.ThrowIfNull(names);
|
||||||
|
|
||||||
|
var meter = names.Meter(attention.MeterId);
|
||||||
|
var key = "cost:" + attention.Kind;
|
||||||
|
switch (attention.Kind)
|
||||||
|
{
|
||||||
|
case CostAttentionKind.MissingPrice when attention.Price is { } price:
|
||||||
|
return MissingPrice(price, names);
|
||||||
|
|
||||||
|
case CostAttentionKind.UnverifiedTariffUnit:
|
||||||
|
return Item(key, attention.MeterId, AttentionSeverity.Info, Strings.Attention_UnverifiedTariffUnit, Strings.Attention_OpenTariffs, TariffLinks.Path);
|
||||||
|
|
||||||
|
case CostAttentionKind.ManualCostAfterToday:
|
||||||
|
return Item(key, null, AttentionSeverity.Info, Loc.F(Strings.Attention_ManualCostAfterToday, attention.ManualCostIds.Count));
|
||||||
|
|
||||||
|
case CostAttentionKind.ManualCostCurrency:
|
||||||
|
return Item(key, null, AttentionSeverity.Info, Loc.F(Strings.Attention_ManualCostCurrency, attention.ManualCostIds.Count));
|
||||||
|
|
||||||
|
case CostAttentionKind.VirtualNotCosted:
|
||||||
|
return Item(
|
||||||
|
key, attention.MeterId, AttentionSeverity.Warning, Loc.F(Strings.Attention_VirtualNotCosted, meter),
|
||||||
|
Strings.Attention_EditCalculation, CalculationLink(attention.MeterId, query));
|
||||||
|
|
||||||
|
case CostAttentionKind.BillingConfiguration:
|
||||||
|
var billing = attention.Totals is { } problem
|
||||||
|
? Loc.F(Strings.Attention_BillingConfigurationBecause, meter, TotalsReason(problem, problem.OtherMeterId is { } otherId ? names.Meter(otherId) : null))
|
||||||
|
: Loc.F(Strings.Attention_BillingConfiguration, meter);
|
||||||
|
return Item(
|
||||||
|
key, attention.MeterId, AttentionSeverity.Warning, billing,
|
||||||
|
Strings.Attention_EditMeter, MeterLink(attention.MeterId, MeterLinks.TabAnalysis, MeterLinks.ActionEdit, query));
|
||||||
|
|
||||||
|
case CostAttentionKind.PriceChangeInsideInterval when attention is { FirstMonth: { } first, LastMonth: { } last }:
|
||||||
|
return Item(
|
||||||
|
key, attention.MeterId, AttentionSeverity.Warning,
|
||||||
|
Loc.F(Strings.Attention_PriceChangeInsideInterval, meter, Format.MonthYear(first), Format.MonthYear(last)),
|
||||||
|
Strings.Attention_OpenTariffs, TariffLinks.Path);
|
||||||
|
|
||||||
|
case CostAttentionKind.BillingBasisGap when attention is { FirstMonth: { } first, LastMonth: { } last }:
|
||||||
|
return Item(
|
||||||
|
key, attention.MeterId, AttentionSeverity.Warning,
|
||||||
|
Loc.F(Strings.Attention_BillingBasisGap, meter, Format.MonthYear(first), Format.MonthYear(last)),
|
||||||
|
Strings.Attention_EditMeter, MeterLink(attention.MeterId, MeterLinks.TabAnalysis, MeterLinks.ActionEdit, query));
|
||||||
|
|
||||||
|
case CostAttentionKind.CategoryPricesNothing:
|
||||||
|
// A category of calculated views or generation prices nothing (D-39, D-42): name the members and where
|
||||||
|
// to change the membership, never "no data yet" (A-22).
|
||||||
|
var members = attention.MeterIds.Count > 0 ? attention.MeterIds : attention.MeterId is { } only ? [only] : [];
|
||||||
|
return Item(
|
||||||
|
key + ":" + attention.CategoryId?.ToString(CultureInfo.InvariantCulture), attention.MeterId, AttentionSeverity.Info,
|
||||||
|
Loc.F(Strings.Attention_CategoryPricesNothing, names.Category(attention.CategoryId), string.Join(", ", members.Select(id => names.Meter(id)))),
|
||||||
|
Strings.Attention_EditCategories, CategoriesPath);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return Item(key, attention.MeterId, AttentionSeverity.Info, attention.MeterId is null ? attention.Kind.Display() : meter + ": " + attention.Kind.Display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A price a figure needed and did not get (D-38): the scope it is missing for, the component and the first month
|
||||||
|
/// that lacks it, with the tariff deep link (D-52). A missing feed-in price is an optional credit.
|
||||||
|
/// </summary>
|
||||||
|
public static AttentionItem MissingPrice(MissingPrice price, AttentionNames names)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(price);
|
||||||
|
ArgumentNullException.ThrowIfNull(names);
|
||||||
|
|
||||||
|
var scope = price.MeterId is { } meterId
|
||||||
|
? names.Meter(meterId)
|
||||||
|
: price.Scope switch
|
||||||
|
{
|
||||||
|
TariffScope.Meter => names.Meter(price.ScopeId),
|
||||||
|
TariffScope.EnergyType => names.EnergyType(price.ScopeId),
|
||||||
|
_ => Strings.Attention_AllEnergyTypes,
|
||||||
|
};
|
||||||
|
var component = price.Component.Display();
|
||||||
|
var month = Format.MonthYear(price.FirstMonth);
|
||||||
|
|
||||||
|
// A unit mismatch says what does not fit (D-37): the currency, a base price's period, or the meter's unit.
|
||||||
|
var (text, severity, action) = price.Reason switch
|
||||||
|
{
|
||||||
|
CostStatus.NotPriced => (Loc.F(Strings.Attention_PriceNotSetUp, scope, component), AttentionSeverity.Warning, Strings.Attention_AddTariff),
|
||||||
|
CostStatus.UnitMismatch when price.Issue == TariffUnitIssue.CurrencyMismatch =>
|
||||||
|
(Loc.F(Strings.Attention_PriceCurrencyMismatch, scope, component, month), AttentionSeverity.Warning, Strings.Attention_FixTariff),
|
||||||
|
CostStatus.UnitMismatch when price.Component == TariffComponent.BasePrice =>
|
||||||
|
(Loc.F(Strings.Attention_BasePriceUnitMismatch, scope, component, month), AttentionSeverity.Warning, Strings.Attention_FixTariff),
|
||||||
|
CostStatus.UnitMismatch => (Loc.F(Strings.Attention_PriceUnitMismatch, scope, component, month), AttentionSeverity.Warning, Strings.Attention_FixTariff),
|
||||||
|
_ when price.IsCredit => (Loc.F(Strings.Attention_CreditMissing, scope, component, month), AttentionSeverity.Info, Strings.Attention_AddTariff),
|
||||||
|
_ => (Loc.F(Strings.Attention_PriceMissing, scope, component, month), AttentionSeverity.Warning, Strings.Attention_AddTariff),
|
||||||
|
};
|
||||||
|
|
||||||
|
var key = string.Create(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
$"price:{price.Reason}:{price.Scope}:{price.ScopeId}:{price.MeterId}:{price.Component}:{price.FirstMonth:yyyy-MM}");
|
||||||
|
return new AttentionItem(key, severity, text, action, TariffLinks.For(price));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AttentionItem RecordedAfterNow(AnalysisProblem problem, string meter, AnalysisQuery? query)
|
||||||
|
{
|
||||||
|
if (problem.AfterNow is not { } block)
|
||||||
|
{
|
||||||
|
return Item(problem.Kind, problem.MeterId, AttentionSeverity.Info, Loc.F(Strings.Attention_RecordedAfterNowPlain, meter));
|
||||||
|
}
|
||||||
|
|
||||||
|
var text = Loc.F(Strings.Attention_RecordedAfterNow, meter, Format.DateRange(block.FirstDay, block.LastDay));
|
||||||
|
string? link = null;
|
||||||
|
if (PeriodResolver.IsValidCustomRange(block.FirstDay, block.LastDay))
|
||||||
|
{
|
||||||
|
var range = (query ?? AnalysisQuery.Default(AnalysisDefaults.History)).WithCustomRange(block.FirstDay, block.LastDay);
|
||||||
|
link = MeterLinks.Detail(block.MeterId, MeterLinks.TabNormalized, null, range);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Item(problem.Kind, block.MeterId, AttentionSeverity.Info, text, link is null ? null : Strings.Attention_ShowRows, link);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AttentionItem PossibleOverlap(AnalysisProblem problem, AttentionNames names, string meter, AnalysisQuery? query)
|
||||||
|
{
|
||||||
|
if (problem.Hint is not { } hint)
|
||||||
|
{
|
||||||
|
return Item(problem.Kind, problem.MeterId, AttentionSeverity.Info, Loc.F(Strings.Attention_PossibleOverlapPlain, meter),
|
||||||
|
Strings.Attention_EditMeter, MeterLink(problem.MeterId, MeterLinks.TabAnalysis, MeterLinks.ActionEdit, query));
|
||||||
|
}
|
||||||
|
|
||||||
|
var other = names.Meter(hint.OtherMeterId);
|
||||||
|
var text = hint.Kind switch
|
||||||
|
{
|
||||||
|
OverlapHintKind.NotLinkedBelowTotalLoad => Loc.F(Strings.Attention_AssumedBelowTotalLoad, meter, other),
|
||||||
|
OverlapHintKind.GridImportNotLinkedToTotalLoad => Loc.F(Strings.Attention_GridImportNotLinked, meter, other),
|
||||||
|
_ => meter + ": " + hint.Kind.Display(),
|
||||||
|
};
|
||||||
|
return Item(
|
||||||
|
problem.Kind, hint.MeterId, AttentionSeverity.Info, text,
|
||||||
|
Strings.Attention_ManageMeters, AnalysisLinks.EnergyType(hint.EnergyTypeId, AnalysisLinks.EnergyTabMeters, query));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Why a calculation is invalid, in words (D-26): the finding's sentence, and in brackets what it is about — the
|
||||||
|
/// meters it names (the loop as a path), or the units and kinds that do not fit.
|
||||||
|
/// </summary>
|
||||||
|
public static string VirtualReason(VirtualProblem problem, AttentionNames names)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(names);
|
||||||
|
return VirtualReasonText(problem, names);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="VirtualReason(VirtualProblem, AttentionNames)"/> without the meters the finding names, for a page that
|
||||||
|
/// lists them itself as links beside the sentence (the meter's Calculation tab): the same words everywhere.
|
||||||
|
/// </summary>
|
||||||
|
public static string VirtualReasonWithoutMeters(VirtualProblem problem) => VirtualReasonText(problem, null);
|
||||||
|
|
||||||
|
private static string VirtualReasonText(VirtualProblem problem, AttentionNames? names)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(problem);
|
||||||
|
|
||||||
|
var detail = problem.Kind switch
|
||||||
|
{
|
||||||
|
VirtualProblemKind.DependencyCycle when names is not null && problem.MeterIds.Count > 0 =>
|
||||||
|
string.Join(" → ", problem.MeterIds.Select(id => names.Meter(id))),
|
||||||
|
VirtualProblemKind.UnitMismatch or VirtualProblemKind.ResultUnitMismatch or VirtualProblemKind.IndicatorNeedsUnit
|
||||||
|
when problem.Values.Count > 0 => string.Join(", ", problem.Values),
|
||||||
|
VirtualProblemKind.KindMismatch or VirtualProblemKind.ResultKindMismatch or VirtualProblemKind.ResultKindRequired
|
||||||
|
or VirtualProblemKind.ResultKindUnsupported when problem.Values.Count > 0 => string.Join(", ", problem.Values.Select(KindWord)),
|
||||||
|
VirtualProblemKind.Syntax or VirtualProblemKind.NoReferences => null,
|
||||||
|
_ when names is not null && problem.MeterIds.Count > 0 => string.Join(", ", problem.MeterIds.Distinct().Select(id => names.Meter(id))),
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
var sentence = problem.Kind.Display();
|
||||||
|
return detail is null ? sentence : sentence + " (" + detail + ")";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>What contradicts itself in the totals configuration (D-22, D-23), with the role it is about.</summary>
|
||||||
|
public static string TotalsReason(TotalsProblem problem, string? otherMeter = null)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(problem);
|
||||||
|
|
||||||
|
var sentence = problem.Kind.Display();
|
||||||
|
var detail = problem.Role is { } role ? role.Display() : otherMeter;
|
||||||
|
return detail is null ? sentence : sentence + " (" + detail + ")";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A quantity kind the validator names by its token, in words ("mixed" too); anything else as it is.</summary>
|
||||||
|
private static string KindWord(string value) => MeterEditing.MeterEditorText.KindWord(value);
|
||||||
|
|
||||||
|
/// <summary>The cost category editor.</summary>
|
||||||
|
public const string CategoriesPath = "/admin/categories";
|
||||||
|
|
||||||
|
private static string? CalculationLink(int? meterId, AnalysisQuery? query) => MeterLink(meterId, MeterLinks.TabCalculation, null, query);
|
||||||
|
|
||||||
|
private static string? MeterLink(int? meterId, string tab, string? action, AnalysisQuery? query) =>
|
||||||
|
meterId is { } id ? MeterLinks.Detail(id, tab, action, query) : null;
|
||||||
|
|
||||||
|
private static AttentionItem Item(
|
||||||
|
AnalysisProblemKind kind, int? meterId, AttentionSeverity severity, string text, string? actionText = null, string? href = null) =>
|
||||||
|
Item("problem:" + kind, meterId, severity, text, actionText, href);
|
||||||
|
|
||||||
|
private static AttentionItem Item(
|
||||||
|
string kind, int? meterId, AttentionSeverity severity, string text, string? actionText = null, string? href = null)
|
||||||
|
{
|
||||||
|
var key = string.Create(CultureInfo.InvariantCulture, $"{kind}:{meterId}:{text}");
|
||||||
|
return new AttentionItem(key, severity, text, href is null ? null : actionText, href);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
using MeterVault.App.Localization;
|
||||||
|
using MeterVault.Core.Analysis;
|
||||||
|
|
||||||
|
namespace MeterVault.App.Analysis;
|
||||||
|
|
||||||
|
/// <summary>Which direction of a change is good news for a metric (D-08, brief §4.2).</summary>
|
||||||
|
public enum ChangePolarity
|
||||||
|
{
|
||||||
|
/// <summary>More is worse: consumption, runtime, cost.</summary>
|
||||||
|
HigherIsWorse,
|
||||||
|
|
||||||
|
/// <summary>More is better: generation, export.</summary>
|
||||||
|
HigherIsBetter,
|
||||||
|
|
||||||
|
/// <summary>Neither: a signed net result, an indicator, a tank level, a cost that is a credit.</summary>
|
||||||
|
Neutral,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>How a change is coloured: good, bad or neither. Never shown by colour alone — the words and the arrow carry it.</summary>
|
||||||
|
public enum ChangeTone
|
||||||
|
{
|
||||||
|
Neutral,
|
||||||
|
Good,
|
||||||
|
Bad,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The polarity of a metric's changes (D-08: more consumption is not good, more generation is).</summary>
|
||||||
|
public static class ChangePolarities
|
||||||
|
{
|
||||||
|
/// <summary>The polarity of a quantity kind; net results and indicators are neutral.</summary>
|
||||||
|
public static ChangePolarity For(QuantityKind kind) => kind switch
|
||||||
|
{
|
||||||
|
QuantityKind.Consumption or QuantityKind.Runtime or QuantityKind.Cost => ChangePolarity.HigherIsWorse,
|
||||||
|
QuantityKind.Generation or QuantityKind.Export => ChangePolarity.HigherIsBetter,
|
||||||
|
_ => ChangePolarity.Neutral,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>The polarity of a toolbar metric; net and tank level are neutral.</summary>
|
||||||
|
public static ChangePolarity For(AnalysisMetric metric) => metric switch
|
||||||
|
{
|
||||||
|
AnalysisMetric.Consumption or AnalysisMetric.Runtime or AnalysisMetric.Cost => ChangePolarity.HigherIsWorse,
|
||||||
|
AnalysisMetric.Generation or AnalysisMetric.Export => ChangePolarity.HigherIsBetter,
|
||||||
|
_ => ChangePolarity.Neutral,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A cost rising is worse — unless either side is a credit (a negative cost, a feed-in larger than the charges):
|
||||||
|
/// then "more" and "less" have no settled meaning and the change is neutral.
|
||||||
|
/// </summary>
|
||||||
|
public static ChangePolarity ForCost(double? current, double? previous) =>
|
||||||
|
current < 0 || previous < 0 ? ChangePolarity.Neutral : ChangePolarity.HigherIsWorse;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A change in words (D-08): the absolute difference always, the percentage where it applies ("percentage not
|
||||||
|
/// applicable" otherwise), and the direction as a word — so a change is readable without its colour.
|
||||||
|
/// </summary>
|
||||||
|
public static class ChangeDisplay
|
||||||
|
{
|
||||||
|
/// <summary>Good, bad or neutral for <paramref name="polarity"/>; neutral when unknown or unchanged.</summary>
|
||||||
|
public static ChangeTone Tone(Change change, ChangePolarity polarity)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(change);
|
||||||
|
|
||||||
|
if (!change.IsAvailable || change.Direction == 0 || polarity == ChangePolarity.Neutral)
|
||||||
|
{
|
||||||
|
return ChangeTone.Neutral;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (change.Direction > 0) == (polarity == ChangePolarity.HigherIsBetter) ? ChangeTone.Good : ChangeTone.Bad;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// "12 kWh more (+4.5 %)", "3.50 € less (-2.0 %)", "12 kWh more (percentage not applicable)", "No change", or
|
||||||
|
/// "No comparison" when either value is unknown.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="change">The change.</param>
|
||||||
|
/// <param name="formatMagnitude">Formats the size of the difference (a quantity with its unit, money).</param>
|
||||||
|
public static string Words(Change change, Func<double, string> formatMagnitude)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(change);
|
||||||
|
ArgumentNullException.ThrowIfNull(formatMagnitude);
|
||||||
|
|
||||||
|
if (change.Absolute is not { } difference)
|
||||||
|
{
|
||||||
|
return Strings.Change_Unavailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (change.Direction == 0)
|
||||||
|
{
|
||||||
|
return Strings.Change_None;
|
||||||
|
}
|
||||||
|
|
||||||
|
var magnitude = formatMagnitude(Math.Abs(difference));
|
||||||
|
var words = Loc.F(change.Direction > 0 ? Strings.Change_More : Strings.Change_Less, magnitude);
|
||||||
|
return words + " (" + Format.ChangePercent(change) + ")";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The CSS class colouring a tone with the theme's palette (<c>app.css</c>).</summary>
|
||||||
|
public static string CssClass(ChangeTone tone) => tone switch
|
||||||
|
{
|
||||||
|
ChangeTone.Good => "mv-change-good",
|
||||||
|
ChangeTone.Bad => "mv-change-bad",
|
||||||
|
_ => "mv-change-neutral",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
using MeterVault.App.Localization;
|
||||||
|
using MeterVault.Core.Analysis;
|
||||||
|
using MeterVault.Infrastructure.Dashboard;
|
||||||
|
|
||||||
|
namespace MeterVault.App.Analysis;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How every page states a cost change (D-07, brief §10 Phase 4 exit): one rule — <see cref="OverviewComparison.Between"/>,
|
||||||
|
/// the totals when both periods are complete, else the paired buckets both sides have complete, else not comparable —
|
||||||
|
/// and one wording, so the Overview, an energy type, the Analysis page and a meter never disagree about the same scope and
|
||||||
|
/// period.
|
||||||
|
/// </summary>
|
||||||
|
public static class CostChanges
|
||||||
|
{
|
||||||
|
/// <summary>The change for a card: null without a comparison; unavailable ("No comparison") when not comparable.</summary>
|
||||||
|
public static Change? ForCard(CostChange change)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(change);
|
||||||
|
|
||||||
|
return change.Basis == CostChangeBasis.NoComparison ? null : change.Change;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The change for a table's total row; null unless one is stated.</summary>
|
||||||
|
public static Change? ForTotalRow(CostChange change)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(change);
|
||||||
|
|
||||||
|
return change.Change.IsAvailable ? change.Change : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Whether a rise is good news: neutral when either amount is a credit.</summary>
|
||||||
|
public static ChangePolarity Polarity(CostChange change)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(change);
|
||||||
|
|
||||||
|
return ChangePolarities.ForCost(change.Current, change.Previous);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The caption of a change: what it is compared with, and — when only part of the period could be matched — that it
|
||||||
|
/// is ("Same period last year · over the part both periods cover").
|
||||||
|
/// </summary>
|
||||||
|
public static string? Caption(AnalysisQuery query, CostChange change)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(query);
|
||||||
|
ArgumentNullException.ThrowIfNull(change);
|
||||||
|
|
||||||
|
if (change.Basis == CostChangeBasis.NoComparison)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var compared = query.Comparison.Display();
|
||||||
|
return change.IsPartial ? compared + " · " + Strings.Overview_MatchedOnly : compared;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using MeterVault.Core.Analysis;
|
||||||
|
using MeterVault.Infrastructure.Costing;
|
||||||
|
|
||||||
|
namespace MeterVault.App.Analysis;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How a cost figure is compared (D-06), from <see cref="AnalysisQuery.ToCostComparison"/>: the comparison as resolved,
|
||||||
|
/// the cost request that prices it in the paired buckets, and the pairs themselves.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Resolution">The comparison period, or why there is none (a code the UI words).</param>
|
||||||
|
/// <param name="Request">The request to price the comparison with; null when the comparison does not apply.</param>
|
||||||
|
/// <param name="Pairs">Each current bucket with its image, by index (A-10); empty when the comparison does not apply.</param>
|
||||||
|
public sealed record CostComparisonRequest(
|
||||||
|
ComparisonResolution Resolution,
|
||||||
|
CostAnalysisRequest? Request,
|
||||||
|
IReadOnlyList<BucketPair> Pairs)
|
||||||
|
{
|
||||||
|
public bool IsApplicable => Request is not null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
using MeterVault.App.Localization;
|
||||||
|
using MeterVault.Core.Analysis;
|
||||||
|
using MeterVault.Core.Analysis.Costing;
|
||||||
|
|
||||||
|
namespace MeterVault.App.Analysis;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How one figure reads beside its number (brief §4.3, D-14): whether a number can be shown at all, whether it is
|
||||||
|
/// qualified — partial, estimated, an opening balance, only partly priced — and its status, detail and provenance in the
|
||||||
|
/// reader's words. Charts mark a qualified figure, tables and tooltips word it, so its meaning never rests on colour.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="IsKnown">A number can be shown (a partial total counts; a missing or invalid bucket does not).</param>
|
||||||
|
/// <param name="IsComplete">
|
||||||
|
/// The figure is complete: an available bucket, or a fully priced cost over available quantities. Only complete figures
|
||||||
|
/// are compared bucket by bucket (D-07); estimated provenance does not make a figure incomplete.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="IsQualified">The figure is not a plain complete measured value: incomplete, estimated or an opening balance.</param>
|
||||||
|
/// <param name="Status">The status in words ("Complete", "Partial", "Not priced (no tariff)").</param>
|
||||||
|
/// <param name="Detail">Why, in words, or null.</param>
|
||||||
|
/// <param name="Provenance">Where the value comes from, in words ("Measured, Estimated"); empty when there is none.</param>
|
||||||
|
public sealed record FigureStatus(bool IsKnown, bool IsComplete, bool IsQualified, string Status, string? Detail, string Provenance)
|
||||||
|
{
|
||||||
|
/// <summary>Status and provenance in one line: "Partial · Measured".</summary>
|
||||||
|
public string Summary => string.IsNullOrEmpty(Provenance) ? Status : Status + " · " + Provenance;
|
||||||
|
|
||||||
|
/// <summary>The summary with its detail: "Partial · Measured — Data covers only part of this period".</summary>
|
||||||
|
public string Full => Detail is null ? Summary : Summary + " — " + Detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Words a <see cref="BucketValue"/> or a <see cref="CostAmount"/> (<see cref="FigureStatus"/>).</summary>
|
||||||
|
public static class FigureText
|
||||||
|
{
|
||||||
|
/// <summary>Provenance that qualifies a value even when its bucket is complete.</summary>
|
||||||
|
private const Provenance QualifyingProvenance = Provenance.Estimated | Provenance.OpeningBalance;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A quantity bucket: its status and issue (<see cref="DisplayNames"/>), the issue's detail, and — for a value derived
|
||||||
|
/// from a dependency — the meter that caused it (the last id of <see cref="BucketValue.DependencyPath"/>).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="value">The value.</param>
|
||||||
|
/// <param name="meterName">Names a meter id for the dependency detail; "#id" without it.</param>
|
||||||
|
public static FigureStatus Of(BucketValue value, Func<int, string?>? meterName = null)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(value);
|
||||||
|
|
||||||
|
var known = value.Value is { } number && double.IsFinite(number);
|
||||||
|
var complete = value.Status == BucketStatus.Available;
|
||||||
|
var qualified = !complete || (value.Provenance & QualifyingProvenance) != 0;
|
||||||
|
return new FigureStatus(known, complete, qualified, value.Status.Display(), DetailOf(value, meterName), value.Provenance.Display());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A cost figure: its price coverage (<see cref="CostAmount.Status"/>), and as detail the availability of the
|
||||||
|
/// quantities behind it, components left unpriced and unchecked tariff units.
|
||||||
|
/// </summary>
|
||||||
|
public static FigureStatus Of(CostAmount amount)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(amount);
|
||||||
|
|
||||||
|
// Nothing to bill and nothing missing (no line, no charge, no manual cost in the bucket): the engine keeps the
|
||||||
|
// figure unknown, so it reads "No data" — never "Priced" beside "—", and never complete (brief §4.3).
|
||||||
|
if (IsNothingBooked(amount))
|
||||||
|
{
|
||||||
|
return new FigureStatus(false, false, true, BucketStatus.Missing.Display(), null, string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
var known = amount.Cost is { } cost && double.IsFinite(cost);
|
||||||
|
var complete = amount.Status == CostStatus.Priced && amount.Availability == BucketStatus.Available;
|
||||||
|
var qualified = !complete || amount.Unverified;
|
||||||
|
|
||||||
|
// Prices cover the bucket but its quantity is unknown (no data, pending, unresolved): the cost is unknown
|
||||||
|
// because of the quantity, so that is its status — "Priced" beside "—" would read as a priced figure.
|
||||||
|
var unknownQuantity = !known && amount.Status == CostStatus.Priced && amount.Availability != BucketStatus.Available;
|
||||||
|
|
||||||
|
var details = new List<string>(3);
|
||||||
|
if (amount.Availability != BucketStatus.Available && !unknownQuantity)
|
||||||
|
{
|
||||||
|
details.Add(Loc.F(Strings.Figure_QuantityStatus, amount.Availability.Display()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (amount.IncludesNotPriced && amount.Status is CostStatus.Priced or CostStatus.Partial)
|
||||||
|
{
|
||||||
|
details.Add(Strings.Figure_SomeNotPriced);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (amount.Unverified)
|
||||||
|
{
|
||||||
|
details.Add(Strings.Figure_UnverifiedUnit);
|
||||||
|
}
|
||||||
|
|
||||||
|
var status = unknownQuantity ? amount.Availability.Display() : amount.Status.Display();
|
||||||
|
return new FigureStatus(
|
||||||
|
known, complete, qualified, status, details.Count > 0 ? string.Join("; ", details) : null, string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True for a cost figure with nothing booked in it: no value, nothing unpriced, no quantity unavailable — the empty
|
||||||
|
/// figure of a bucket with no line, charge or manual cost (<see cref="CostAmount.Empty"/>). It is unknown, not a
|
||||||
|
/// priced zero; tables, charts and the CSV export word it as "No data" alike.
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsNothingBooked(CostAmount amount)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(amount);
|
||||||
|
|
||||||
|
return amount.Cost is null && amount.Status == CostStatus.Priced && amount.Availability == BucketStatus.Available
|
||||||
|
&& amount.MissingPrices.Count == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? DetailOf(BucketValue value, Func<int, string?>? meterName)
|
||||||
|
{
|
||||||
|
if (value.Issue == ValueIssue.None)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var extras = new List<string>(2);
|
||||||
|
if (!string.IsNullOrWhiteSpace(value.IssueDetail))
|
||||||
|
{
|
||||||
|
extras.Add(value.IssueDetail.Trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.DependencyPath is { Count: > 1 } path)
|
||||||
|
{
|
||||||
|
var culprit = path[^1];
|
||||||
|
extras.Add(meterName?.Invoke(culprit) is { Length: > 0 } name ? name : "#" + culprit.ToString(System.Globalization.CultureInfo.CurrentCulture));
|
||||||
|
}
|
||||||
|
|
||||||
|
var issue = value.Issue.Display();
|
||||||
|
return extras.Count == 0 ? issue : issue + " (" + string.Join(", ", extras) + ")";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace MeterVault.App.Analysis;
|
||||||
|
|
||||||
|
/// <summary>A piece of a virtual meter's formula: plain text, or a meter reference (<c>m12</c>) with its id.</summary>
|
||||||
|
public sealed record FormulaSegment(string Text, int? MeterId)
|
||||||
|
{
|
||||||
|
public bool IsMeter => MeterId is not null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A virtual meter's formula for display (brief §5.1): split into text and meter references so each <c>m<id></c>
|
||||||
|
/// token can stand beside its meter's friendly name. The scan mirrors the formula lexer: an identifier is a letter or
|
||||||
|
/// underscore followed by letters, digits or underscores, a reference is exactly <c>m</c> and digits, and a number run
|
||||||
|
/// (digits and dots) is skipped whole, so the "3" of "1.3" is never read as part of a name.
|
||||||
|
/// </summary>
|
||||||
|
public static class FormulaText
|
||||||
|
{
|
||||||
|
/// <summary>Splits an expression into segments; empty for null or blank.</summary>
|
||||||
|
public static IReadOnlyList<FormulaSegment> Split(string? expression)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(expression))
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var segments = new List<FormulaSegment>();
|
||||||
|
var text = new StringBuilder();
|
||||||
|
var pos = 0;
|
||||||
|
while (pos < expression.Length)
|
||||||
|
{
|
||||||
|
var c = expression[pos];
|
||||||
|
if (char.IsLetter(c) || c == '_')
|
||||||
|
{
|
||||||
|
var start = pos;
|
||||||
|
while (pos < expression.Length && (char.IsLetterOrDigit(expression[pos]) || expression[pos] == '_'))
|
||||||
|
{
|
||||||
|
pos++;
|
||||||
|
}
|
||||||
|
|
||||||
|
var token = expression[start..pos];
|
||||||
|
if (TryParseReference(token, out var id))
|
||||||
|
{
|
||||||
|
Flush(segments, text);
|
||||||
|
segments.Add(new FormulaSegment(token, id));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
text.Append(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (char.IsAsciiDigit(c) || c == '.')
|
||||||
|
{
|
||||||
|
var start = pos;
|
||||||
|
while (pos < expression.Length && (char.IsAsciiDigit(expression[pos]) || expression[pos] == '.'))
|
||||||
|
{
|
||||||
|
pos++;
|
||||||
|
}
|
||||||
|
|
||||||
|
text.Append(expression, start, pos - start);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
text.Append(c);
|
||||||
|
pos++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Flush(segments, text);
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The expression with each reference followed by its meter's name: <c>m5 (Solar 1) + m6 (Solar 2)</c>. A reference
|
||||||
|
/// nobody named stays as it is.
|
||||||
|
/// </summary>
|
||||||
|
public static string Annotate(string? expression, Func<int, string?> name)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(name);
|
||||||
|
|
||||||
|
var builder = new StringBuilder();
|
||||||
|
foreach (var segment in Split(expression))
|
||||||
|
{
|
||||||
|
builder.Append(segment.Text);
|
||||||
|
if (segment.MeterId is { } id && name(id) is { Length: > 0 } meter)
|
||||||
|
{
|
||||||
|
builder.Append(" (").Append(meter).Append(')');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseReference(string token, out int id)
|
||||||
|
{
|
||||||
|
id = 0;
|
||||||
|
return token.Length >= 2
|
||||||
|
&& token[0] == 'm'
|
||||||
|
&& token.AsSpan(1).IndexOfAnyExceptInRange('0', '9') < 0
|
||||||
|
&& int.TryParse(token.AsSpan(1), NumberStyles.None, CultureInfo.InvariantCulture, out id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Flush(List<FormulaSegment> segments, StringBuilder text)
|
||||||
|
{
|
||||||
|
if (text.Length > 0)
|
||||||
|
{
|
||||||
|
segments.Add(new FormulaSegment(text.ToString(), null));
|
||||||
|
text.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
namespace MeterVault.App.Analysis;
|
||||||
|
|
||||||
|
/// <summary>One requested load: its generation and the token that cancels it when a newer load is requested.</summary>
|
||||||
|
public readonly record struct LoadTicket(long Generation, CancellationToken Token);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Makes sure only the latest requested load is committed (brief §8, A13): <see cref="Next"/> cancels the load before it
|
||||||
|
/// and hands out a ticket; a load commits its result only while <see cref="IsCurrent"/> holds for its ticket. A delayed
|
||||||
|
/// first request can then never overwrite the scope or range the user selected after it — the old
|
||||||
|
/// <c>if (_loading) return;</c> guard dropped the newer request instead.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>The page pattern (one sequencer per independently loading panel):</para>
|
||||||
|
/// <code>
|
||||||
|
/// private readonly LoadSequencer _loads = new();
|
||||||
|
/// private readonly LoadState<AnalysisResult> _result = new();
|
||||||
|
///
|
||||||
|
/// protected override async Task OnParametersSetAsync()
|
||||||
|
/// {
|
||||||
|
/// var query = AnalysisQuery.Parse(Nav.Uri, Defaults);
|
||||||
|
/// if (query == _query) return; // an action drop or a tab change is not a new analysis (D-46)
|
||||||
|
/// _query = query;
|
||||||
|
/// await _loads.RunAsync(_result, async token =>
|
||||||
|
/// {
|
||||||
|
/// var period = await Periods.ResolveAsync(query, Clock.Now, token);
|
||||||
|
/// return await Reader.ReadAsync(query.ToAnalysisRequest(period)!, token);
|
||||||
|
/// }, Logger);
|
||||||
|
/// }
|
||||||
|
///
|
||||||
|
/// public void Dispose() => _loads.Dispose();
|
||||||
|
/// </code>
|
||||||
|
/// <para>
|
||||||
|
/// Render from <see cref="LoadState{T}"/>: <see cref="LoadState{T}.IsInitialLoad"/> → skeleton/progress;
|
||||||
|
/// <see cref="LoadState{T}.Value"/> with <see cref="LoadState{T}.IsRefreshing"/> → the previous result, dimmed, with a
|
||||||
|
/// thin progress bar (stale but visible); <see cref="LoadState{T}.Error"/> → a panel-level error with Retry (and, when a
|
||||||
|
/// value is kept, the note that it is from before); otherwise the value. Everything a panel shows — title, chart, table —
|
||||||
|
/// comes from the one committed value, so a new type's title never sits above the previous type's chart.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Initial loads stay in <c>OnInitialized</c>/<c>OnParametersSet</c> (D-46): the render tests read prerendered data.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class LoadSequencer : IDisposable
|
||||||
|
{
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private CancellationTokenSource? _current;
|
||||||
|
private long _generation;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
/// <summary>The generation of the latest ticket; 0 before the first.</summary>
|
||||||
|
public long Generation
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
return _generation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Starts a new load: cancels the previous one and returns the new ticket.</summary>
|
||||||
|
/// <exception cref="ObjectDisposedException">The sequencer (its component) is disposed.</exception>
|
||||||
|
public LoadTicket Next()
|
||||||
|
{
|
||||||
|
CancellationTokenSource? previous;
|
||||||
|
LoadTicket ticket;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
|
|
||||||
|
previous = _current;
|
||||||
|
_current = new CancellationTokenSource();
|
||||||
|
_generation++;
|
||||||
|
ticket = new LoadTicket(_generation, _current.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancelled before it is disposed, so the token the superseded load holds stays cancelled (and usable).
|
||||||
|
previous?.Cancel();
|
||||||
|
previous?.Dispose();
|
||||||
|
return ticket;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>True while <paramref name="ticket"/> is the latest load and has not been cancelled: only then may it commit.</summary>
|
||||||
|
public bool IsCurrent(LoadTicket ticket)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
return !_disposed && ticket.Generation == _generation && !ticket.Token.IsCancellationRequested;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs one load through <paramref name="state"/>: marks it loading, awaits <paramref name="load"/> with the ticket's
|
||||||
|
/// token, and commits the value or the error — only if no newer load was requested meanwhile. A superseded or
|
||||||
|
/// cancelled load changes nothing. Errors are logged and kept in the state for a panel-level Retry; they never
|
||||||
|
/// escape to the circuit.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>True when this load committed (a value or an error), false when it was superseded.</returns>
|
||||||
|
public async Task<bool> RunAsync<T>(LoadState<T> state, Func<CancellationToken, Task<T>> load, ILogger? logger = null)
|
||||||
|
where T : class
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(state);
|
||||||
|
ArgumentNullException.ThrowIfNull(load);
|
||||||
|
|
||||||
|
var ticket = Next();
|
||||||
|
state.Begin(ticket.Generation);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var value = await load(ticket.Token);
|
||||||
|
if (!IsCurrent(ticket))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.Commit(value);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (ticket.Token.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A panel shows its own error with Retry: nothing a reader throws may end the circuit.
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
if (!IsCurrent(ticket))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger?.LogError(ex, "Loading {Panel} failed", typeof(T).Name);
|
||||||
|
state.Fail(ex);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Cancels the load in flight; no ticket is current afterwards.</summary>
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
CancellationTokenSource? current;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_disposed = true;
|
||||||
|
current = _current;
|
||||||
|
_current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
current?.Cancel();
|
||||||
|
current?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What a panel shows while it loads (brief §8): the last committed value, whether a load is running, and the error of
|
||||||
|
/// the last load. The value is kept through a refresh and a failure, so a panel stays readable ("stale but visible")
|
||||||
|
/// instead of blanking.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LoadState<T>
|
||||||
|
where T : class
|
||||||
|
{
|
||||||
|
/// <summary>The last committed value; null before the first load finished.</summary>
|
||||||
|
public T? Value { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>True while a load is running.</summary>
|
||||||
|
public bool IsLoading { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>The error of the last committed load; null after a success.</summary>
|
||||||
|
public Exception? Error { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>The generation of the running or last committed load.</summary>
|
||||||
|
public long Generation { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Nothing to show yet: the first load is running.</summary>
|
||||||
|
public bool IsInitialLoad => IsLoading && Value is null;
|
||||||
|
|
||||||
|
/// <summary>A value is shown while a newer one loads.</summary>
|
||||||
|
public bool IsRefreshing => IsLoading && Value is not null;
|
||||||
|
|
||||||
|
/// <summary>The value shown is not the answer to the current request: a newer load is running, or it failed.</summary>
|
||||||
|
public bool IsStale => Value is not null && (IsLoading || Error is not null);
|
||||||
|
|
||||||
|
/// <summary>A load is starting.</summary>
|
||||||
|
public void Begin(long generation)
|
||||||
|
{
|
||||||
|
Generation = generation;
|
||||||
|
IsLoading = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The load finished with <paramref name="value"/>.</summary>
|
||||||
|
public void Commit(T value)
|
||||||
|
{
|
||||||
|
Value = value;
|
||||||
|
Error = null;
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The load failed; the previous value, if any, stays visible.</summary>
|
||||||
|
public void Fail(Exception error)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(error);
|
||||||
|
|
||||||
|
Error = error;
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Forgets the value (e.g. when the page moved to a different entity whose old data must not show).</summary>
|
||||||
|
public void Clear()
|
||||||
|
{
|
||||||
|
Value = null;
|
||||||
|
Error = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using MeterVault.Infrastructure.Analysis;
|
||||||
|
using MeterVault.Infrastructure.Costing;
|
||||||
|
|
||||||
|
namespace MeterVault.App.Analysis;
|
||||||
|
|
||||||
|
/// <summary>What an analysis URL is about (D-47 <c>scope=portfolio|type|category|meter|meters</c>).</summary>
|
||||||
|
public enum QueryScopeKind
|
||||||
|
{
|
||||||
|
/// <summary>Everything: every energy type's totals and the whole bill.</summary>
|
||||||
|
Portfolio,
|
||||||
|
|
||||||
|
/// <summary>One energy type (<c>scope=type&id=</c>).</summary>
|
||||||
|
EnergyType,
|
||||||
|
|
||||||
|
/// <summary>One cost category (<c>scope=category&id=</c>); analysed by cost.</summary>
|
||||||
|
Category,
|
||||||
|
|
||||||
|
/// <summary>One meter, physical or virtual (<c>scope=meter&id=</c>).</summary>
|
||||||
|
Meter,
|
||||||
|
|
||||||
|
/// <summary>An explicit selection of meters side by side (<c>scope=meters&ids=3,5</c>, at most <see cref="AnalysisLimits.MaxSeries"/>).</summary>
|
||||||
|
Meters,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The scope of an <see cref="AnalysisQuery"/>: a kind and the id(s) it names. Immutable, compared by value, and mapped
|
||||||
|
/// in one place onto the quantity reader's <see cref="AnalysisScope"/> and the cost reader's <see cref="CostScope"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class QueryScope : IEquatable<QueryScope>
|
||||||
|
{
|
||||||
|
private static readonly (QueryScopeKind Value, string Token)[] Tokens =
|
||||||
|
[
|
||||||
|
(QueryScopeKind.Portfolio, "portfolio"),
|
||||||
|
(QueryScopeKind.EnergyType, "type"),
|
||||||
|
(QueryScopeKind.Category, "category"),
|
||||||
|
(QueryScopeKind.Meter, "meter"),
|
||||||
|
(QueryScopeKind.Meters, "meters"),
|
||||||
|
];
|
||||||
|
|
||||||
|
private QueryScope(QueryScopeKind kind, int? id, IReadOnlyList<int> meterIds)
|
||||||
|
{
|
||||||
|
Kind = kind;
|
||||||
|
Id = id;
|
||||||
|
MeterIds = meterIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Every energy type and the whole bill.</summary>
|
||||||
|
public static QueryScope Portfolio { get; } = new(QueryScopeKind.Portfolio, null, []);
|
||||||
|
|
||||||
|
public QueryScopeKind Kind { get; }
|
||||||
|
|
||||||
|
/// <summary>The energy type, category or meter id; null for the portfolio and a meter selection.</summary>
|
||||||
|
public int? Id { get; }
|
||||||
|
|
||||||
|
/// <summary>The meters of a selection (distinct, in the order given); the one meter of a meter scope; empty otherwise.</summary>
|
||||||
|
public IReadOnlyList<int> MeterIds { get; }
|
||||||
|
|
||||||
|
/// <summary>The URL token of <see cref="Kind"/>.</summary>
|
||||||
|
public string Token => TokenOf(Kind);
|
||||||
|
|
||||||
|
public static QueryScope ForEnergyType(int energyTypeId) => new(QueryScopeKind.EnergyType, Positive(energyTypeId, nameof(energyTypeId)), []);
|
||||||
|
|
||||||
|
public static QueryScope ForCategory(int categoryId) => new(QueryScopeKind.Category, Positive(categoryId, nameof(categoryId)), []);
|
||||||
|
|
||||||
|
public static QueryScope ForMeter(int meterId)
|
||||||
|
{
|
||||||
|
Positive(meterId, nameof(meterId));
|
||||||
|
return new QueryScope(QueryScopeKind.Meter, meterId, [meterId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An explicit selection. The ids are made distinct (first occurrence wins). More than
|
||||||
|
/// <see cref="AnalysisLimits.MaxSeries"/> are kept as given; the reader refuses them (<see cref="AnalysisRefusal.TooManySeries"/>),
|
||||||
|
/// and <see cref="AnalysisQuery.Parse(string, AnalysisDefaults)"/> caps them with a notice.
|
||||||
|
/// </summary>
|
||||||
|
/// <exception cref="ArgumentException">No id, or an id that is not positive.</exception>
|
||||||
|
public static QueryScope ForMeters(IEnumerable<int> meterIds)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(meterIds);
|
||||||
|
|
||||||
|
List<int> ids = [.. meterIds.Distinct()];
|
||||||
|
if (ids.Count == 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("A meter selection needs at least one meter.", nameof(meterIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ids.Any(id => id <= 0))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Meter ids are positive.", nameof(meterIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new QueryScope(QueryScopeKind.Meters, null, ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The URL token of a scope kind.</summary>
|
||||||
|
public static string TokenOf(QueryScopeKind kind)
|
||||||
|
{
|
||||||
|
foreach (var (value, token) in Tokens)
|
||||||
|
{
|
||||||
|
if (value == kind)
|
||||||
|
{
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(kind), kind, "No URL token for this scope.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Parses a scope token (<c>portfolio</c>, <c>type</c>, …), ignoring case and surrounding blanks.</summary>
|
||||||
|
public static bool TryParseKind(string? token, out QueryScopeKind kind)
|
||||||
|
{
|
||||||
|
kind = default;
|
||||||
|
if (string.IsNullOrWhiteSpace(token))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var text = token.Trim();
|
||||||
|
foreach (var (value, name) in Tokens)
|
||||||
|
{
|
||||||
|
if (string.Equals(name, text, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
kind = value;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The quantity reader's scope: the portfolio, an energy type, a meter or a selection. Null for a category, which
|
||||||
|
/// the quantity reader does not know — a category is analysed by cost (brief §7.4).
|
||||||
|
/// </summary>
|
||||||
|
public AnalysisScope? ToAnalysisScope() => Kind switch
|
||||||
|
{
|
||||||
|
QueryScopeKind.Portfolio => AnalysisScope.Portfolio,
|
||||||
|
QueryScopeKind.EnergyType => AnalysisScope.ForEnergyType(Id!.Value),
|
||||||
|
QueryScopeKind.Meter => AnalysisScope.ForMeter(Id!.Value),
|
||||||
|
QueryScopeKind.Meters => AnalysisScope.ForMeters(MeterIds),
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The cost reader's scopes: one for the portfolio, a type, a meter or a category, and one per meter for a selection
|
||||||
|
/// (each meter is priced by its own rule, never as a sum, D-39).
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<CostScope> ToCostScopes() => Kind switch
|
||||||
|
{
|
||||||
|
QueryScopeKind.Portfolio => [CostScope.Portfolio],
|
||||||
|
QueryScopeKind.EnergyType => [CostScope.ForEnergyType(Id!.Value)],
|
||||||
|
QueryScopeKind.Category => [CostScope.ForCategory(Id!.Value)],
|
||||||
|
QueryScopeKind.Meter => [CostScope.ForMeter(Id!.Value)],
|
||||||
|
_ => [.. MeterIds.Select(CostScope.ForMeter)],
|
||||||
|
};
|
||||||
|
|
||||||
|
public bool Equals(QueryScope? other) =>
|
||||||
|
other is not null && Kind == other.Kind && Id == other.Id && MeterIds.SequenceEqual(other.MeterIds);
|
||||||
|
|
||||||
|
public override bool Equals(object? obj) => Equals(obj as QueryScope);
|
||||||
|
|
||||||
|
public override int GetHashCode()
|
||||||
|
{
|
||||||
|
var hash = new HashCode();
|
||||||
|
hash.Add(Kind);
|
||||||
|
hash.Add(Id);
|
||||||
|
foreach (var id in MeterIds)
|
||||||
|
{
|
||||||
|
hash.Add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return hash.ToHashCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary><c>portfolio</c>, <c>type:3</c>, <c>meter:12</c>, <c>meters:3,5</c> — for logs and keys.</summary>
|
||||||
|
public override string ToString() => Kind switch
|
||||||
|
{
|
||||||
|
QueryScopeKind.Portfolio => Token,
|
||||||
|
QueryScopeKind.Meters => Token + ":" + string.Join(',', MeterIds.Select(id => id.ToString(CultureInfo.InvariantCulture))),
|
||||||
|
_ => Token + ":" + Id!.Value.ToString(CultureInfo.InvariantCulture),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static int Positive(int id, string paramName) =>
|
||||||
|
id > 0 ? id : throw new ArgumentOutOfRangeException(paramName, id, "Ids are positive.");
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using MeterVault.App.Analysis;
|
||||||
|
|
||||||
|
namespace MeterVault.App;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Addresses of the analysis pages — the Overview, an energy type, the Analysis page (<c>/trends</c>), Solar,
|
||||||
|
/// Consumables — and of the CSV export, each carrying the period of an <see cref="AnalysisQuery"/> so a drill-down or a
|
||||||
|
/// Back link never loses the dates (brief §3.1, D-47). Meter pages are <see cref="MeterLinks"/>'.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Keys a route already had come first, the analysis keys after them (D-47). A key equal to the target page's default
|
||||||
|
/// is left out (D-02), so a link written from the Overview's month to date says <c>period=mtd</c> to a history page but
|
||||||
|
/// nothing to the Overview.
|
||||||
|
/// </remarks>
|
||||||
|
public static class AnalysisLinks
|
||||||
|
{
|
||||||
|
/// <summary>The Overview tab of an energy type page: quantities, cost, coverage, changes (brief §7.3).</summary>
|
||||||
|
public const string EnergyTabOverview = "overview";
|
||||||
|
|
||||||
|
/// <summary>The History tab: the shared chart and table.</summary>
|
||||||
|
public const string EnergyTabHistory = "history";
|
||||||
|
|
||||||
|
/// <summary>The Flow tab: the Sankey and its table.</summary>
|
||||||
|
public const string EnergyTabFlow = "flow";
|
||||||
|
|
||||||
|
/// <summary>The Meters tab: the type's meters with their period values.</summary>
|
||||||
|
public const string EnergyTabMeters = "meters";
|
||||||
|
|
||||||
|
/// <summary>The route of the CSV export (D-55).</summary>
|
||||||
|
public const string ExportPath = "/export/analysis.csv";
|
||||||
|
|
||||||
|
/// <summary>The energy type page's tabs, in order; the first is the default.</summary>
|
||||||
|
public static IReadOnlyList<string> EnergyTabs { get; } = [EnergyTabOverview, EnergyTabHistory, EnergyTabFlow, EnergyTabMeters];
|
||||||
|
|
||||||
|
/// <summary>The energy type tab a requested key opens: the key itself (any case), or Overview for anything else.</summary>
|
||||||
|
public static string ResolveEnergyTab(string? tab)
|
||||||
|
{
|
||||||
|
var key = tab?.Trim().ToLowerInvariant();
|
||||||
|
return key is not null && EnergyTabs.Contains(key, StringComparer.Ordinal) ? key : EnergyTabOverview;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The panel index of a requested energy type tab.</summary>
|
||||||
|
public static int EnergyTabIndex(string? tab)
|
||||||
|
{
|
||||||
|
var key = ResolveEnergyTab(tab);
|
||||||
|
for (var i = 0; i < EnergyTabs.Count; i++)
|
||||||
|
{
|
||||||
|
if (string.Equals(EnergyTabs[i], key, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The Overview (<c>/</c>), carrying the period — the target of breadcrumbs and Back.</summary>
|
||||||
|
public static string Overview(AnalysisQuery? query = null) => Carry("/", query, AnalysisDefaults.Overview);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An energy type page (<c>/energy/{id}</c>), optionally on a tab (<see cref="EnergyTabs"/>; the Overview tab is not
|
||||||
|
/// written), carrying the period.
|
||||||
|
/// </summary>
|
||||||
|
public static string EnergyType(int energyTypeId, string? tab = null, AnalysisQuery? query = null)
|
||||||
|
{
|
||||||
|
var url = "/energy/" + energyTypeId.ToString(CultureInfo.InvariantCulture);
|
||||||
|
if (!string.IsNullOrWhiteSpace(tab) && ResolveEnergyTab(tab) is var key && key != EnergyTabOverview)
|
||||||
|
{
|
||||||
|
url += "?tab=" + key;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Carry(url, query, AnalysisDefaults.History);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The Analysis page (<c>/trends</c>) for a scope and metric, carrying the rest of <paramref name="query"/>:
|
||||||
|
/// <c>/trends?scope=type&id=3&metric=cost&period=ytd</c>. A null <paramref name="metric"/> keeps the
|
||||||
|
/// query's.
|
||||||
|
/// </summary>
|
||||||
|
public static string Analysis(QueryScope scope, AnalysisMetric? metric = null, AnalysisQuery? query = null)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(scope);
|
||||||
|
|
||||||
|
var target = (query ?? AnalysisQuery.Default(AnalysisDefaults.History)).WithScope(scope);
|
||||||
|
if (metric is not null)
|
||||||
|
{
|
||||||
|
target = target.WithMetric(metric);
|
||||||
|
}
|
||||||
|
|
||||||
|
return target.AppendTo("/trends", AnalysisDefaults.History, AnalysisQueryParts.All);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Solar (<c>/solar</c>), carrying the period.</summary>
|
||||||
|
public static string Solar(AnalysisQuery? query = null) => Carry("/solar", query, AnalysisDefaults.History);
|
||||||
|
|
||||||
|
/// <summary>Tanks & consumables (<c>/consumables</c>), carrying the period.</summary>
|
||||||
|
public static string Consumables(AnalysisQuery? query = null) => Carry("/consumables", query, AnalysisDefaults.History);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The CSV export of what <paramref name="query"/> shows (D-55): every key that differs from the export's defaults,
|
||||||
|
/// scope included — the export has no route to imply one.
|
||||||
|
/// </summary>
|
||||||
|
public static string Export(AnalysisQuery query)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(query);
|
||||||
|
|
||||||
|
return query.AppendTo(ExportPath, AnalysisDefaults.Export, AnalysisQueryParts.All);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Carry(string url, AnalysisQuery? query, AnalysisDefaults target) =>
|
||||||
|
query is null ? url : query.AppendTo(url, target);
|
||||||
|
}
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
using MeterVault.App.Analysis;
|
||||||
|
using MeterVault.App.Localization;
|
||||||
|
using MeterVault.Core.Analysis;
|
||||||
|
using MeterVault.Core.Analysis.Coverage;
|
||||||
|
using MeterVault.Core.Analysis.Totals;
|
||||||
|
using MeterVault.Infrastructure.Analysis;
|
||||||
|
using MeterVault.Infrastructure.Costing;
|
||||||
|
using MeterVault.Infrastructure.Dashboard;
|
||||||
|
|
||||||
|
namespace MeterVault.App.AnalysisPage;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads what the Analysis page shows for one address (brief §7.4): quantities through the shared analysis reader and
|
||||||
|
/// costs through the cost reader — the same calls the meter and energy type pages, the Overview and the CSV export make
|
||||||
|
/// — and turns them into chart and table inputs once, inside the load. No figure is computed here: the page never adds
|
||||||
|
/// up series, never prices anything, never fills a gap.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Quantities.</b> The portfolio and an energy type show the per-type measures of the metric (consumption: total use
|
||||||
|
/// and grid import, side by side, never added — D-22); a meter, a comparison and a category's meters each their own
|
||||||
|
/// series. One meter also shows its cost by its rule in the same buckets.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Costs.</b> One cost series per cost scope: the whole bill with manual costs and standing charges (the figure the
|
||||||
|
/// Overview shows for the same range), a type's bill, a category (D-42) or a meter by its rule — each priced month by month,
|
||||||
|
/// so the bucket size never changes a total (D-36). A comparison is priced in the images of the current buckets (A-10).
|
||||||
|
/// The priced meters' quantities are read once more for their resolution, which decides how far a bucket can be drilled
|
||||||
|
/// into (D-51) — a monthly import has no finer detail to open.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class AnalysisPageLoader(AnalysisReader reader, CostReader costs, AnalysisPeriods periods)
|
||||||
|
{
|
||||||
|
/// <summary>More base series than this and the comparison stays in the table: overlays would crowd the chart.</summary>
|
||||||
|
public const int MaxOverlaidSeries = 3;
|
||||||
|
|
||||||
|
/// <summary>Reads the page for <paramref name="query"/> (the address as parsed) and its <paramref name="selection"/>.</summary>
|
||||||
|
/// <param name="query">The address's analysis state.</param>
|
||||||
|
/// <param name="selection">The address read against the options (<see cref="AnalysisSelection.Resolve"/>).</param>
|
||||||
|
/// <param name="options">The names of types and meters.</param>
|
||||||
|
/// <param name="now">The instant captured once for this load (D-01).</param>
|
||||||
|
/// <param name="cancellationToken">Cancels a superseded load.</param>
|
||||||
|
public async Task<AnalysisPageView> LoadAsync(
|
||||||
|
AnalysisQuery query, AnalysisSelection selection, AnalysisPageOptions options, DateTimeOffset now, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(query);
|
||||||
|
ArgumentNullException.ThrowIfNull(selection);
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
|
||||||
|
var read = selection.ReadQuery(query);
|
||||||
|
var kind = selection.IsCost ? AnalysisPageViewKind.Cost : AnalysisPageViewKind.Quantity;
|
||||||
|
if (selection.Refusal != AnalysisPageRefusal.None)
|
||||||
|
{
|
||||||
|
// Nothing to read; the dates still show in the toolbar.
|
||||||
|
return new AnalysisPageView(read, selection, read.Resolve(now, periods.Zone), kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
var period = await periods.ResolveAsync(read, now, cancellationToken).ConfigureAwait(false);
|
||||||
|
return kind == AnalysisPageViewKind.Cost
|
||||||
|
? await CostAsync(read, selection, options, period, cancellationToken).ConfigureAwait(false)
|
||||||
|
: await QuantityAsync(read, selection, options, period, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<AnalysisPageView> QuantityAsync(
|
||||||
|
AnalysisQuery read, AnalysisSelection selection, AnalysisPageOptions options, ResolvedPeriod period, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var view = new AnalysisPageView(read, selection, period, AnalysisPageViewKind.Quantity);
|
||||||
|
var result = await reader.ReadAsync(read.ToAnalysisRequest(period)!, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (result.Refusal != AnalysisRefusal.None)
|
||||||
|
{
|
||||||
|
return view with { Plan = result.Plan, ReaderRefusal = result.Refusal };
|
||||||
|
}
|
||||||
|
|
||||||
|
List<AnalysisSeries> series;
|
||||||
|
Dictionary<string, string> names = new(StringComparer.Ordinal);
|
||||||
|
AvailableRange? availability;
|
||||||
|
if (selection.ShowsMeters)
|
||||||
|
{
|
||||||
|
series = [.. selection.SeriesMeterIds.Select(result.SeriesFor).OfType<AnalysisSeries>()];
|
||||||
|
foreach (var item in series)
|
||||||
|
{
|
||||||
|
names[item.Key.Id] = item.Name;
|
||||||
|
}
|
||||||
|
|
||||||
|
availability = AvailableRange.Union(series.Select(s => s.Availability), reader.Zone);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var measures = AnalysisMetrics.MeasuresOf(selection.Metric ?? AnalysisMetric.Consumption);
|
||||||
|
var typeOrder = options.Types.Select((t, i) => (t.Id, i)).ToDictionary(p => p.Id, p => p.i);
|
||||||
|
series =
|
||||||
|
[
|
||||||
|
.. result.Measures
|
||||||
|
.Where(m => m.Key.Measure is { } measure && measures.Contains(measure))
|
||||||
|
.Where(m => selection.EnergyTypeId is not { } typeId || m.EnergyTypeId == typeId)
|
||||||
|
.OrderBy(m => m.EnergyTypeId is { } id ? typeOrder.GetValueOrDefault(id, int.MaxValue) : int.MaxValue)
|
||||||
|
.ThenBy(m => m.Key.Measure),
|
||||||
|
];
|
||||||
|
foreach (var item in series)
|
||||||
|
{
|
||||||
|
names[item.Key.Id] = MeasureName(item, series, selection.Scope.Kind == QueryScopeKind.Portfolio, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
availability = result.Availability.Quantity;
|
||||||
|
}
|
||||||
|
|
||||||
|
CostAnalysis? meterCost = null;
|
||||||
|
var meterCostChange = CostChange.NoComparison;
|
||||||
|
if (selection.Scope.Kind == QueryScopeKind.Meter && options.Meter(selection.Scope.Id) is { IsCostable: true } meter)
|
||||||
|
{
|
||||||
|
var priced = await costs.ReadAsync(new CostAnalysisRequest(CostScope.ForMeter(meter.Id), period) { Plan = result.Plan }, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
meterCost = priced.Refusal == CostRefusal.None && priced.Meter is not { Rule: MeterCostRule.None } ? priced : null;
|
||||||
|
|
||||||
|
// Its change, as the meter's own page states it (D-07): priced in the paired buckets of the comparison.
|
||||||
|
if (meterCost is not null && read.Comparison.Kind != ComparisonKind.None
|
||||||
|
&& read.ToCostComparison(meterCost.Request, meterCost.Plan) is { Request: { } comparisonRequest } costComparison)
|
||||||
|
{
|
||||||
|
var previous = await costs.ReadAsync(comparisonRequest, cancellationToken).ConfigureAwait(false);
|
||||||
|
meterCostChange = OverviewComparison.Between(meterCost.Buckets, meterCost.Total, previous.Buckets, previous.Total, costComparison.Pairs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A derived value names the meter it misses (a source without data, a loop) by its catalog name, as the attention
|
||||||
|
// list beside it does — never by its id.
|
||||||
|
string? MeterNameOf(int id) => options.Meter(id)?.Name;
|
||||||
|
|
||||||
|
var overlays = series.Count <= MaxOverlaidSeries;
|
||||||
|
List<AnalysisChartSeries> chart = [];
|
||||||
|
List<AnalysisTableSeries> table = [];
|
||||||
|
foreach (var item in series)
|
||||||
|
{
|
||||||
|
var name = names[item.Key.Id];
|
||||||
|
chart.Add(AnalysisChartSeries.ForSeries(item, name, meterName: MeterNameOf));
|
||||||
|
if (overlays && AnalysisChartSeries.ComparisonOf(item, AnalysisChartSeries.ComparisonName(name, read.Comparison), meterName: MeterNameOf) is { } overlay)
|
||||||
|
{
|
||||||
|
chart.Add(overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
var row = AnalysisTableSeries.ForSeries(item, name, MeterNameOf);
|
||||||
|
table.Add(meterCost is not null ? row.WithCosts(meterCost.Buckets, meterCost.Total, meterCost.Currency) : row);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<AnalysisProblem> problems = [.. result.Problems];
|
||||||
|
if (meterCost is not null)
|
||||||
|
{
|
||||||
|
problems.AddRange(meterCost.QuantityProblems);
|
||||||
|
}
|
||||||
|
|
||||||
|
return view with
|
||||||
|
{
|
||||||
|
Plan = result.Plan,
|
||||||
|
Quantities = result,
|
||||||
|
Series = series,
|
||||||
|
SeriesNames = names,
|
||||||
|
MeterCost = meterCost,
|
||||||
|
MeterCostChange = meterCostChange,
|
||||||
|
Chart = chart,
|
||||||
|
Table = table,
|
||||||
|
Pairs = result.Comparison is { IsApplicable: true } comparison ? comparison.Buckets : null,
|
||||||
|
Comparison = result.Comparison?.Resolution,
|
||||||
|
Matched = series.Count == 1 ? series[0].Comparison?.Matched : null,
|
||||||
|
Resolution = Coarsest(series.Select(s => s.Resolution)),
|
||||||
|
Availability = availability,
|
||||||
|
Problems = problems,
|
||||||
|
CostAttention = meterCost?.Attention ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<AnalysisPageView> CostAsync(
|
||||||
|
AnalysisQuery read, AnalysisSelection selection, AnalysisPageOptions options, ResolvedPeriod period, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var view = new AnalysisPageView(read, selection, period, AnalysisPageViewKind.Cost);
|
||||||
|
|
||||||
|
// A comparison prices only the meters that can have a cost (D-34); the others are named on the page.
|
||||||
|
var requests = read.ToCostRequests(period)
|
||||||
|
.Where(r => r.Scope.Kind != CostScopeKind.Meter || selection.Scope.Kind == QueryScopeKind.Meter || selection.SeriesMeterIds.Contains(r.Scope.Id!.Value))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
BucketPlan? plan = null;
|
||||||
|
ComparisonResolution? resolution = null;
|
||||||
|
IReadOnlyList<BucketPair>? pairs = null;
|
||||||
|
var series = new List<AnalysisCostSeries>(requests.Count);
|
||||||
|
foreach (var request in requests)
|
||||||
|
{
|
||||||
|
var current = await costs.ReadAsync(plan is null ? request : request with { Plan = plan }, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (current.Refusal == CostRefusal.TooManyPoints)
|
||||||
|
{
|
||||||
|
return view with { Plan = current.Plan, ReaderRefusal = AnalysisRefusal.TooManyPoints };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.Refusal == CostRefusal.UnknownScope)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
plan ??= current.Plan;
|
||||||
|
|
||||||
|
CostAnalysis? previous = null;
|
||||||
|
IReadOnlyList<BucketPair> seriesPairs = [];
|
||||||
|
if (read.Comparison.Kind != ComparisonKind.None)
|
||||||
|
{
|
||||||
|
var comparison = read.ToCostComparison(request with { Plan = current.Plan }, current.Plan);
|
||||||
|
resolution ??= comparison.Resolution;
|
||||||
|
if (comparison.Request is { } comparisonRequest)
|
||||||
|
{
|
||||||
|
pairs ??= comparison.Pairs;
|
||||||
|
seriesPairs = comparison.Pairs;
|
||||||
|
previous = await costs.ReadAsync(comparisonRequest, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (key, name) = CostSeriesName(request.Scope, options);
|
||||||
|
series.Add(new AnalysisCostSeries(key, name, current, previous) { Pairs = seriesPairs });
|
||||||
|
}
|
||||||
|
|
||||||
|
plan ??= BucketPlanner.Plan(period, read.Bucket == BucketSize.Auto ? BucketSize.Month : read.Bucket);
|
||||||
|
|
||||||
|
var overlays = series.Count <= MaxOverlaidSeries;
|
||||||
|
List<AnalysisChartSeries> chart = [];
|
||||||
|
List<AnalysisTableSeries> table = [];
|
||||||
|
foreach (var item in series)
|
||||||
|
{
|
||||||
|
var currency = item.Current.Currency;
|
||||||
|
chart.Add(AnalysisChartSeries.ForCost(item.Key, item.Name, currency, item.Current.Buckets));
|
||||||
|
if (overlays && item.Comparison is { } previous)
|
||||||
|
{
|
||||||
|
chart.Add(AnalysisChartSeries.ComparisonForCost(
|
||||||
|
item.Key, AnalysisChartSeries.ComparisonName(item.Name, read.Comparison), currency, previous.Buckets));
|
||||||
|
}
|
||||||
|
|
||||||
|
var row = AnalysisTableSeries.ForCosts(item.Key, item.Name, currency, item.Current.Buckets, item.Current.Total);
|
||||||
|
table.Add(item.Comparison is { } compared
|
||||||
|
? row.WithComparisonCosts(compared.Buckets, compared.Total, currency) with { TotalChange = CostChanges.ForTotalRow(item.CostChange) }
|
||||||
|
: row);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The priced meters' own data decides how fine a bucket can be opened (D-51): a monthly import has no days. A line
|
||||||
|
// without any tariff adds nothing to the figure, so its data does not hold the drill-down back.
|
||||||
|
var priced = series
|
||||||
|
.SelectMany(s => s.Current.Lines
|
||||||
|
.Where(l => l.Total.Status != Core.Analysis.Costing.CostStatus.NotPriced)
|
||||||
|
.Select(l => l.MeterId)
|
||||||
|
.Concat(s.Current.Meter is { } m ? [m.MeterId] : []))
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
AnalysisResult? quantities = null;
|
||||||
|
if (priced.Count > 0)
|
||||||
|
{
|
||||||
|
quantities = await reader.ReadAsync(
|
||||||
|
new AnalysisRequest(AnalysisScope.ForMeters(priced), period) { Plan = plan, MaxSeries = int.MaxValue }, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return view with
|
||||||
|
{
|
||||||
|
Plan = plan,
|
||||||
|
Quantities = quantities,
|
||||||
|
Costs = series,
|
||||||
|
Chart = chart,
|
||||||
|
Table = table,
|
||||||
|
Pairs = pairs,
|
||||||
|
Comparison = resolution ?? (read.Comparison.Kind == ComparisonKind.None ? null : ComparisonResolver.Resolve(period, read.Comparison)),
|
||||||
|
Resolution = quantities is null ? null : Coarsest(quantities.Series.Select(s => s.Resolution)),
|
||||||
|
Availability = AvailableRange.Union(series.Select(s => s.Current.Availability.Range), costs.Zone),
|
||||||
|
Problems = [.. series.SelectMany(s => s.Current.QuantityProblems)],
|
||||||
|
CostAttention = [.. series.SelectMany(s => s.Current.Attention)],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The coarsest of the given resolutions; null when none is known.</summary>
|
||||||
|
private static ResolutionClass? Coarsest(IEnumerable<ResolutionClass?> resolutions)
|
||||||
|
{
|
||||||
|
ResolutionClass? coarsest = null;
|
||||||
|
foreach (var resolution in resolutions)
|
||||||
|
{
|
||||||
|
if (resolution is { } value && (coarsest is null || value > coarsest))
|
||||||
|
{
|
||||||
|
coarsest = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return coarsest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A measure's name: its wording ("Total use"), prefixed with its energy type on the portfolio ("Strom · Total use"),
|
||||||
|
/// and with its unit when the same measure appears in two units.
|
||||||
|
/// </summary>
|
||||||
|
private static string MeasureName(AnalysisSeries series, IReadOnlyList<AnalysisSeries> all, bool withType, AnalysisPageOptions options)
|
||||||
|
{
|
||||||
|
var name = AnalysisChartSeries.NameOf(series);
|
||||||
|
if (withType && series.EnergyTypeId is { } typeId)
|
||||||
|
{
|
||||||
|
name = options.TypeName(typeId) + " · " + name;
|
||||||
|
}
|
||||||
|
|
||||||
|
var twins = all.Count(s => s.EnergyTypeId == series.EnergyTypeId && s.Key.Measure == series.Key.Measure);
|
||||||
|
return twins > 1 ? name + " (" + series.Unit + ")" : name;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (string Key, string Name) CostSeriesName(CostScope scope, AnalysisPageOptions options) => scope.Kind switch
|
||||||
|
{
|
||||||
|
CostScopeKind.EnergyType => ("cost:" + scope, options.TypeName(scope.Id!.Value)),
|
||||||
|
CostScopeKind.Meter => ("cost:" + scope, options.MeterName(scope.Id!.Value)),
|
||||||
|
CostScopeKind.Category => ("cost:" + scope, options.Category(scope.Id)?.Name ?? scope.ToString()),
|
||||||
|
_ => ("cost:portfolio", Strings.Analysis_TotalCost),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
using MeterVault.App.Analysis;
|
||||||
|
using MeterVault.Core.Analysis;
|
||||||
|
using MeterVault.Core.Analysis.Quantities;
|
||||||
|
using MeterVault.Core.Analysis.Totals;
|
||||||
|
using MeterVault.Core.Analysis.Virtual;
|
||||||
|
using MeterVault.Core.Domain;
|
||||||
|
using MeterVault.Infrastructure.Analysis;
|
||||||
|
using MeterVault.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace MeterVault.App.AnalysisPage;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One meter the Analysis page can pick (brief §7.4): its name (user data, never translated), its energy type, whether it
|
||||||
|
/// is calculated, what it measures in which normalized unit (D-20), and whether it can have a cost of its own (D-34,
|
||||||
|
/// D-39).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Id">The meter.</param>
|
||||||
|
/// <param name="Name">Its name.</param>
|
||||||
|
/// <param name="EnergyTypeId">Its energy type.</param>
|
||||||
|
/// <param name="IsVirtual">A calculated (virtual) meter.</param>
|
||||||
|
/// <param name="Kind">What its values measure.</param>
|
||||||
|
/// <param name="Unit">The normalized unit of its values.</param>
|
||||||
|
/// <param name="IsCostable">
|
||||||
|
/// True when a cost can be formed for it: a physical consumption or export meter (a bill line or a view at its price), or
|
||||||
|
/// a virtual meter whose cost rule is not <c>none</c>. Generation and runtime are never billed.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="IsRetired">Retired: it keeps its history (D-24), and the pickers say so.</param>
|
||||||
|
public sealed record AnalysisPageMeter(
|
||||||
|
int Id, string Name, int EnergyTypeId, bool IsVirtual, QuantityKind Kind, string Unit, bool IsCostable, bool IsRetired = false)
|
||||||
|
{
|
||||||
|
/// <summary>The metric the meter's own series is charted under; null for an indicator, which has none.</summary>
|
||||||
|
public AnalysisMetric? Metric => AnalysisMetrics.MetricOf(Kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>An energy type and the quantity metrics its totals report (D-22: a measure per metric).</summary>
|
||||||
|
/// <param name="Id">The energy type.</param>
|
||||||
|
/// <param name="Name">Its display name (user data).</param>
|
||||||
|
/// <param name="QuantityMetrics">The quantity metrics its measures cover, in <see cref="AnalysisPageOptions.QuantityOrder"/>.</param>
|
||||||
|
public sealed record AnalysisPageType(int Id, string Name, IReadOnlyList<AnalysisMetric> QuantityMetrics);
|
||||||
|
|
||||||
|
/// <summary>A cost category and its meters (meter members plus the meters of its energy-type members, D-42).</summary>
|
||||||
|
/// <param name="Id">The category.</param>
|
||||||
|
/// <param name="Name">Its name (user data).</param>
|
||||||
|
/// <param name="MeterIds">Its meters, ascending, each once.</param>
|
||||||
|
public sealed record AnalysisPageCategory(int Id, string Name, IReadOnlyList<int> MeterIds);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What the Analysis page can offer: the energy types with the metrics they support, the cost categories with their
|
||||||
|
/// meters, and every meter with its quantity — the input of <see cref="AnalysisSelection.Resolve"/>, loaded once per page
|
||||||
|
/// from the same catalog the reader classifies with, so the choices never promise a measure the reader does not have.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AnalysisPageOptions
|
||||||
|
{
|
||||||
|
private readonly Dictionary<int, AnalysisPageType> _types;
|
||||||
|
private readonly Dictionary<int, AnalysisPageCategory> _categories;
|
||||||
|
private readonly Dictionary<int, AnalysisPageMeter> _meters;
|
||||||
|
|
||||||
|
public AnalysisPageOptions(
|
||||||
|
IEnumerable<AnalysisPageType> types, IEnumerable<AnalysisPageCategory> categories, IEnumerable<AnalysisPageMeter> meters)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(types);
|
||||||
|
ArgumentNullException.ThrowIfNull(categories);
|
||||||
|
ArgumentNullException.ThrowIfNull(meters);
|
||||||
|
|
||||||
|
Types = [.. types];
|
||||||
|
Categories = [.. categories];
|
||||||
|
|
||||||
|
// Grouped by energy type in the types' order, then by name: the order of the pickers.
|
||||||
|
var typeOrder = Types.Select((t, i) => (t.Id, i)).ToDictionary(p => p.Id, p => p.i);
|
||||||
|
Meters =
|
||||||
|
[
|
||||||
|
.. meters
|
||||||
|
.OrderBy(m => typeOrder.GetValueOrDefault(m.EnergyTypeId, int.MaxValue))
|
||||||
|
.ThenBy(m => m.Name, StringComparer.CurrentCultureIgnoreCase)
|
||||||
|
.ThenBy(m => m.Id),
|
||||||
|
];
|
||||||
|
|
||||||
|
_types = Types.ToDictionary(t => t.Id);
|
||||||
|
_categories = Categories.ToDictionary(c => c.Id);
|
||||||
|
_meters = Meters.ToDictionary(m => m.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The quantity metrics in the order every list offers them.</summary>
|
||||||
|
public static IReadOnlyList<AnalysisMetric> QuantityOrder { get; } =
|
||||||
|
[AnalysisMetric.Consumption, AnalysisMetric.Generation, AnalysisMetric.Export, AnalysisMetric.Runtime, AnalysisMetric.Net];
|
||||||
|
|
||||||
|
public static AnalysisPageOptions Empty { get; } = new([], [], []);
|
||||||
|
|
||||||
|
/// <summary>The energy types, in their stored order.</summary>
|
||||||
|
public IReadOnlyList<AnalysisPageType> Types { get; }
|
||||||
|
|
||||||
|
/// <summary>The cost categories, in their sort order.</summary>
|
||||||
|
public IReadOnlyList<AnalysisPageCategory> Categories { get; }
|
||||||
|
|
||||||
|
/// <summary>Every meter, grouped by energy type, then by name.</summary>
|
||||||
|
public IReadOnlyList<AnalysisPageMeter> Meters { get; }
|
||||||
|
|
||||||
|
public AnalysisPageType? Type(int? id) => id is { } key ? _types.GetValueOrDefault(key) : null;
|
||||||
|
|
||||||
|
public AnalysisPageCategory? Category(int? id) => id is { } key ? _categories.GetValueOrDefault(key) : null;
|
||||||
|
|
||||||
|
public AnalysisPageMeter? Meter(int? id) => id is { } key ? _meters.GetValueOrDefault(key) : null;
|
||||||
|
|
||||||
|
/// <summary>The energy type's name; "#id" when it is unknown.</summary>
|
||||||
|
public string TypeName(int id) => Type(id)?.Name ?? "#" + id.ToString(System.Globalization.CultureInfo.CurrentCulture);
|
||||||
|
|
||||||
|
/// <summary>The meter's name; "#id" when it is unknown.</summary>
|
||||||
|
public string MeterName(int id) => Meter(id)?.Name ?? "#" + id.ToString(System.Globalization.CultureInfo.CurrentCulture);
|
||||||
|
|
||||||
|
/// <summary>Meter names by id, for attention items and contributions.</summary>
|
||||||
|
public IReadOnlyDictionary<int, string> MeterNames => _meters.ToDictionary(p => p.Key, p => p.Value.Name);
|
||||||
|
|
||||||
|
/// <summary>Energy type names by id.</summary>
|
||||||
|
public IReadOnlyDictionary<int, string> TypeNames => _types.ToDictionary(p => p.Key, p => p.Value.Name);
|
||||||
|
|
||||||
|
/// <summary>The quantity metric of a per-type measure (D-22): household use and grid import are both consumption.</summary>
|
||||||
|
public static AnalysisMetric MetricOf(TotalsMeasure measure) => measure switch
|
||||||
|
{
|
||||||
|
TotalsMeasure.Use or TotalsMeasure.GridImport => AnalysisMetric.Consumption,
|
||||||
|
TotalsMeasure.Generation => AnalysisMetric.Generation,
|
||||||
|
TotalsMeasure.Export => AnalysisMetric.Export,
|
||||||
|
_ => AnalysisMetric.Runtime,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The options from loaded rows: the reader's catalog (normalized quantities, effective virtual definitions, the
|
||||||
|
/// totals classification), the energy types and the categories with their members.
|
||||||
|
/// </summary>
|
||||||
|
public static AnalysisPageOptions Build(AnalysisCatalog catalog, IEnumerable<EnergyType> types, IEnumerable<CostCategory> categories)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(catalog);
|
||||||
|
ArgumentNullException.ThrowIfNull(types);
|
||||||
|
ArgumentNullException.ThrowIfNull(categories);
|
||||||
|
|
||||||
|
var typeList = types.OrderBy(t => t.Id).ToList();
|
||||||
|
var pageTypes = typeList.Select(t =>
|
||||||
|
{
|
||||||
|
var measures = catalog.Totals.ForType(t.Id).Measures.Where(g => g.MeterIds.Count > 0).Select(g => MetricOf(g.Measure)).ToHashSet();
|
||||||
|
return new AnalysisPageType(t.Id, t.DisplayName, [.. QuantityOrder.Where(measures.Contains)]);
|
||||||
|
});
|
||||||
|
|
||||||
|
var meters = catalog.Meters.Values.Select(m => new AnalysisPageMeter(
|
||||||
|
m.Id,
|
||||||
|
m.Name,
|
||||||
|
m.EnergyTypeId,
|
||||||
|
m.IsVirtual,
|
||||||
|
m.Quantity.Kind,
|
||||||
|
m.Quantity.Unit,
|
||||||
|
IsCostable(m),
|
||||||
|
m.Meter.RetiredAt is not null));
|
||||||
|
|
||||||
|
var metersByType = catalog.Meters.Values.GroupBy(m => m.EnergyTypeId).ToDictionary(g => g.Key, g => g.Select(m => m.Id).ToList());
|
||||||
|
var pageCategories = categories
|
||||||
|
.OrderBy(c => c.Sort)
|
||||||
|
.ThenBy(c => c.Name, StringComparer.CurrentCultureIgnoreCase)
|
||||||
|
.Select(c => new AnalysisPageCategory(
|
||||||
|
c.Id,
|
||||||
|
c.Name,
|
||||||
|
[
|
||||||
|
.. c.Members
|
||||||
|
.SelectMany(member => member.MeterId is { } meterId
|
||||||
|
? [meterId]
|
||||||
|
: member.EnergyTypeId is { } typeId ? metersByType.GetValueOrDefault(typeId) ?? [] : (IEnumerable<int>)[])
|
||||||
|
.Where(catalog.Meters.ContainsKey)
|
||||||
|
.Distinct()
|
||||||
|
.Order(),
|
||||||
|
]));
|
||||||
|
|
||||||
|
return new AnalysisPageOptions(pageTypes, pageCategories, meters);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Loads the options: the reader's catalog, the energy types and the categories with their members.</summary>
|
||||||
|
public static async Task<AnalysisPageOptions> LoadAsync(
|
||||||
|
IDbContextFactory<MeterVaultDbContext> contextFactory, AnalysisReader reader, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(contextFactory);
|
||||||
|
ArgumentNullException.ThrowIfNull(reader);
|
||||||
|
|
||||||
|
var catalog = await reader.LoadCatalogAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
await using var db = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
var types = await db.EnergyTypes.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
var categories = await db.CostCategories.AsNoTracking().Include(c => c.Members).ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
return Build(catalog, types, categories);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether a meter can have a cost of its own: physical consumption and export (D-34), a virtual meter with a cost rule
|
||||||
|
/// (D-39) — never generation or runtime, which are not billed, and never an indicator.
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsCostable(AnalysisMeter meter) => meter.IsVirtual
|
||||||
|
? meter.CostRule != VirtualCostRule.None && meter.Quantity.Kind is QuantityKind.Consumption or QuantityKind.Net
|
||||||
|
: meter.Quantity.Kind is QuantityKind.Consumption or QuantityKind.Export;
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
using MeterVault.App.Analysis;
|
||||||
|
using MeterVault.Core.Analysis;
|
||||||
|
using MeterVault.Core.Analysis.Coverage;
|
||||||
|
using MeterVault.Infrastructure.Analysis;
|
||||||
|
using MeterVault.Infrastructure.Costing;
|
||||||
|
using MeterVault.Infrastructure.Dashboard;
|
||||||
|
|
||||||
|
namespace MeterVault.App.AnalysisPage;
|
||||||
|
|
||||||
|
/// <summary>What the Analysis page charts: quantities from the analysis reader, or costs from the cost reader.</summary>
|
||||||
|
public enum AnalysisPageViewKind
|
||||||
|
{
|
||||||
|
Quantity,
|
||||||
|
Cost,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One cost series of the page: a cost scope (the portfolio, a type, a category or a meter), its figure priced by the cost
|
||||||
|
/// reader, and — when a comparison applies — the same scope priced in the paired buckets (D-06, A-10).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Key">A stable key for the chart and table.</param>
|
||||||
|
/// <param name="Name">The series name (user data for a type, category or meter).</param>
|
||||||
|
/// <param name="Current">The priced period.</param>
|
||||||
|
/// <param name="Comparison">The comparison priced in the images of the current buckets; null when none applies.</param>
|
||||||
|
public sealed record AnalysisCostSeries(string Key, string Name, CostAnalysis Current, CostAnalysis? Comparison)
|
||||||
|
{
|
||||||
|
/// <summary>The comparison's buckets paired with the current ones (A-10); empty without a comparison.</summary>
|
||||||
|
public IReadOnlyList<BucketPair> Pairs { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The change of the period's cost against the comparison, by the rule every page states it with (D-07,
|
||||||
|
/// <see cref="OverviewComparison.Between"/>): the totals when both are complete, else the paired buckets complete on
|
||||||
|
/// both sides, else not comparable.
|
||||||
|
/// </summary>
|
||||||
|
public CostChange CostChange => Comparison is { } previous && Pairs.Count > 0
|
||||||
|
? OverviewComparison.Between(Current.Buckets, Current.Total, previous.Buckets, previous.Total, Pairs)
|
||||||
|
: CostChange.NoComparison;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Everything the Analysis page shows for one address, committed at once (brief §8): the selection, the resolved period
|
||||||
|
/// and buckets, the series with their chart and table inputs (formatted in the request's culture), the comparison, the
|
||||||
|
/// availability behind the empty states, and the attention items. Nothing here is computed by the page itself — the
|
||||||
|
/// quantities are the analysis reader's and the costs the cost reader's, so the page agrees with every other view.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record AnalysisPageView(AnalysisQuery Query, AnalysisSelection Selection, ResolvedPeriod Period, AnalysisPageViewKind Kind)
|
||||||
|
{
|
||||||
|
/// <summary>The buckets; null when nothing was read (a refused selection).</summary>
|
||||||
|
public BucketPlan? Plan { get; init; }
|
||||||
|
|
||||||
|
/// <summary>The quantity result (the quantity view), or the priced meters' quantities read for their resolution (the cost view).</summary>
|
||||||
|
public AnalysisResult? Quantities { get; init; }
|
||||||
|
|
||||||
|
/// <summary>The quantity series shown, in the order charted.</summary>
|
||||||
|
public IReadOnlyList<AnalysisSeries> Series { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>Display names of <see cref="Series"/>, by series key.</summary>
|
||||||
|
public IReadOnlyDictionary<string, string> SeriesNames { get; init; } = new Dictionary<string, string>();
|
||||||
|
|
||||||
|
/// <summary>The cost series shown (the cost view).</summary>
|
||||||
|
public IReadOnlyList<AnalysisCostSeries> Costs { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>For one meter's quantity: its cost by its rule, in the same buckets (null when it is not costed).</summary>
|
||||||
|
public CostAnalysis? MeterCost { get; init; }
|
||||||
|
|
||||||
|
/// <summary>The change of <see cref="MeterCost"/> against the comparison, by the rule every page uses (D-07).</summary>
|
||||||
|
public CostChange MeterCostChange { get; init; } = CostChange.NoComparison;
|
||||||
|
|
||||||
|
public IReadOnlyList<AnalysisChartSeries> Chart { get; init; } = [];
|
||||||
|
|
||||||
|
public IReadOnlyList<AnalysisTableSeries> Table { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>The comparison buckets paired with the plan's (A-10); null without a comparison.</summary>
|
||||||
|
public IReadOnlyList<BucketPair>? Pairs { get; init; }
|
||||||
|
|
||||||
|
/// <summary>How the comparison was resolved, or why there is none.</summary>
|
||||||
|
public ComparisonResolution? Comparison { get; init; }
|
||||||
|
|
||||||
|
/// <summary>The coverage both periods share, for a single quantity series (D-07).</summary>
|
||||||
|
public MatchedCoverageResult? Matched { get; init; }
|
||||||
|
|
||||||
|
/// <summary>The coarsest resolution among the charted data: how far a bucket can be drilled into (D-51).</summary>
|
||||||
|
public ResolutionClass? Resolution { get; init; }
|
||||||
|
|
||||||
|
/// <summary>What the view's scope has data for (D-19), for "No data for this period" and "Go to latest data".</summary>
|
||||||
|
public AvailableRange? Availability { get; init; }
|
||||||
|
|
||||||
|
/// <summary>The readers refused the request before reading (too many meters or buckets).</summary>
|
||||||
|
public AnalysisRefusal ReaderRefusal { get; init; }
|
||||||
|
|
||||||
|
public IReadOnlyList<AnalysisProblem> Problems { get; init; } = [];
|
||||||
|
|
||||||
|
public IReadOnlyList<CostAttention> CostAttention { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>The whole range lies after now (D-04).</summary>
|
||||||
|
public bool NotYetOccurred => Period.HasNotStarted();
|
||||||
|
|
||||||
|
/// <summary>True when nothing was shown for the address: the selection or the readers refused it (an explanation instead).</summary>
|
||||||
|
public bool IsRefused => Selection.Refusal != AnalysisPageRefusal.None || ReaderRefusal != AnalysisRefusal.None;
|
||||||
|
|
||||||
|
/// <summary>Every series is being rebuilt (D-16): "analysis being prepared", never "no data".</summary>
|
||||||
|
public bool IsPending => Kind == AnalysisPageViewKind.Quantity
|
||||||
|
? Series.Count > 0 && Series.All(s => s.IsPending)
|
||||||
|
: Costs.Count > 0 && Costs.All(c => c.Current.Total.Availability == BucketStatus.Pending);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Nothing is known in the period: no series, every quantity total missing, or no cost figure with a value, nothing that
|
||||||
|
/// needs a price and no quantity that is only unresolved. A known zero is data; a missing price is not "no data" (the
|
||||||
|
/// cost cards say "not priced"), nor is a quantity the buckets cannot resolve (the cards name its state), nor a
|
||||||
|
/// category whose members price nothing — the attention list says why (A-22), and "nothing recorded yet" would be false.
|
||||||
|
/// </summary>
|
||||||
|
public bool HasNoData => Kind == AnalysisPageViewKind.Quantity
|
||||||
|
? Series.Count == 0 || Series.All(s => s.Total.Status == BucketStatus.Missing)
|
||||||
|
: Costs.Count == 0
|
||||||
|
|| (Costs.All(c => c.Current.Total is { Cost: null, Status: Core.Analysis.Costing.CostStatus.Priced, Availability: BucketStatus.Missing or BucketStatus.Available })
|
||||||
|
&& !CostAttention.Any(a => a.Kind == CostAttentionKind.CategoryPricesNothing));
|
||||||
|
|
||||||
|
/// <summary>The name of a quantity series as the page shows it.</summary>
|
||||||
|
public string NameOf(AnalysisSeries series)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(series);
|
||||||
|
|
||||||
|
return SeriesNames.TryGetValue(series.Key.Id, out var name) ? name : AnalysisChartSeries.NameOf(series);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
using MeterVault.App.Analysis;
|
||||||
|
using MeterVault.Core.Analysis;
|
||||||
|
using MeterVault.Core.Analysis.Quantities;
|
||||||
|
using MeterVault.Infrastructure.Analysis;
|
||||||
|
|
||||||
|
namespace MeterVault.App.AnalysisPage;
|
||||||
|
|
||||||
|
/// <summary>Why the Analysis page cannot show what its address asks for, and explains instead (brief §7.4).</summary>
|
||||||
|
public enum AnalysisPageRefusal
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
|
||||||
|
/// <summary>The energy type, category or meter does not exist (any more).</summary>
|
||||||
|
UnknownScope,
|
||||||
|
|
||||||
|
/// <summary>More meters than can be compared side by side (<see cref="AnalysisLimits.MaxSeries"/>); never cut silently.</summary>
|
||||||
|
TooManyMeters,
|
||||||
|
|
||||||
|
/// <summary>A category asked for a quantity while its meters measure different kinds or units.</summary>
|
||||||
|
CategoryMixed,
|
||||||
|
|
||||||
|
/// <summary>A category asked for a quantity has no meters (its costs are manual costs only).</summary>
|
||||||
|
CategoryWithoutMeters,
|
||||||
|
|
||||||
|
/// <summary>A category asked for a quantity has more meters than can be charted side by side.</summary>
|
||||||
|
CategoryTooManyMeters,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>What the page did differently from the address, and says so.</summary>
|
||||||
|
public enum AnalysisPageNoticeKind
|
||||||
|
{
|
||||||
|
/// <summary>The metric does not apply to the selection; its natural metric is shown instead.</summary>
|
||||||
|
MetricNotAvailable,
|
||||||
|
|
||||||
|
/// <summary>Some selected meters do not exist and were left out.</summary>
|
||||||
|
UnknownMetersLeftOut,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A page notice with the metric it is about (for <see cref="AnalysisPageNoticeKind.MetricNotAvailable"/>).</summary>
|
||||||
|
public sealed record AnalysisPageNotice(AnalysisPageNoticeKind Kind, AnalysisMetric? Requested = null, AnalysisMetric? Shown = null);
|
||||||
|
|
||||||
|
/// <summary>Meters of one kind and unit, e.g. the consumption meters of a mixed category, in kWh.</summary>
|
||||||
|
public sealed record AnalysisMeterGroup(QuantityKind Kind, string Unit, IReadOnlyList<int> MeterIds)
|
||||||
|
{
|
||||||
|
public AnalysisMetric? Metric => AnalysisMetrics.MetricOf(Kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The Analysis page's reading of its address against what exists (brief §7.4, D-47): the scope and its name, the metrics
|
||||||
|
/// the scope supports and the one shown, the meters shown as series, and — when the address asks for something that
|
||||||
|
/// cannot be shown as one quantity — the reason in <see cref="Refusal"/>. Pure: the page, the CSV link and the tests read
|
||||||
|
/// the same answer.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Metrics.</b> The portfolio and an energy type offer the quantity metrics of their per-type measures (D-22) and the
|
||||||
|
/// cost; a meter its own quantity and — when it can be costed — its cost; a comparison the metrics of its meters and the
|
||||||
|
/// cost. A cost category is analysed by cost, and by a quantity only when all its meters measure one kind in one unit.
|
||||||
|
/// A metric the scope does not support falls back to the scope's natural one with a notice (D-02), except a category
|
||||||
|
/// quantity, which is explained, never silently turned into a cost.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Series.</b> A comparison shows the meters that measure the chosen metric (a meter measures what it measures) and
|
||||||
|
/// names the others; its cost shows the meters that can have one. A category's quantity is its meters side by side —
|
||||||
|
/// each from the shared reader, never added up, because members may overlap (D-22).
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed record AnalysisSelection
|
||||||
|
{
|
||||||
|
private AnalysisSelection(QueryScope scope, AnalysisMetric? metric)
|
||||||
|
{
|
||||||
|
Scope = scope;
|
||||||
|
Metric = metric;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The scope shown (unknown meters of a comparison left out).</summary>
|
||||||
|
public QueryScope Scope { get; private init; }
|
||||||
|
|
||||||
|
/// <summary>The metric shown; null for a meter's own quantity when it has no metric (an indicator).</summary>
|
||||||
|
public AnalysisMetric? Metric { get; private init; }
|
||||||
|
|
||||||
|
/// <summary>The metrics the scope supports, in the order the selector lists them.</summary>
|
||||||
|
public IReadOnlyList<AnalysisMetric> Metrics { get; private init; } = [];
|
||||||
|
|
||||||
|
/// <summary>What the scope shows without a <c>metric</c> key.</summary>
|
||||||
|
public AnalysisMetric? NaturalMetric { get; private init; }
|
||||||
|
|
||||||
|
/// <summary>The meters shown as series (meter, comparison, category quantity); empty for measure and whole-scope cost views.</summary>
|
||||||
|
public IReadOnlyList<int> SeriesMeterIds { get; private init; } = [];
|
||||||
|
|
||||||
|
/// <summary>Selected meters not shown for this metric (another kind, or no cost of their own).</summary>
|
||||||
|
public IReadOnlyList<int> HiddenMeterIds { get; private init; } = [];
|
||||||
|
|
||||||
|
/// <summary>For <see cref="AnalysisPageRefusal.CategoryMixed"/>: the category's meters by kind and unit.</summary>
|
||||||
|
public IReadOnlyList<AnalysisMeterGroup> Groups { get; private init; } = [];
|
||||||
|
|
||||||
|
public AnalysisPageRefusal Refusal { get; private init; }
|
||||||
|
|
||||||
|
public IReadOnlyList<AnalysisPageNotice> Notices { get; private init; } = [];
|
||||||
|
|
||||||
|
/// <summary>The scope's name (a type, category or meter — user data); null for the portfolio and a comparison.</summary>
|
||||||
|
public string? ScopeName { get; private init; }
|
||||||
|
|
||||||
|
/// <summary>The energy type the scope belongs to (a type, or a meter's type).</summary>
|
||||||
|
public int? EnergyTypeId { get; private init; }
|
||||||
|
|
||||||
|
/// <summary>True when the metric shown is the cost.</summary>
|
||||||
|
public bool IsCost => Metric == AnalysisMetric.Cost;
|
||||||
|
|
||||||
|
/// <summary>True when the values shown are meters' own series (a meter, a comparison, a category's meters).</summary>
|
||||||
|
public bool ShowsMeters => !IsCost && Scope.Kind is QueryScopeKind.Meter or QueryScopeKind.Meters or QueryScopeKind.Category;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The query the readers are asked with: the metric shown written out, and for a view of meters their explicit
|
||||||
|
/// selection — so resolving <c>all</c> (D-19), reading and the CSV export all see the same scope.
|
||||||
|
/// </summary>
|
||||||
|
public AnalysisQuery ReadQuery(AnalysisQuery query)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(query);
|
||||||
|
|
||||||
|
var read = query.WithMetric(Metric);
|
||||||
|
return ShowsMeters && Scope.Kind != QueryScopeKind.Meter && SeriesMeterIds.Count > 0
|
||||||
|
? read.WithScope(QueryScope.ForMeters(SeriesMeterIds))
|
||||||
|
: read.WithScope(Scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <paramref name="query"/> as the page shows it: the scope shown and the metric shown, with no <c>metric</c> key
|
||||||
|
/// when it is the scope's natural one — the state the selectors and the drill-downs build on.
|
||||||
|
/// </summary>
|
||||||
|
public AnalysisQuery Shown(AnalysisQuery query)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(query);
|
||||||
|
|
||||||
|
return query.WithScope(Scope).WithMetric(Metric == NaturalMetric ? null : Metric);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reads <paramref name="query"/> against <paramref name="options"/>.</summary>
|
||||||
|
public static AnalysisSelection Resolve(AnalysisQuery query, AnalysisPageOptions options)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(query);
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
|
||||||
|
return query.Scope.Kind switch
|
||||||
|
{
|
||||||
|
QueryScopeKind.EnergyType => ForType(query, options),
|
||||||
|
QueryScopeKind.Category => ForCategory(query, options),
|
||||||
|
QueryScopeKind.Meter => ForMeter(query, options),
|
||||||
|
QueryScopeKind.Meters => ForMeters(query, options),
|
||||||
|
_ => ForPortfolio(query, options),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The metric a scope's natural choice is: consumption when offered, else the first quantity, else the cost.</summary>
|
||||||
|
private static AnalysisMetric NaturalOf(IReadOnlyList<AnalysisMetric> quantities) =>
|
||||||
|
quantities.Contains(AnalysisMetric.Consumption) ? AnalysisMetric.Consumption : quantities.Count > 0 ? quantities[0] : AnalysisMetric.Cost;
|
||||||
|
|
||||||
|
private static AnalysisSelection ForPortfolio(AnalysisQuery query, AnalysisPageOptions options)
|
||||||
|
{
|
||||||
|
var quantities = options.Types.SelectMany(t => t.QuantityMetrics).ToHashSet();
|
||||||
|
List<AnalysisMetric> metrics = [AnalysisMetric.Cost, .. AnalysisPageOptions.QuantityOrder.Where(quantities.Contains)];
|
||||||
|
return WithMetric(new AnalysisSelection(QueryScope.Portfolio, null) { Metrics = metrics, NaturalMetric = AnalysisMetric.Cost }, query.Metric);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AnalysisSelection ForType(AnalysisQuery query, AnalysisPageOptions options)
|
||||||
|
{
|
||||||
|
if (options.Type(query.Scope.Id) is not { } type)
|
||||||
|
{
|
||||||
|
return Refused(query.Scope, AnalysisPageRefusal.UnknownScope);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<AnalysisMetric> metrics = [.. type.QuantityMetrics, AnalysisMetric.Cost];
|
||||||
|
var selection = new AnalysisSelection(query.Scope, null)
|
||||||
|
{
|
||||||
|
Metrics = metrics,
|
||||||
|
NaturalMetric = NaturalOf(type.QuantityMetrics),
|
||||||
|
ScopeName = type.Name,
|
||||||
|
EnergyTypeId = type.Id,
|
||||||
|
};
|
||||||
|
return WithMetric(selection, query.Metric);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AnalysisSelection ForCategory(AnalysisQuery query, AnalysisPageOptions options)
|
||||||
|
{
|
||||||
|
if (options.Category(query.Scope.Id) is not { } category)
|
||||||
|
{
|
||||||
|
return Refused(query.Scope, AnalysisPageRefusal.UnknownScope);
|
||||||
|
}
|
||||||
|
|
||||||
|
var members = category.MeterIds.Select(id => options.Meter(id)).OfType<AnalysisPageMeter>().ToList();
|
||||||
|
var groups = GroupsOf(members);
|
||||||
|
|
||||||
|
// A quantity only when every meter measures one kind in one unit (brief §7.4).
|
||||||
|
var single = groups.Count == 1 && groups[0].Metric is { } only ? only : (AnalysisMetric?)null;
|
||||||
|
List<AnalysisMetric> metrics = single is { } metric ? [AnalysisMetric.Cost, metric] : [AnalysisMetric.Cost];
|
||||||
|
var selection = new AnalysisSelection(query.Scope, AnalysisMetric.Cost)
|
||||||
|
{
|
||||||
|
Metrics = metrics,
|
||||||
|
NaturalMetric = AnalysisMetric.Cost,
|
||||||
|
ScopeName = category.Name,
|
||||||
|
Groups = groups,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (query.Metric is not { } requested || requested == AnalysisMetric.Cost)
|
||||||
|
{
|
||||||
|
return selection;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!requested.IsQuantity() || (single is { } supported && supported != requested))
|
||||||
|
{
|
||||||
|
// A metric its meters do not measure (or the tank balance): the cost, with a notice.
|
||||||
|
return selection with { Notices = [new AnalysisPageNotice(AnalysisPageNoticeKind.MetricNotAvailable, requested, AnalysisMetric.Cost)] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// The quantity asked for cannot be one: explained, never silently shown as the cost.
|
||||||
|
var refusal = members.Count == 0 ? AnalysisPageRefusal.CategoryWithoutMeters
|
||||||
|
: single is null ? AnalysisPageRefusal.CategoryMixed
|
||||||
|
: members.Count > AnalysisLimits.MaxSeries ? AnalysisPageRefusal.CategoryTooManyMeters
|
||||||
|
: AnalysisPageRefusal.None;
|
||||||
|
|
||||||
|
return selection with
|
||||||
|
{
|
||||||
|
Metric = requested,
|
||||||
|
SeriesMeterIds = refusal == AnalysisPageRefusal.None ? [.. members.Select(m => m.Id)] : [],
|
||||||
|
Refusal = refusal,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AnalysisSelection ForMeter(AnalysisQuery query, AnalysisPageOptions options)
|
||||||
|
{
|
||||||
|
if (options.Meter(query.Scope.Id) is not { } meter)
|
||||||
|
{
|
||||||
|
return Refused(query.Scope, AnalysisPageRefusal.UnknownScope);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<AnalysisMetric> metrics = [];
|
||||||
|
if (meter.Metric is { } own)
|
||||||
|
{
|
||||||
|
metrics.Add(own);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meter.IsCostable)
|
||||||
|
{
|
||||||
|
metrics.Add(AnalysisMetric.Cost);
|
||||||
|
}
|
||||||
|
|
||||||
|
var selection = new AnalysisSelection(query.Scope, null)
|
||||||
|
{
|
||||||
|
Metrics = metrics,
|
||||||
|
NaturalMetric = meter.Metric,
|
||||||
|
SeriesMeterIds = [meter.Id],
|
||||||
|
ScopeName = meter.Name,
|
||||||
|
EnergyTypeId = meter.EnergyTypeId,
|
||||||
|
};
|
||||||
|
return WithMetric(selection, query.Metric);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AnalysisSelection ForMeters(AnalysisQuery query, AnalysisPageOptions options)
|
||||||
|
{
|
||||||
|
var requested = query.Scope.MeterIds;
|
||||||
|
if (requested.Count > AnalysisLimits.MaxSeries)
|
||||||
|
{
|
||||||
|
return Refused(query.Scope, AnalysisPageRefusal.TooManyMeters);
|
||||||
|
}
|
||||||
|
|
||||||
|
var meters = requested.Select(id => options.Meter(id)).OfType<AnalysisPageMeter>().ToList();
|
||||||
|
if (meters.Count == 0)
|
||||||
|
{
|
||||||
|
return Refused(query.Scope, AnalysisPageRefusal.UnknownScope);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<AnalysisPageNotice> notices = meters.Count < requested.Count ? [new AnalysisPageNotice(AnalysisPageNoticeKind.UnknownMetersLeftOut)] : [];
|
||||||
|
var scope = QueryScope.ForMeters(meters.Select(m => m.Id));
|
||||||
|
|
||||||
|
List<AnalysisMetric> metrics =
|
||||||
|
[
|
||||||
|
.. meters.Select(m => m.Metric).OfType<AnalysisMetric>().Distinct().OrderBy(IndexOf),
|
||||||
|
];
|
||||||
|
if (meters.Any(m => m.IsCostable))
|
||||||
|
{
|
||||||
|
metrics.Add(AnalysisMetric.Cost);
|
||||||
|
}
|
||||||
|
|
||||||
|
var selection = WithMetric(
|
||||||
|
new AnalysisSelection(scope, null) { Metrics = metrics, NaturalMetric = meters[0].Metric, Notices = notices },
|
||||||
|
query.Metric);
|
||||||
|
|
||||||
|
// A meter measures what it measures: the metric picks which of the selected meters are compared.
|
||||||
|
var shown = selection.IsCost
|
||||||
|
? meters.Where(m => m.IsCostable).ToList()
|
||||||
|
: meters.Where(m => m.Metric == selection.Metric).ToList();
|
||||||
|
return selection with
|
||||||
|
{
|
||||||
|
SeriesMeterIds = [.. shown.Select(m => m.Id)],
|
||||||
|
HiddenMeterIds = [.. meters.Except(shown).Select(m => m.Id)],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The metric shown for a requested one: the request when supported, else the natural metric with a notice.</summary>
|
||||||
|
private static AnalysisSelection WithMetric(AnalysisSelection selection, AnalysisMetric? requested)
|
||||||
|
{
|
||||||
|
if (requested is not { } metric || metric == selection.NaturalMetric)
|
||||||
|
{
|
||||||
|
return selection with { Metric = selection.NaturalMetric };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selection.Metrics.Contains(metric))
|
||||||
|
{
|
||||||
|
return selection with { Metric = metric };
|
||||||
|
}
|
||||||
|
|
||||||
|
return selection with
|
||||||
|
{
|
||||||
|
Metric = selection.NaturalMetric,
|
||||||
|
Notices = [.. selection.Notices, new AnalysisPageNotice(AnalysisPageNoticeKind.MetricNotAvailable, metric, selection.NaturalMetric)],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AnalysisSelection Refused(QueryScope scope, AnalysisPageRefusal refusal) =>
|
||||||
|
new(scope, null) { Refusal = refusal };
|
||||||
|
|
||||||
|
private static List<AnalysisMeterGroup> GroupsOf(IEnumerable<AnalysisPageMeter> meters) =>
|
||||||
|
[
|
||||||
|
.. meters
|
||||||
|
.GroupBy(m => (m.Kind, Unit: Units.Normalize(m.Unit)))
|
||||||
|
.Select(g => new AnalysisMeterGroup(g.Key.Kind, g.Key.Unit, [.. g.Select(m => m.Id)])),
|
||||||
|
];
|
||||||
|
|
||||||
|
private static int IndexOf(AnalysisMetric metric)
|
||||||
|
{
|
||||||
|
var index = AnalysisPageOptions.QuantityOrder.ToList().IndexOf(metric);
|
||||||
|
return index < 0 ? int.MaxValue : index;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
|
using MeterVault.Core.Analysis.Costing;
|
||||||
|
using MeterVault.Core.Analysis.Quantities;
|
||||||
using MeterVault.Core.Domain;
|
using MeterVault.Core.Domain;
|
||||||
using MeterVault.Infrastructure.Costing;
|
using MeterVault.Infrastructure.Costing;
|
||||||
using MeterVault.Infrastructure.Dashboard;
|
using MeterVault.Infrastructure.Dashboard;
|
||||||
using MeterVault.Infrastructure.Ingestion;
|
using MeterVault.Infrastructure.Ingestion;
|
||||||
using MeterVault.Infrastructure.Normalization;
|
using MeterVault.Infrastructure.Normalization;
|
||||||
using MeterVault.Infrastructure.Persistence;
|
using MeterVault.Infrastructure.Persistence;
|
||||||
|
using MeterVault.Infrastructure.Update;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace MeterVault.App.Api;
|
namespace MeterVault.App.Api;
|
||||||
@@ -19,11 +22,40 @@ public sealed record TariffPush(TariffScope ScopeType, int? ScopeId, TariffCompo
|
|||||||
|
|
||||||
public sealed record IngestResult(int Written, int Updated, int Rejected, int Ignored);
|
public sealed record IngestResult(int Written, int Updated, int Rejected, int Ignored);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A price a cost figure needed and did not get (D-38), as <c>/api/v1/cost</c> reports it in <c>missingPrices</c>:
|
||||||
|
/// what to price, why it is missing, where the price belongs and from which month to which.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ApiMissingPrice(
|
||||||
|
TariffComponent Component,
|
||||||
|
CostStatus Reason,
|
||||||
|
TariffScope Scope,
|
||||||
|
int? ScopeId,
|
||||||
|
int? MeterId,
|
||||||
|
DateOnly FirstMonth,
|
||||||
|
DateOnly LastMonth,
|
||||||
|
int? TariffId,
|
||||||
|
TariffUnitIssue Issue,
|
||||||
|
bool IsCredit)
|
||||||
|
{
|
||||||
|
public static ApiMissingPrice Of(MissingPrice price)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(price);
|
||||||
|
|
||||||
|
return new ApiMissingPrice(
|
||||||
|
price.Component, price.Reason, price.Scope, price.ScopeId, price.MeterId, price.FirstMonth, price.LastMonth,
|
||||||
|
price.TariffId, price.Issue, price.IsCredit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Maps the versioned REST API. All endpoints require a valid API key (SDD §9).</summary>
|
/// <summary>Maps the versioned REST API. All endpoints require a valid API key (SDD §9).</summary>
|
||||||
public static class ApiEndpoints
|
public static class ApiEndpoints
|
||||||
{
|
{
|
||||||
private const int MaxReadingsPerRequest = 5000;
|
private const int MaxReadingsPerRequest = 5000;
|
||||||
|
|
||||||
|
/// <summary>Header confirming an update request was made on purpose rather than by a foreign page.</summary>
|
||||||
|
public const string UpdateRequestHeader = "X-MeterVault-Update";
|
||||||
|
|
||||||
public static IEndpointRouteBuilder MapMeterVaultApi(this IEndpointRouteBuilder app)
|
public static IEndpointRouteBuilder MapMeterVaultApi(this IEndpointRouteBuilder app)
|
||||||
{
|
{
|
||||||
var api = app.MapGroup("/api/v1").AddEndpointFilter<ApiKeyFilter>().WithTags("MeterVault");
|
var api = app.MapGroup("/api/v1").AddEndpointFilter<ApiKeyFilter>().WithTags("MeterVault");
|
||||||
@@ -36,20 +68,57 @@ public static class ApiEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
int written = 0, updated = 0, rejected = 0, ignored = 0;
|
int written = 0, updated = 0, rejected = 0, ignored = 0;
|
||||||
|
var touched = new HashSet<int>();
|
||||||
foreach (var r in readings)
|
foreach (var r in readings)
|
||||||
{
|
{
|
||||||
switch (await ingestion.IngestByMeterAsync(r.MeterId, r.Time, r.Value, ct))
|
// Normalize once per meter after the batch, not per reading: a recompute rewrites the
|
||||||
|
// meter's whole consumption series, so doing it inside the loop is quadratic.
|
||||||
|
switch (await ingestion.IngestByMeterAsync(r.MeterId, r.Time, r.Value, renormalize: false, cancellationToken: ct))
|
||||||
{
|
{
|
||||||
case IngestionOutcome.Written: written++; break;
|
case IngestionOutcome.Written: written++; touched.Add(r.MeterId); break;
|
||||||
case IngestionOutcome.Updated: updated++; break;
|
case IngestionOutcome.Updated: updated++; touched.Add(r.MeterId); break;
|
||||||
case IngestionOutcome.RejectedDecrease: rejected++; break;
|
case IngestionOutcome.RejectedDecrease: rejected++; break;
|
||||||
default: ignored++; break; // unknown meter
|
default: ignored++; break; // unknown meter
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach (var meterId in touched)
|
||||||
|
{
|
||||||
|
await ingestion.RenormalizeMeterAsync(meterId, ct);
|
||||||
|
}
|
||||||
|
|
||||||
return Results.Ok(new IngestResult(written, updated, rejected, ignored));
|
return Results.Ok(new IngestResult(written, updated, rejected, ignored));
|
||||||
}).WithSummary("Ingest one or more readings (idempotent). Lets Home Assistant push.");
|
}).WithSummary("Ingest one or more readings (idempotent). Lets Home Assistant push.");
|
||||||
|
|
||||||
|
api.MapPost("/system/update", async (HttpContext http, UpdateRunner runner, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
if (runner.Availability is not UpdateAvailability.Allowed)
|
||||||
|
{
|
||||||
|
return Results.Problem(
|
||||||
|
statusCode: StatusCodes.Status409Conflict,
|
||||||
|
title: runner.Availability switch
|
||||||
|
{
|
||||||
|
UpdateAvailability.NotEnabled => "In-app update is disabled. Set MeterVault__AllowInAppUpdate=true.",
|
||||||
|
_ => "This install has no in-place update mechanism (containers are replaced, not updated).",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not authentication — the operator opted out of that. This only stops a *different site*
|
||||||
|
// driving the endpoint through the browser of someone on this network: a plain HTML form
|
||||||
|
// cannot set a custom header, and a cross-origin fetch that tries is stopped by the
|
||||||
|
// preflight, which nothing here answers. Costs a deliberate caller one flag.
|
||||||
|
if (!http.Request.Headers.ContainsKey(UpdateRequestHeader))
|
||||||
|
{
|
||||||
|
return Results.Problem(statusCode: StatusCodes.Status400BadRequest,
|
||||||
|
title: $"Send the {UpdateRequestHeader} header to confirm this is a deliberate request.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var launch = await runner.LaunchAsync(ct);
|
||||||
|
return launch.Started
|
||||||
|
? Results.Accepted(value: new { message = launch.Message })
|
||||||
|
: Results.Problem(statusCode: StatusCodes.Status500InternalServerError, title: launch.Message);
|
||||||
|
}).WithSummary($"Start an in-place update (LXC only; requires MeterVault__AllowInAppUpdate and the {UpdateRequestHeader} header).");
|
||||||
|
|
||||||
api.MapGet("/meters", async (MeterVaultDbContext db, CancellationToken ct) =>
|
api.MapGet("/meters", async (MeterVaultDbContext db, CancellationToken ct) =>
|
||||||
Results.Ok(await db.Meters.AsNoTracking()
|
Results.Ok(await db.Meters.AsNoTracking()
|
||||||
.Select(m => new { m.Id, m.Name, m.EnergyTypeId, Mode = m.Mode.ToString(), m.Unit, m.IsActive })
|
.Select(m => new { m.Id, m.Name, m.EnergyTypeId, Mode = m.Mode.ToString(), m.Unit, m.IsActive })
|
||||||
@@ -60,19 +129,43 @@ public static class ApiEndpoints
|
|||||||
.Select(t => new { t.Id, t.Key, t.DisplayName, t.BaseUnit, Mode = t.DefaultMode.ToString() })
|
.Select(t => new { t.Id, t.Key, t.DisplayName, t.BaseUnit, Mode = t.DefaultMode.ToString() })
|
||||||
.ToListAsync(ct)));
|
.ToListAsync(ct)));
|
||||||
|
|
||||||
|
// /consumption, /cost and /dashboard/summary are contracts other systems read (D-45, ApiContractTests): every field
|
||||||
|
// keeps its name and type, and what the analysis rework adds arrives as new fields. The numbers follow the new
|
||||||
|
// engine — actuals stop at now, virtual meters are evaluated, costs are the bill's — which the release notes list.
|
||||||
api.MapGet("/consumption", async (int meter, DateTimeOffset from, DateTimeOffset to,
|
api.MapGet("/consumption", async (int meter, DateTimeOffset from, DateTimeOffset to,
|
||||||
CostService cost, CancellationToken ct) =>
|
CostService cost, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var buckets = await cost.GetMeterCostsAsync(meter, from, to, CostBucket.Month, ct);
|
// Npgsql accepts only UTC instants for timestamptz; a caller's local offset must not be a 500.
|
||||||
return Results.Ok(buckets.Select(b => new { b.Period, b.Consumption, b.Generation }));
|
var buckets = await cost.GetMeterCostsAsync(meter, from.ToUniversalTime(), to.ToUniversalTime(), CostBucket.Month, ct);
|
||||||
}).WithSummary("Normalized monthly consumption/generation for a meter.");
|
// Months that exist only for a cost (a standing charge through a reading gap) are no consumption rows.
|
||||||
|
return Results.Ok(buckets.Where(b => b.HasQuantity).Select(b => new { b.Period, b.Consumption, b.Generation, b.Status, b.Issue, b.Kind, b.Unit }));
|
||||||
|
}).WithSummary("Normalized monthly consumption/generation for a meter (virtual meters evaluated), with each month's status.");
|
||||||
|
|
||||||
api.MapGet("/cost", async (int meter, DateTimeOffset from, DateTimeOffset to,
|
api.MapGet("/cost", async (int meter, DateTimeOffset from, DateTimeOffset to,
|
||||||
CostService cost, CancellationToken ct) =>
|
CostService cost, CancellationToken ct) =>
|
||||||
Results.Ok(await cost.GetMeterCostsAsync(meter, from, to, CostBucket.Month, ct)));
|
{
|
||||||
|
var buckets = await cost.GetMeterCostsAsync(meter, from.ToUniversalTime(), to.ToUniversalTime(), CostBucket.Month, ct);
|
||||||
|
return Results.Ok(buckets.Select(b => new
|
||||||
|
{
|
||||||
|
b.Period,
|
||||||
|
b.Consumption,
|
||||||
|
b.Generation,
|
||||||
|
b.Cost,
|
||||||
|
b.CostStatus,
|
||||||
|
b.CostAvailability,
|
||||||
|
b.CostRule,
|
||||||
|
b.NotCosted,
|
||||||
|
MissingPrices = b.MissingPrices.Select(ApiMissingPrice.Of),
|
||||||
|
b.Status,
|
||||||
|
b.Issue,
|
||||||
|
b.Kind,
|
||||||
|
b.Unit,
|
||||||
|
}));
|
||||||
|
}).WithSummary("Monthly cost of a meter by its cost rule (costRule; notCosted says why a meter has none), with its price coverage (costStatus, missingPrices) and whether the cost is known (costAvailability).");
|
||||||
|
|
||||||
api.MapGet("/dashboard/summary", async (DashboardService dashboard, CancellationToken ct) =>
|
api.MapGet("/dashboard/summary", async (DashboardService dashboard, TimeProvider time, CancellationToken ct) =>
|
||||||
Results.Ok(await dashboard.GetSummaryAsync(DateOnly.FromDateTime(DateTime.UtcNow), ct)));
|
Results.Ok(await dashboard.GetSummaryAsync(time.GetUtcNow(), ct)))
|
||||||
|
.WithSummary("Overview KPIs: this calendar month and year to now against the whole previous ones (legacy windows), priced as the bill.");
|
||||||
|
|
||||||
api.MapPost("/events", async (EventPush push, MeterVaultDbContext db,
|
api.MapPost("/events", async (EventPush push, MeterVaultDbContext db,
|
||||||
NormalizationService normalization, CancellationToken ct) =>
|
NormalizationService normalization, CancellationToken ct) =>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ namespace MeterVault.App.Api;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Endpoint filter enforcing the <c>X-Api-Key</c> header against the configured keys (SDD §9).
|
/// Endpoint filter enforcing the <c>X-Api-Key</c> header against the configured keys (SDD §9).
|
||||||
/// When no keys are configured the API is open — intended only for local development.
|
/// With no keys configured the API is closed, unless <c>AllowAnonymousApi</c> opts into an open one.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ApiKeyFilter(IOptions<MeterVaultOptions> options) : IEndpointFilter
|
public sealed class ApiKeyFilter(IOptions<MeterVaultOptions> options) : IEndpointFilter
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using Microsoft.JSInterop;
|
||||||
|
|
||||||
|
namespace MeterVault.App;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// UI preferences kept in cookies (D-48, D-49) so the server renders them right from the first byte: the theme and the
|
||||||
|
/// expanded navigation groups. <c>App.razor</c> reads them on every full request and hands them to the interactive
|
||||||
|
/// root; the circuit writes them back through the tiny script helper in <c>wwwroot/metervault.js</c>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A cookie rather than local storage because the server needs the value while prerendering: a theme stored only in
|
||||||
|
/// the browser would flash dark-then-light on every reload and reset on the language switch, which is a full reload.
|
||||||
|
/// The cookies are plain preferences (not HttpOnly, SameSite=Lax, one year, path /), written only by the script, whose
|
||||||
|
/// whitelist accepts nothing but these two names.
|
||||||
|
/// </remarks>
|
||||||
|
public static class BrowserPreferences
|
||||||
|
{
|
||||||
|
/// <summary>The theme cookie: <c>dark</c> or <c>light</c>.</summary>
|
||||||
|
public const string ThemeCookie = "mv-theme";
|
||||||
|
|
||||||
|
/// <summary>The expanded navigation groups (<see cref="NavGroups"/>).</summary>
|
||||||
|
public const string NavCookie = "mv-nav";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes a preference cookie from the circuit. A missing or disconnected browser (prerender, a closed tab) only
|
||||||
|
/// loses the preference, never the circuit.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task SaveAsync(IJSRuntime js, string name, string value)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(js);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await js.InvokeAsync<bool>("meterVault.setPreference", name, value);
|
||||||
|
}
|
||||||
|
catch (JSDisconnectedException)
|
||||||
|
{
|
||||||
|
// The browser went away; there is nobody to remember the preference for.
|
||||||
|
}
|
||||||
|
catch (JSException)
|
||||||
|
{
|
||||||
|
// The helper script failed to load (blocked, stale cache): the preference lasts for this circuit only.
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException)
|
||||||
|
{
|
||||||
|
// Prerendering: no browser yet. Preferences are only changed interactively, so this is a programming slip.
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
// The call timed out or the circuit is shutting down.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
<!DOCTYPE html>
|
@using System.Globalization
|
||||||
<html lang="en">
|
@using Microsoft.AspNetCore.Localization
|
||||||
|
@using MeterVault.App.Theme
|
||||||
|
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="@CultureInfo.CurrentUICulture.TwoLetterISOLanguageName">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
@@ -14,12 +18,53 @@
|
|||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<Routes @rendermode="InteractiveServer" />
|
@* The theme and the expanded navigation groups come from cookies read here, on the server, and ride into the
|
||||||
|
interactive root as parameters: they survive prerender -> circuit, so a reload or the language switch (a full
|
||||||
|
reload) renders the chosen mode from the first byte instead of flashing the default (D-48, D-49). *@
|
||||||
|
<Routes @rendermode="InteractiveServer" DarkMode="@_darkMode" NavGroups="@_navGroups" />
|
||||||
<ReconnectModal />
|
<ReconnectModal />
|
||||||
<script src="@Assets["_framework/blazor.web.js"]"></script>
|
<script src="@Assets["_framework/blazor.web.js"]"></script>
|
||||||
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
|
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
|
||||||
<script src="_content/Blazor-ApexCharts/js/apex-charts.min.js"></script>
|
@* Blazor-ApexCharts 6.x imports its own ES modules (js/apexcharts.esm.js, js/blazor-apexcharts.js) when a chart
|
||||||
<script src="_content/Blazor-ApexCharts/js/blazor-apex-charts.min.js"></script>
|
first renders; the old apex-charts.min.js / blazor-apex-charts.min.js bundles no longer ship, so they are not
|
||||||
|
referenced. *@
|
||||||
|
<script src="@Assets["metervault.js"]"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private bool _darkMode = ThemeState.DefaultIsDark;
|
||||||
|
private string? _navGroups;
|
||||||
|
|
||||||
|
[CascadingParameter]
|
||||||
|
private HttpContext? HttpContext { get; set; }
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
if (HttpContext is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_darkMode = ThemeState.Parse(HttpContext.Request.Cookies[BrowserPreferences.ThemeCookie]) ?? ThemeState.DefaultIsDark;
|
||||||
|
_navGroups = HttpContext.Request.Cookies[BrowserPreferences.NavCookie];
|
||||||
|
|
||||||
|
// Pin whatever the middleware negotiated (Accept-Language, or the configured default) into the
|
||||||
|
// culture cookie on the very first visit. Without this the language picker would be the only
|
||||||
|
// thing that ever writes the cookie, so a reader whose browser asked for German would be served
|
||||||
|
// German until the moment they touched the picker — and the picker would open showing English.
|
||||||
|
HttpContext.Response.Cookies.Append(
|
||||||
|
CookieRequestCultureProvider.DefaultCookieName,
|
||||||
|
CookieRequestCultureProvider.MakeCookieValue(
|
||||||
|
new RequestCulture(CultureInfo.CurrentCulture, CultureInfo.CurrentUICulture)),
|
||||||
|
new CookieOptions
|
||||||
|
{
|
||||||
|
Path = "/",
|
||||||
|
Expires = DateTimeOffset.UtcNow.AddYears(1),
|
||||||
|
SameSite = SameSiteMode.Lax,
|
||||||
|
HttpOnly = true,
|
||||||
|
IsEssential = true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
@inherits LayoutComponentBase
|
@inherits LayoutComponentBase
|
||||||
|
@implements IDisposable
|
||||||
|
@using System.Globalization
|
||||||
@using MeterVault.App.Theme
|
@using MeterVault.App.Theme
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
@inject IDialogService DialogService
|
||||||
|
@inject ThemeState Theme
|
||||||
|
|
||||||
<MudThemeProvider Theme="MeterVaultTheme.Instance" @bind-IsDarkMode="_darkMode" />
|
@* The mode lives in the scoped ThemeState (D-49): App.razor seeds it from the cookie, so prerender, reload and the
|
||||||
|
language switch keep it, and charts observe the same value. *@
|
||||||
|
<MudThemeProvider Theme="MeterVaultTheme.Instance" IsDarkMode="Theme.IsDark" />
|
||||||
<MudPopoverProvider />
|
<MudPopoverProvider />
|
||||||
<MudDialogProvider />
|
<MudDialogProvider />
|
||||||
<MudSnackbarProvider />
|
<MudSnackbarProvider />
|
||||||
@@ -9,13 +16,34 @@
|
|||||||
<MudLayout>
|
<MudLayout>
|
||||||
<MudAppBar Elevation="1" Dense="true">
|
<MudAppBar Elevation="1" Dense="true">
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit" Edge="Edge.Start"
|
<MudIconButton Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit" Edge="Edge.Start"
|
||||||
OnClick="@(() => _drawerOpen = !_drawerOpen)" aria-label="Toggle navigation" />
|
OnClick="@(() => _drawerOpen = !_drawerOpen)" aria-label="@S.Layout_ToggleNavigation" />
|
||||||
<MudIcon Icon="@Icons.Material.Filled.Bolt" Class="mr-2" />
|
<MudIcon Icon="@Icons.Material.Filled.Bolt" Class="mr-2" />
|
||||||
<MudText Typo="Typo.h6">MeterVault</MudText>
|
<MudText Typo="Typo.h6">MeterVault</MudText>
|
||||||
<MudSpacer />
|
<MudSpacer />
|
||||||
<MudTooltip Text="@(_darkMode ? "Light mode" : "Dark mode")">
|
@* Search is the fastest way to a meter from anywhere: labelled where there is room (md and up), an icon with
|
||||||
<MudIconButton Icon="@(_darkMode ? Icons.Material.Filled.LightMode : Icons.Material.Filled.DarkMode)"
|
an accessible name below. *@
|
||||||
Color="Color.Inherit" OnClick="@(() => _darkMode = !_darkMode)" />
|
<MudButton Variant="Variant.Text" Color="Color.Inherit" StartIcon="@Icons.Material.Filled.Search"
|
||||||
|
OnClick="OpenMeterSearchAsync" Class="d-none d-md-inline-flex mr-1">@S.Layout_FindMeter</MudButton>
|
||||||
|
<MudTooltip Text="@S.Layout_FindMeter" RootClass="d-md-none">
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Search" Color="Color.Inherit" OnClick="OpenMeterSearchAsync"
|
||||||
|
aria-label="@S.Layout_FindMeter" />
|
||||||
|
</MudTooltip>
|
||||||
|
<MudTooltip Text="@S.Layout_Language">
|
||||||
|
<MudMenu Icon="@Icons.Material.Filled.Translate" Color="Color.Inherit"
|
||||||
|
AriaLabel="@S.Layout_Language" Dense="true">
|
||||||
|
@foreach (var culture in Loc.SupportedCultures)
|
||||||
|
{
|
||||||
|
<MudMenuItem OnClick="@(() => SwitchCulture(culture))"
|
||||||
|
Icon="@(culture == _current ? Icons.Material.Filled.Check : null)"
|
||||||
|
IconColor="Color.Primary">
|
||||||
|
@Loc.DisplayName(culture)
|
||||||
|
</MudMenuItem>
|
||||||
|
}
|
||||||
|
</MudMenu>
|
||||||
|
</MudTooltip>
|
||||||
|
<MudTooltip Text="@ThemeToggleLabel">
|
||||||
|
<MudIconButton Icon="@(Theme.IsDark ? Icons.Material.Filled.LightMode : Icons.Material.Filled.DarkMode)"
|
||||||
|
Color="Color.Inherit" OnClick="Theme.ToggleAsync" aria-label="@ThemeToggleLabel" />
|
||||||
</MudTooltip>
|
</MudTooltip>
|
||||||
</MudAppBar>
|
</MudAppBar>
|
||||||
|
|
||||||
@@ -31,12 +59,53 @@
|
|||||||
</MudLayout>
|
</MudLayout>
|
||||||
|
|
||||||
<div id="blazor-error-ui" data-nosnippet>
|
<div id="blazor-error-ui" data-nosnippet>
|
||||||
An unhandled error has occurred.
|
@S.Layout_UnhandledError
|
||||||
<a href="." class="reload">Reload</a>
|
<a href="." class="reload">@S.Layout_Reload</a>
|
||||||
<span class="dismiss">🗙</span>
|
<span class="dismiss">🗙</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private bool _drawerOpen = true;
|
private bool _drawerOpen = true;
|
||||||
private bool _darkMode = true;
|
|
||||||
|
private string _current = Loc.SupportedCultures[0];
|
||||||
|
|
||||||
|
/// <summary>What the theme button does: switch to the other mode.</summary>
|
||||||
|
private string ThemeToggleLabel => Theme.IsDark ? S.Layout_LightMode : S.Layout_DarkMode;
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
Loc.TryResolve(CultureInfo.CurrentUICulture.Name, out _current);
|
||||||
|
Theme.Changed += OnThemeChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnThemeChanged() => _ = InvokeAsync(StateHasChanged);
|
||||||
|
|
||||||
|
public void Dispose() => Theme.Changed -= OnThemeChanged;
|
||||||
|
|
||||||
|
private async Task OpenMeterSearchAsync() =>
|
||||||
|
await DialogService.ShowAsync<MeterSearchDialog>(S.Layout_FindMeter, new DialogOptions
|
||||||
|
{
|
||||||
|
MaxWidth = MaxWidth.Small,
|
||||||
|
FullWidth = true,
|
||||||
|
CloseButton = true,
|
||||||
|
CloseOnEscapeKey = true,
|
||||||
|
Position = DialogPosition.TopCenter,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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, and
|
||||||
|
// the theme cookie keeps the mode.
|
||||||
|
private void SwitchCulture(string culture)
|
||||||
|
{
|
||||||
|
if (culture == _current)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var here = new Uri(Navigation.Uri).GetComponents(UriComponents.PathAndQuery, UriFormat.Unescaped);
|
||||||
|
Navigation.NavigateTo(
|
||||||
|
$"/culture/set?culture={Uri.EscapeDataString(culture)}&redirectUri={Uri.EscapeDataString(here)}",
|
||||||
|
forceLoad: true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,48 +1,181 @@
|
|||||||
|
@implements IDisposable
|
||||||
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
@inject NavState NavState
|
||||||
|
@inject IJSRuntime JS
|
||||||
|
@inject ILogger<NavMenu> Logger
|
||||||
|
@using Microsoft.AspNetCore.Components.Routing
|
||||||
@using Microsoft.EntityFrameworkCore
|
@using Microsoft.EntityFrameworkCore
|
||||||
@using MeterVault.Core.Domain
|
@using MeterVault.Core.Domain
|
||||||
|
|
||||||
|
@* One stable structure (D-48, brief §3.1): the analysis entries first, the per-type analysis and the specialized views
|
||||||
|
as groups, then data import and the configuration pages. "Energy types" here opens a type's analysis; the entry of
|
||||||
|
the same name under Configuration edits the definitions. Expanded groups persist in a cookie, and the group holding
|
||||||
|
the current page is always open. *@
|
||||||
<MudNavMenu>
|
<MudNavMenu>
|
||||||
<MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">Overview</MudNavLink>
|
<MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">@S.Nav_Overview</MudNavLink>
|
||||||
<MudNavLink Href="/trends" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.ShowChart">Trends</MudNavLink>
|
<MudNavLink Href="/trends" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.ShowChart">@S.Nav_Analysis</MudNavLink>
|
||||||
|
<MudNavLink Href="/meters" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Speed">@S.Nav_Meters</MudNavLink>
|
||||||
|
|
||||||
|
<MudNavGroup Title="@S.Nav_EnergyTypes" Icon="@Icons.Material.Filled.Layers"
|
||||||
|
Expanded="@IsExpanded(NavGroups.EnergyTypes)" ExpandedChanged="@(v => SetExpandedAsync(NavGroups.EnergyTypes, v))">
|
||||||
|
@if (_typesFailed)
|
||||||
|
{
|
||||||
|
@* A database hiccup keeps the group and says so, with a way to try again — never a silently shorter menu. *@
|
||||||
|
<MudText Typo="Typo.caption" Color="Color.Error" Class="d-block px-4 py-1" role="status">@S.Nav_EnergyTypesError</MudText>
|
||||||
|
<MudNavLink OnClick="RetryEnergyTypesAsync" Icon="@Icons.Material.Filled.Refresh">@S.Common_Retry</MudNavLink>
|
||||||
|
}
|
||||||
|
else if (_energyTypes is { Count: 0 })
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="d-block px-4 py-1">@S.Nav_NoEnergyTypes</MudText>
|
||||||
|
}
|
||||||
|
else if (_energyTypes is not null)
|
||||||
|
{
|
||||||
@foreach (var type in _energyTypes)
|
@foreach (var type in _energyTypes)
|
||||||
{
|
{
|
||||||
<MudNavLink Href="@($"/energy/{type.Id}")" Match="NavLinkMatch.Prefix" Icon="@TypeIcon(type.Icon)">@type.DisplayName</MudNavLink>
|
<MudNavLink Href="@AnalysisLinks.EnergyType(type.Id)" Match="NavLinkMatch.Prefix" Icon="@TypeIcon(type.Icon)">@type.DisplayName</MudNavLink>
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
</MudNavGroup>
|
||||||
|
|
||||||
<MudNavLink Href="/solar" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.WbSunny">Solar / PV</MudNavLink>
|
@* Always listed; without the meters a view needs, it says what is missing instead of disappearing. What counts is
|
||||||
<MudNavLink Href="/consumables" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.OilBarrel">Oil / consumables</MudNavLink>
|
the meters' configured mode and tank, never their names. *@
|
||||||
<MudNavLink Href="/meters" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Speed">Meters</MudNavLink>
|
<MudNavGroup Title="@S.Nav_SpecializedViews" Icon="@Icons.Material.Filled.ViewQuilt"
|
||||||
<MudNavLink Href="/import" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.UploadFile">Import</MudNavLink>
|
Expanded="@IsExpanded(NavGroups.SpecializedViews)" ExpandedChanged="@(v => SetExpandedAsync(NavGroups.SpecializedViews, v))">
|
||||||
<MudDivider Class="my-2" />
|
<MudNavLink Href="/solar" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.WbSunny">
|
||||||
<MudNavGroup Title="Admin" Icon="@Icons.Material.Filled.Settings" Expanded="false">
|
@S.Nav_Solar
|
||||||
<MudNavLink Href="/admin/energy-types" Icon="@Icons.Material.Filled.Category">Energy types</MudNavLink>
|
@if (_setup is { HasGeneration: false })
|
||||||
<MudNavLink Href="/admin/tariffs" Icon="@Icons.Material.Filled.Euro">Tariffs</MudNavLink>
|
{
|
||||||
<MudNavLink Href="/admin/categories" Icon="@Icons.Material.Filled.Folder">Cost categories</MudNavLink>
|
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="d-block">@S.Nav_SolarSetup</MudText>
|
||||||
<MudNavLink Href="/admin/connectors" Icon="@Icons.Material.Filled.SettingsInputComponent">Connectors</MudNavLink>
|
}
|
||||||
<MudNavLink Href="/admin/settings" Icon="@Icons.Material.Filled.Tune">Settings</MudNavLink>
|
</MudNavLink>
|
||||||
|
<MudNavLink Href="/consumables" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.OilBarrel">
|
||||||
|
@S.Nav_Consumables
|
||||||
|
@if (_setup is { HasTank: false })
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="d-block">@S.Nav_ConsumablesSetup</MudText>
|
||||||
|
}
|
||||||
|
</MudNavLink>
|
||||||
|
</MudNavGroup>
|
||||||
|
|
||||||
|
<MudNavLink Href="/import" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.UploadFile">@S.Nav_Import</MudNavLink>
|
||||||
|
|
||||||
|
<MudNavGroup Title="@S.Nav_Configuration" Icon="@Icons.Material.Filled.Settings"
|
||||||
|
Expanded="@IsExpanded(NavGroups.Configuration)" ExpandedChanged="@(v => SetExpandedAsync(NavGroups.Configuration, v))">
|
||||||
|
<MudNavLink Href="/admin/energy-types" Icon="@Icons.Material.Filled.Category">@S.Nav_EnergyTypes</MudNavLink>
|
||||||
|
<MudNavLink Href="/admin/tariffs" Icon="@Icons.Material.Filled.Euro">@S.Nav_Tariffs</MudNavLink>
|
||||||
|
<MudNavLink Href="/admin/categories" Icon="@Icons.Material.Filled.Folder">@S.Nav_CostCategories</MudNavLink>
|
||||||
|
<MudNavLink Href="/admin/connectors" Icon="@Icons.Material.Filled.SettingsInputComponent">@S.Nav_Connectors</MudNavLink>
|
||||||
|
<MudNavLink Href="/admin/settings" Icon="@Icons.Material.Filled.Tune">@S.Nav_Settings</MudNavLink>
|
||||||
</MudNavGroup>
|
</MudNavGroup>
|
||||||
</MudNavMenu>
|
</MudNavMenu>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private List<EnergyType> _energyTypes = [];
|
private List<EnergyType>? _energyTypes;
|
||||||
|
private bool _typesFailed;
|
||||||
|
private ViewSetup? _setup;
|
||||||
|
private HashSet<string> _expanded = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
_expanded = new HashSet<string>((IEnumerable<string>?)NavState.SavedGroups ?? NavGroups.DefaultExpanded, StringComparer.Ordinal);
|
||||||
|
OpenGroupOf(Navigation.Uri);
|
||||||
|
|
||||||
|
Navigation.LocationChanged += OnLocationChanged;
|
||||||
|
NavState.EnergyTypesChanged += OnEnergyTypesChanged;
|
||||||
|
NavState.MetersChanged += OnMetersChanged;
|
||||||
|
|
||||||
|
await LoadEnergyTypesAsync();
|
||||||
|
await LoadSetupAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsExpanded(string group) => _expanded.Contains(group);
|
||||||
|
|
||||||
|
/// <summary>A group folded or opened by the user: remembered for the next visit.</summary>
|
||||||
|
private async Task SetExpandedAsync(string group, bool expanded)
|
||||||
|
{
|
||||||
|
var changed = expanded ? _expanded.Add(group) : _expanded.Remove(group);
|
||||||
|
if (changed)
|
||||||
|
{
|
||||||
|
await BrowserPreferences.SaveAsync(JS, BrowserPreferences.NavCookie, NavGroups.Format(_expanded));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Opens the group holding the page at <paramref name="uri"/>; never folds one (that is the user's call).</summary>
|
||||||
|
private bool OpenGroupOf(string uri) =>
|
||||||
|
NavGroups.GroupFor(Navigation.ToBaseRelativePath(uri)) is { } group && _expanded.Add(group);
|
||||||
|
|
||||||
|
private async Task LoadEnergyTypesAsync()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await using var db = await DbFactory.CreateDbContextAsync();
|
await using var db = await DbFactory.CreateDbContextAsync();
|
||||||
_energyTypes = await db.EnergyTypes.AsNoTracking().OrderBy(t => t.Id).ToListAsync();
|
_energyTypes = await db.EnergyTypes.AsNoTracking().OrderBy(t => t.Id).ToListAsync();
|
||||||
|
_typesFailed = false;
|
||||||
}
|
}
|
||||||
catch (Exception)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// Nav must never break the layout — a DB hiccup just hides the per-type links.
|
// The layout must never break over the menu: keep the group, say it failed, offer Retry — and log why.
|
||||||
_energyTypes = [];
|
Logger.LogError(ex, "Loading the energy types for the navigation failed");
|
||||||
|
_energyTypes = null;
|
||||||
|
_typesFailed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map the energy type's stored icon name to a Material icon; fall back to a generic gauge.
|
/// <summary>Whether the specialized views have what they need: a generation counter for Solar, a tank for Consumables.</summary>
|
||||||
|
private async Task LoadSetupAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var db = await DbFactory.CreateDbContextAsync();
|
||||||
|
var hasGeneration = await db.Meters.AnyAsync(m => m.Mode == MeterMode.GenerationCounter);
|
||||||
|
var hasTank = await db.Tanks.AnyAsync(t => db.Meters.Any(m => m.Id == t.MeterId && m.Mode == MeterMode.ConsumableBalance));
|
||||||
|
_setup = new ViewSetup(hasGeneration, hasTank);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Unknown is shown as nothing: the entries stay, without a setup hint.
|
||||||
|
Logger.LogError(ex, "Checking the specialized views' setup for the navigation failed");
|
||||||
|
_setup = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RetryEnergyTypesAsync()
|
||||||
|
{
|
||||||
|
await LoadEnergyTypesAsync();
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnLocationChanged(object? sender, LocationChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (OpenGroupOf(e.Location))
|
||||||
|
{
|
||||||
|
_ = InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnEnergyTypesChanged() =>
|
||||||
|
_ = InvokeAsync(async () =>
|
||||||
|
{
|
||||||
|
await LoadEnergyTypesAsync();
|
||||||
|
StateHasChanged();
|
||||||
|
});
|
||||||
|
|
||||||
|
private void OnMetersChanged() =>
|
||||||
|
_ = InvokeAsync(async () =>
|
||||||
|
{
|
||||||
|
await LoadSetupAsync();
|
||||||
|
StateHasChanged();
|
||||||
|
});
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Navigation.LocationChanged -= OnLocationChanged;
|
||||||
|
NavState.EnergyTypesChanged -= OnEnergyTypesChanged;
|
||||||
|
NavState.MetersChanged -= OnMetersChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
private static string TypeIcon(string? icon) => icon switch
|
||||||
{
|
{
|
||||||
"bolt" => Icons.Material.Filled.Bolt,
|
"bolt" => Icons.Material.Filled.Bolt,
|
||||||
@@ -52,4 +185,6 @@
|
|||||||
"thermostat" => Icons.Material.Filled.Thermostat,
|
"thermostat" => Icons.Material.Filled.Thermostat,
|
||||||
_ => Icons.Material.Filled.Bolt,
|
_ => Icons.Material.Filled.Bolt,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private sealed record ViewSetup(bool HasGeneration, bool HasTank);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,25 +7,25 @@
|
|||||||
<div></div>
|
<div></div>
|
||||||
</div>
|
</div>
|
||||||
<p class="components-reconnect-first-attempt-visible">
|
<p class="components-reconnect-first-attempt-visible">
|
||||||
Rejoining the server...
|
@S.Reconnect_Rejoining
|
||||||
</p>
|
</p>
|
||||||
<p class="components-reconnect-repeated-attempt-visible">
|
<p class="components-reconnect-repeated-attempt-visible">
|
||||||
Rejoin failed... trying again in <span id="components-seconds-to-next-attempt"></span> seconds.
|
@S.Reconnect_RetryCountdownPrefix <span id="components-seconds-to-next-attempt"></span> @S.Reconnect_RetryCountdownSuffix
|
||||||
</p>
|
</p>
|
||||||
<p class="components-reconnect-failed-visible">
|
<p class="components-reconnect-failed-visible">
|
||||||
Failed to rejoin.<br />Please retry or reload the page.
|
@S.Reconnect_RejoinFailed<br />@S.Reconnect_RetryOrReload
|
||||||
</p>
|
</p>
|
||||||
<button id="components-reconnect-button" class="components-reconnect-failed-visible">
|
<button id="components-reconnect-button" class="components-reconnect-failed-visible">
|
||||||
Retry
|
@S.Reconnect_Retry
|
||||||
</button>
|
</button>
|
||||||
<p class="components-pause-visible">
|
<p class="components-pause-visible">
|
||||||
The session has been paused by the server.
|
@S.Reconnect_Paused
|
||||||
</p>
|
</p>
|
||||||
<p class="components-resume-failed-visible">
|
<p class="components-resume-failed-visible">
|
||||||
Failed to resume the session.<br />Please retry or reload the page.
|
@S.Reconnect_ResumeFailed<br />@S.Reconnect_RetryOrReload
|
||||||
</p>
|
</p>
|
||||||
<button id="components-resume-button" class="components-pause-visible components-resume-failed-visible">
|
<button id="components-resume-button" class="components-pause-visible components-resume-failed-visible">
|
||||||
Resume
|
@S.Reconnect_Resume
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|||||||
@@ -5,12 +5,12 @@
|
|||||||
@using Microsoft.EntityFrameworkCore
|
@using Microsoft.EntityFrameworkCore
|
||||||
@using MudBlazor
|
@using MudBlazor
|
||||||
|
|
||||||
<PageTitle>MeterVault — Cost categories</PageTitle>
|
<PageTitle>MeterVault — @S.Nav_CostCategories</PageTitle>
|
||||||
|
|
||||||
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
||||||
<MudText Typo="Typo.h4">Cost categories</MudText>
|
<MudText Typo="Typo.h4">@S.Nav_CostCategories</MudText>
|
||||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
|
||||||
Add category
|
@S.Categories_AddCategory
|
||||||
</MudButton>
|
</MudButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -22,22 +22,22 @@ else
|
|||||||
{
|
{
|
||||||
<MudTable Items="_categories" Dense="true" Hover="true" Elevation="2">
|
<MudTable Items="_categories" Dense="true" Hover="true" Elevation="2">
|
||||||
<HeaderContent>
|
<HeaderContent>
|
||||||
<MudTh>Name</MudTh>
|
<MudTh>@S.Common_Name</MudTh>
|
||||||
<MudTh>Sort</MudTh>
|
<MudTh>@S.Categories_Sort</MudTh>
|
||||||
<MudTh>Members</MudTh>
|
<MudTh>@S.Categories_Members</MudTh>
|
||||||
<MudTh Style="text-align:right">Actions</MudTh>
|
<MudTh Style="text-align:right">@S.Common_Actions</MudTh>
|
||||||
</HeaderContent>
|
</HeaderContent>
|
||||||
<RowTemplate>
|
<RowTemplate>
|
||||||
<MudTd DataLabel="Name">
|
<MudTd DataLabel="@S.Common_Name">
|
||||||
@if (!string.IsNullOrWhiteSpace(context.ColorHex))
|
@if (!string.IsNullOrWhiteSpace(context.ColorHex))
|
||||||
{
|
{
|
||||||
<span style="display:inline-block;width:.8em;height:.8em;border-radius:2px;margin-right:.4em;background:@context.ColorHex"></span>
|
<span style="display:inline-block;width:.8em;height:.8em;border-radius:2px;margin-right:.4em;background:@context.ColorHex"></span>
|
||||||
}
|
}
|
||||||
@context.Name
|
@context.Name
|
||||||
</MudTd>
|
</MudTd>
|
||||||
<MudTd DataLabel="Sort">@context.Sort</MudTd>
|
<MudTd DataLabel="@S.Categories_Sort">@context.Sort</MudTd>
|
||||||
<MudTd DataLabel="Members">@MemberSummary(context)</MudTd>
|
<MudTd DataLabel="@S.Categories_Members">@MemberSummary(context)</MudTd>
|
||||||
<MudTd DataLabel="Actions" Style="text-align:right">
|
<MudTd DataLabel="@S.Common_Actions" Style="text-align:right">
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => OpenEdit(context))" />
|
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => OpenEdit(context))" />
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(context))" />
|
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(context))" />
|
||||||
</MudTd>
|
</MudTd>
|
||||||
@@ -47,20 +47,20 @@ else
|
|||||||
|
|
||||||
<MudDialog @bind-Visible="_editOpen" Options="_dialogOptions">
|
<MudDialog @bind-Visible="_editOpen" Options="_dialogOptions">
|
||||||
<TitleContent>
|
<TitleContent>
|
||||||
<MudText Typo="Typo.h6">@(_working.Id == 0 ? "New category" : $"Edit {_working.Name}")</MudText>
|
<MudText Typo="Typo.h6">@(_working.Id == 0 ? S.Categories_NewCategory : Loc.F(S.Categories_EditCategory, _working.Name))</MudText>
|
||||||
</TitleContent>
|
</TitleContent>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<MudTextField @bind-Value="_working.Name" Label="Name" Required="true" Class="mb-2" />
|
<MudTextField @bind-Value="_working.Name" Label="@S.Common_Name" Required="true" Class="mb-2" />
|
||||||
<MudTextField @bind-Value="_working.ColorHex" Label="Color hex (optional, e.g. #ff9800)" Class="mb-2" />
|
<MudTextField @bind-Value="_working.ColorHex" Label="@S.Categories_ColorHex" Class="mb-2" />
|
||||||
<MudNumericField T="int" @bind-Value="_working.Sort" Label="Sort order" Class="mb-2" />
|
<MudNumericField T="int" @bind-Value="_working.Sort" Label="@S.Categories_SortOrder" Class="mb-2" />
|
||||||
|
|
||||||
@if (_working.Id != 0)
|
@if (_working.Id != 0)
|
||||||
{
|
{
|
||||||
<MudDivider Class="my-3" />
|
<MudDivider Class="my-3" />
|
||||||
<MudText Typo="Typo.subtitle2" Class="mb-2">Members</MudText>
|
<MudText Typo="Typo.subtitle2" Class="mb-2">@S.Categories_Members</MudText>
|
||||||
@if (_members.Count == 0)
|
@if (_members.Count == 0)
|
||||||
{
|
{
|
||||||
<MudText Typo="Typo.body2" Color="Color.Secondary">No members yet — add a meter or an energy type.</MudText>
|
<MudText Typo="Typo.body2" Color="Color.Secondary">@S.Categories_NoMembers</MudText>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -77,30 +77,30 @@ else
|
|||||||
</MudList>
|
</MudList>
|
||||||
}
|
}
|
||||||
<div class="d-flex align-center mt-2" style="gap:.5rem; flex-wrap:wrap">
|
<div class="d-flex align-center mt-2" style="gap:.5rem; flex-wrap:wrap">
|
||||||
<MudSelect T="int?" @bind-Value="_addMeterId" Label="Add meter" Dense="true" Style="min-width:180px">
|
<MudSelect T="int?" @bind-Value="_addMeterId" Label="@S.Categories_AddMeter" Dense="true" Style="min-width:180px">
|
||||||
@foreach (var meter in _meters)
|
@foreach (var meter in _meters)
|
||||||
{
|
{
|
||||||
<MudSelectItem T="int?" Value="@((int?)meter.Id)">@meter.Name</MudSelectItem>
|
<MudSelectItem T="int?" Value="@((int?)meter.Id)">@meter.Name</MudSelectItem>
|
||||||
}
|
}
|
||||||
</MudSelect>
|
</MudSelect>
|
||||||
<MudButton Size="Size.Small" OnClick="AddMeterMemberAsync" Disabled="_addMeterId is null">Add</MudButton>
|
<MudButton Size="Size.Small" OnClick="AddMeterMemberAsync" Disabled="_addMeterId is null">@S.Categories_Add</MudButton>
|
||||||
<MudSelect T="int?" @bind-Value="_addTypeId" Label="Add energy type" Dense="true" Style="min-width:180px">
|
<MudSelect T="int?" @bind-Value="_addTypeId" Label="@S.Categories_AddEnergyType" Dense="true" Style="min-width:180px">
|
||||||
@foreach (var t in _energyTypes)
|
@foreach (var t in _energyTypes)
|
||||||
{
|
{
|
||||||
<MudSelectItem T="int?" Value="@((int?)t.Id)">@t.DisplayName</MudSelectItem>
|
<MudSelectItem T="int?" Value="@((int?)t.Id)">@t.DisplayName</MudSelectItem>
|
||||||
}
|
}
|
||||||
</MudSelect>
|
</MudSelect>
|
||||||
<MudButton Size="Size.Small" OnClick="AddTypeMemberAsync" Disabled="_addTypeId is null">Add</MudButton>
|
<MudButton Size="Size.Small" OnClick="AddTypeMemberAsync" Disabled="_addTypeId is null">@S.Categories_Add</MudButton>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-2">Save the category first to add members.</MudAlert>
|
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-2">@S.Categories_SaveFirst</MudAlert>
|
||||||
}
|
}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<MudButton OnClick="@(() => _editOpen = false)">Close</MudButton>
|
<MudButton OnClick="@(() => _editOpen = false)">@S.Categories_Close</MudButton>
|
||||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">Save</MudButton>
|
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">@S.Common_Save</MudButton>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</MudDialog>
|
</MudDialog>
|
||||||
|
|
||||||
@@ -129,12 +129,12 @@ else
|
|||||||
{
|
{
|
||||||
var meters = c.Members.Count(m => m.MeterId is not null);
|
var meters = c.Members.Count(m => m.MeterId is not null);
|
||||||
var types = c.Members.Count(m => m.EnergyTypeId is not null);
|
var types = c.Members.Count(m => m.EnergyTypeId is not null);
|
||||||
return meters + types == 0 ? "—" : $"{meters} meter(s), {types} type(s)";
|
return meters + types == 0 ? "—" : Loc.F(S.Categories_MemberSummary, meters, types);
|
||||||
}
|
}
|
||||||
|
|
||||||
private string MemberLabel(CostCategoryMember m) =>
|
private string MemberLabel(CostCategoryMember m) =>
|
||||||
m.MeterId is { } meterId ? $"Meter: {_meters.FirstOrDefault(x => x.Id == meterId)?.Name ?? $"#{meterId}"}"
|
m.MeterId is { } meterId ? Loc.F(S.Categories_MemberMeter, _meters.FirstOrDefault(x => x.Id == meterId)?.Name ?? $"#{meterId}")
|
||||||
: m.EnergyTypeId is { } typeId ? $"Type: {_energyTypes.FirstOrDefault(x => x.Id == typeId)?.DisplayName ?? $"#{typeId}"}"
|
: m.EnergyTypeId is { } typeId ? Loc.F(S.Categories_MemberType, _energyTypes.FirstOrDefault(x => x.Id == typeId)?.DisplayName ?? $"#{typeId}")
|
||||||
: "—";
|
: "—";
|
||||||
|
|
||||||
private void OpenEdit(CostCategory? category)
|
private void OpenEdit(CostCategory? category)
|
||||||
@@ -158,7 +158,7 @@ else
|
|||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(_working.Name))
|
if (string.IsNullOrWhiteSpace(_working.Name))
|
||||||
{
|
{
|
||||||
Snackbar.Add("Name is required.", Severity.Warning);
|
Snackbar.Add(S.Common_NameRequired, Severity.Warning);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,7 +169,7 @@ else
|
|||||||
db.CostCategories.Add(category);
|
db.CostCategories.Add(category);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
// Re-open on the new category so members can be added.
|
// Re-open on the new category so members can be added.
|
||||||
Snackbar.Add("Saved. Add members below.", Severity.Success);
|
Snackbar.Add(S.Categories_SavedAddMembers, Severity.Success);
|
||||||
await LoadAsync();
|
await LoadAsync();
|
||||||
OpenEdit(_categories!.First(c => c.Id == category.Id));
|
OpenEdit(_categories!.First(c => c.Id == category.Id));
|
||||||
return;
|
return;
|
||||||
@@ -181,7 +181,7 @@ else
|
|||||||
existing.Sort = _working.Sort;
|
existing.Sort = _working.Sort;
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
_editOpen = false;
|
_editOpen = false;
|
||||||
Snackbar.Add("Saved.", Severity.Success);
|
Snackbar.Add(S.Common_Saved, Severity.Success);
|
||||||
await LoadAsync();
|
await LoadAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,8 +235,8 @@ else
|
|||||||
|
|
||||||
private async Task DeleteAsync(CostCategory category)
|
private async Task DeleteAsync(CostCategory category)
|
||||||
{
|
{
|
||||||
if (!await Confirm.DeleteAsync(DialogService, "Delete category",
|
if (!await Confirm.DeleteAsync(DialogService, S.Categories_DeleteTitle,
|
||||||
$"Delete '{category.Name}' and its {category.Members.Count} membership(s)? Manual costs in this category are kept but unlinked."))
|
Loc.F(S.Categories_DeleteBody, category.Name, category.Members.Count)))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -244,7 +244,7 @@ else
|
|||||||
await using var db = await DbFactory.CreateDbContextAsync();
|
await using var db = await DbFactory.CreateDbContextAsync();
|
||||||
// Members cascade with the category; manual_cost.category_id is SetNull.
|
// Members cascade with the category; manual_cost.category_id is SetNull.
|
||||||
await db.CostCategories.Where(c => c.Id == category.Id).ExecuteDeleteAsync();
|
await db.CostCategories.Where(c => c.Id == category.Id).ExecuteDeleteAsync();
|
||||||
Snackbar.Add("Deleted.", Severity.Success);
|
Snackbar.Add(S.Common_Deleted, Severity.Success);
|
||||||
await LoadAsync();
|
await LoadAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,33 @@
|
|||||||
@page "/admin/connectors"
|
@page "/admin/connectors"
|
||||||
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
||||||
@inject MeterVault.Infrastructure.Ingestion.HaConnectionTester HaTester
|
@inject MeterVault.Infrastructure.Ingestion.HaConnectionTester HaTester
|
||||||
|
@inject MeterVault.Infrastructure.Security.SecretProtector Secrets
|
||||||
@inject ISnackbar Snackbar
|
@inject ISnackbar Snackbar
|
||||||
@inject IDialogService DialogService
|
@inject IDialogService DialogService
|
||||||
|
@inject NavigationManager Nav
|
||||||
@using Microsoft.EntityFrameworkCore
|
@using Microsoft.EntityFrameworkCore
|
||||||
@using MeterVault.Infrastructure.Ingestion
|
@using MeterVault.Infrastructure.Ingestion
|
||||||
@using MudBlazor
|
@using MudBlazor
|
||||||
|
|
||||||
<PageTitle>MeterVault — Connectors</PageTitle>
|
<PageTitle>MeterVault — @S.Nav_Connectors</PageTitle>
|
||||||
|
|
||||||
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
||||||
<MudText Typo="Typo.h4">Connectors</MudText>
|
<MudText Typo="Typo.h4">@S.Nav_Connectors</MudText>
|
||||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
|
||||||
Add connector
|
@S.Connectors_AddConnector
|
||||||
</MudButton>
|
</MudButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (_returnTo is { } back)
|
||||||
|
{
|
||||||
|
@* Arrived from a meter's source dialog: the way back is always in view, saved or not. *@
|
||||||
|
<MudAlert Severity="Severity.Normal" Variant="Variant.Outlined" Class="mb-4" Dense="true" Icon="@Icons.Material.Filled.Sensors">
|
||||||
|
@Loc.F(S.Connectors_ForMeter, back.MeterName) <MudLink Href="@MeterLinks.Source(back.MeterId, back.SourceId, back.SourceType)">@Loc.F(S.Connectors_BackToMeter, back.MeterName)</MudLink>
|
||||||
|
</MudAlert>
|
||||||
|
}
|
||||||
|
|
||||||
<MudAlert Severity="Severity.Info" Class="mb-4" Dense="true">
|
<MudAlert Severity="Severity.Info" Class="mb-4" Dense="true">
|
||||||
Secrets are never stored here. Credentials/tokens are referenced by the <b>name of an environment variable</b>
|
@S.Connectors_SecretsNoticePrefix <b>@S.Connectors_SecretsNoticeEnvVar</b> @S.Connectors_SecretsNoticeSuffix
|
||||||
(or Docker secret) resolved at runtime.
|
|
||||||
</MudAlert>
|
</MudAlert>
|
||||||
|
|
||||||
@if (_endpoints is null)
|
@if (_endpoints is null)
|
||||||
@@ -29,20 +38,43 @@ else
|
|||||||
{
|
{
|
||||||
<MudTable Items="_endpoints" Dense="true" Hover="true" Elevation="2">
|
<MudTable Items="_endpoints" Dense="true" Hover="true" Elevation="2">
|
||||||
<HeaderContent>
|
<HeaderContent>
|
||||||
<MudTh>Name</MudTh>
|
<MudTh>@S.Common_Name</MudTh>
|
||||||
<MudTh>Type</MudTh>
|
<MudTh>@S.Common_Type</MudTh>
|
||||||
<MudTh>Enabled</MudTh>
|
<MudTh>@S.Common_Enabled</MudTh>
|
||||||
<MudTh>Last status</MudTh>
|
<MudTh>@S.Connectors_UsedBy</MudTh>
|
||||||
<MudTh>Last seen</MudTh>
|
<MudTh>@S.Connectors_LastStatus</MudTh>
|
||||||
<MudTh Style="text-align:right">Actions</MudTh>
|
<MudTh>@S.Common_LastSeen</MudTh>
|
||||||
|
<MudTh Style="text-align:right">@S.Common_Actions</MudTh>
|
||||||
</HeaderContent>
|
</HeaderContent>
|
||||||
<RowTemplate>
|
<RowTemplate>
|
||||||
<MudTd DataLabel="Name">@context.Name</MudTd>
|
<MudTd DataLabel="@S.Common_Name">@context.Name</MudTd>
|
||||||
<MudTd DataLabel="Type">@context.Type</MudTd>
|
<MudTd DataLabel="@S.Common_Type">@context.Type.Display()</MudTd>
|
||||||
<MudTd DataLabel="Enabled">@(context.IsEnabled ? "yes" : "no")</MudTd>
|
<MudTd DataLabel="@S.Common_Enabled">@(context.IsEnabled ? S.Connectors_Yes : S.Connectors_No)</MudTd>
|
||||||
<MudTd DataLabel="Last status">@(context.LastStatus ?? "—")</MudTd>
|
<MudTd DataLabel="@S.Connectors_UsedBy">
|
||||||
<MudTd DataLabel="Last seen">@(context.LastSeenAt?.ToString("yyyy-MM-dd HH:mm") ?? "—")</MudTd>
|
@if (_usage.TryGetValue(context.Id, out var users))
|
||||||
<MudTd DataLabel="Actions" Style="text-align:right">
|
{
|
||||||
|
@foreach (var user in users.Take(UsersShown))
|
||||||
|
{
|
||||||
|
<MudLink Href="@MeterLinks.Detail(user.MeterId, MeterLinks.TabSources)" Class="mr-2">@user.MeterName</MudLink>
|
||||||
|
}
|
||||||
|
@if (users.Count > UsersShown)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.caption" Inline="true">@Loc.F(S.Connectors_UsedByMore, users.Count - UsersShown)</MudText>
|
||||||
|
}
|
||||||
|
@if (!context.IsEnabled)
|
||||||
|
{
|
||||||
|
@* Workers skip disabled connectors, so every source on it has stopped. *@
|
||||||
|
<MudText Typo="Typo.caption" Color="Color.Warning" Class="d-block">@S.Connectors_DisabledInUse</MudText>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2" Color="Color.Secondary">@S.Connectors_Unused</MudText>
|
||||||
|
}
|
||||||
|
</MudTd>
|
||||||
|
<MudTd DataLabel="@S.Connectors_LastStatus">@(context.LastStatus ?? "—")</MudTd>
|
||||||
|
<MudTd DataLabel="@S.Common_LastSeen">@(context.LastSeenAt?.ToString("yyyy-MM-dd HH:mm") ?? "—")</MudTd>
|
||||||
|
<MudTd DataLabel="@S.Common_Actions" Style="text-align:right">
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => OpenEdit(context))" />
|
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => OpenEdit(context))" />
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(context))" />
|
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(context))" />
|
||||||
</MudTd>
|
</MudTd>
|
||||||
@@ -50,34 +82,49 @@ else
|
|||||||
</MudTable>
|
</MudTable>
|
||||||
@if (_endpoints.Count == 0)
|
@if (_endpoints.Count == 0)
|
||||||
{
|
{
|
||||||
<MudAlert Severity="Severity.Normal" Class="mt-4">No connectors yet. Add an MQTT broker or a Home Assistant connection.</MudAlert>
|
<MudAlert Severity="Severity.Normal" Class="mt-4">@S.Connectors_Empty</MudAlert>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
<MudDialog @bind-Visible="_editOpen" Options="_dialogOptions">
|
<MudDialog @bind-Visible="_editOpen" Options="_dialogOptions">
|
||||||
<TitleContent>
|
<TitleContent>
|
||||||
<MudText Typo="Typo.h6">@(_working.Id == 0 ? "New connector" : $"Edit {_working.Name}")</MudText>
|
<MudText Typo="Typo.h6">@(_working.Id == 0 ? S.Connectors_NewConnector : Loc.F(S.Connectors_EditTitle, _working.Name))</MudText>
|
||||||
</TitleContent>
|
</TitleContent>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<MudSelect T="EndpointType" @bind-Value="_working.Type" Label="Type" Class="mb-2">
|
<MudSelect T="EndpointType" @bind-Value="_working.Type" Label="@S.Common_Type" Class="mb-2">
|
||||||
@foreach (var type in Enum.GetValues<EndpointType>())
|
@foreach (var type in Enum.GetValues<EndpointType>())
|
||||||
{
|
{
|
||||||
<MudSelectItem T="EndpointType" Value="type">@type</MudSelectItem>
|
<MudSelectItem T="EndpointType" Value="type">@type.Display()</MudSelectItem>
|
||||||
}
|
}
|
||||||
</MudSelect>
|
</MudSelect>
|
||||||
<MudTextField @bind-Value="_working.Name" Label="Name" Required="true" Class="mb-2" />
|
<MudTextField @bind-Value="_working.Name" Label="@S.Common_Name" Required="true" Class="mb-2" />
|
||||||
|
|
||||||
@if (_working.Type == EndpointType.HomeAssistant)
|
@if (_working.Type == EndpointType.HomeAssistant)
|
||||||
{
|
{
|
||||||
<MudTextField @bind-Value="_working.BaseUrl" Label="Base URL (e.g. http://homeassistant.local:8123)" Class="mb-2" />
|
<MudTextField @bind-Value="_working.BaseUrl" Label="@S.Connectors_BaseUrl" Class="mb-2" />
|
||||||
<MudTextField @bind-Value="_working.TokenEnv" Label="Token env-var name (e.g. HA_TOKEN)" Class="mb-2" />
|
<MudSwitch T="bool" @bind-Value="_working.UseDirectToken" Label="@S.Connectors_EnterTokenHere" Color="Color.Primary" Class="mb-1" />
|
||||||
<MudSwitch T="bool" @bind-Value="_working.UseWebSocket" Label="Real-time WebSocket push" Color="Color.Primary" Class="mb-1" />
|
@if (_working.UseDirectToken)
|
||||||
|
{
|
||||||
|
<MudTextField @bind-Value="_working.Token" InputType="InputType.Password" Class="mb-1"
|
||||||
|
Label="@(_working.HasStoredToken ? S.Connectors_TokenStored : S.Connectors_Token)" />
|
||||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2 d-block">
|
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2 d-block">
|
||||||
On: subscribe to state changes and ingest in real time. Off: REST poll on each source's interval.
|
@S.Connectors_EncryptedHint
|
||||||
</MudText>
|
</MudText>
|
||||||
<MudTextField @bind-Value="_working.TestEntityId" Label="Test entity id (optional, e.g. sensor.house_power)" Class="mb-2" />
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudTextField @bind-Value="_working.TokenEnv" Label="@S.Connectors_TokenEnv" Class="mb-1" />
|
||||||
|
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2 d-block">
|
||||||
|
@S.Connectors_TokenEnvHintPrefix <em>@S.Connectors_TokenEnvHintEmphasis</em>@S.Connectors_TokenEnvHintSuffix
|
||||||
|
</MudText>
|
||||||
|
}
|
||||||
|
<MudSwitch T="bool" @bind-Value="_working.UseWebSocket" Label="@S.Connectors_WebSocketPush" Color="Color.Primary" Class="mb-1" />
|
||||||
|
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2 d-block">
|
||||||
|
@S.Connectors_WebSocketHint
|
||||||
|
</MudText>
|
||||||
|
<MudTextField @bind-Value="_working.TestEntityId" Label="@S.Connectors_TestEntityId" Class="mb-2" />
|
||||||
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.NetworkCheck" OnClick="TestHaAsync" Disabled="_testing" Class="mb-2">
|
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.NetworkCheck" OnClick="TestHaAsync" Disabled="_testing" Class="mb-2">
|
||||||
Test connection
|
@S.Connectors_TestConnection
|
||||||
</MudButton>
|
</MudButton>
|
||||||
@if (_testing)
|
@if (_testing)
|
||||||
{
|
{
|
||||||
@@ -85,28 +132,65 @@ else
|
|||||||
}
|
}
|
||||||
@if (_testResult is not null)
|
@if (_testResult is not null)
|
||||||
{
|
{
|
||||||
<MudAlert Severity="@(_testResult.Ok ? Severity.Success : Severity.Error)" Dense="true" Class="mb-2">@_testResult.Message</MudAlert>
|
<MudAlert Severity="@(_testResult.Ok ? Severity.Success : Severity.Error)" Dense="true" Class="mb-2">@TestText(_testResult)</MudAlert>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<MudTextField @bind-Value="_working.Host" Label="Host" Class="mb-2" />
|
<MudTextField @bind-Value="_working.Host" Label="@S.Connectors_Host" Class="mb-2" />
|
||||||
<MudNumericField T="int" @bind-Value="_working.Port" Label="Port" Class="mb-2" />
|
<MudNumericField T="int" @bind-Value="_working.Port" Label="@S.Connectors_Port" Class="mb-2" />
|
||||||
<MudSwitch T="bool" @bind-Value="_working.Tls" Label="TLS" Color="Color.Primary" Class="mb-2" />
|
<MudSwitch T="bool" @bind-Value="_working.Tls" Label="@S.Connectors_Tls" Color="Color.Primary" Class="mb-2" />
|
||||||
<MudTextField @bind-Value="_working.UsernameEnv" Label="Username env-var name (optional)" Class="mb-2" />
|
<MudSwitch T="bool" @bind-Value="_working.UseDirectCredentials" Label="@S.Connectors_EnterCredentialsHere" Color="Color.Primary" Class="mb-1" />
|
||||||
<MudTextField @bind-Value="_working.PasswordEnv" Label="Password env-var name (optional)" Class="mb-2" />
|
@if (_working.UseDirectCredentials)
|
||||||
<MudTextField @bind-Value="_working.ExtraTopics" Label="Extra topics (comma-separated, optional)" Class="mb-2" />
|
{
|
||||||
|
<MudTextField @bind-Value="_working.Username" Label="@S.Connectors_UsernameOptional" Class="mb-1" />
|
||||||
|
<MudTextField @bind-Value="_working.Password" InputType="InputType.Password" Class="mb-1"
|
||||||
|
Label="@(_working.HasStoredPassword ? S.Connectors_PasswordStored : S.Connectors_PasswordOptional)" />
|
||||||
|
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2 d-block">
|
||||||
|
@S.Connectors_EncryptedHint
|
||||||
|
</MudText>
|
||||||
}
|
}
|
||||||
<MudSwitch T="bool" @bind-Value="_working.IsEnabled" Label="Enabled" Color="Color.Primary" />
|
else
|
||||||
|
{
|
||||||
|
<MudTextField @bind-Value="_working.UsernameEnv" Label="@S.Connectors_UsernameEnv" Class="mb-2" />
|
||||||
|
<MudTextField @bind-Value="_working.PasswordEnv" Label="@S.Connectors_PasswordEnv" Class="mb-2" />
|
||||||
|
}
|
||||||
|
<MudTextField @bind-Value="_working.ExtraTopics" Label="@S.Connectors_ExtraTopics" Class="mb-2" />
|
||||||
|
}
|
||||||
|
<MudSwitch T="bool" @bind-Value="_working.IsEnabled" Label="@S.Common_Enabled" Color="Color.Primary" />
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<MudButton OnClick="@(() => _editOpen = false)">Cancel</MudButton>
|
<MudButton OnClick="@(() => _editOpen = false)">@S.Common_Cancel</MudButton>
|
||||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">Save</MudButton>
|
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">@S.Common_Save</MudButton>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</MudDialog>
|
</MudDialog>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
|
/// <summary>Opens a new connector of this <see cref="EndpointType"/> once the page is interactive.</summary>
|
||||||
|
[SupplyParameterFromQuery(Name = "new")]
|
||||||
|
public string? NewParam { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Opens this connector for editing once the page is interactive.</summary>
|
||||||
|
[SupplyParameterFromQuery(Name = "edit")]
|
||||||
|
public int? EditParam { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The meter whose source dialog sent the user here; see <see cref="MeterLinks.NewConnector"/>.</summary>
|
||||||
|
[SupplyParameterFromQuery(Name = "meter")]
|
||||||
|
public int? MeterParam { get; set; }
|
||||||
|
|
||||||
|
[SupplyParameterFromQuery(Name = MeterLinks.ParamSource)]
|
||||||
|
public int? SourceParam { get; set; }
|
||||||
|
|
||||||
|
[SupplyParameterFromQuery(Name = MeterLinks.ParamSourceType)]
|
||||||
|
public string? SourceTypeParam { get; set; }
|
||||||
|
|
||||||
|
private const int UsersShown = 3;
|
||||||
|
|
||||||
private List<IngestionEndpoint>? _endpoints;
|
private List<IngestionEndpoint>? _endpoints;
|
||||||
|
private Dictionary<int, List<ConnectorUser>> _usage = [];
|
||||||
|
private ReturnTarget? _returnTo;
|
||||||
|
private (EndpointType? New, int? Edit)? _pendingOpen;
|
||||||
|
private bool _droppingOpen;
|
||||||
private bool _editOpen;
|
private bool _editOpen;
|
||||||
private bool _testing;
|
private bool _testing;
|
||||||
private HaTestResult? _testResult;
|
private HaTestResult? _testResult;
|
||||||
@@ -115,10 +199,97 @@ else
|
|||||||
|
|
||||||
protected override Task OnInitializedAsync() => LoadAsync();
|
protected override Task OnInitializedAsync() => LoadAsync();
|
||||||
|
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
if (MeterParam != _returnTo?.MeterId)
|
||||||
|
{
|
||||||
|
_returnTo = null;
|
||||||
|
if (MeterParam is { } meterId)
|
||||||
|
{
|
||||||
|
// Resolved against the database, so the way back names a meter that exists — and only
|
||||||
|
// ever leads to a meter page, whatever the query string says.
|
||||||
|
await using var db = await DbFactory.CreateDbContextAsync();
|
||||||
|
var name = await db.Meters.AsNoTracking().Where(m => m.Id == meterId).Select(m => m.Name).FirstOrDefaultAsync();
|
||||||
|
if (name is not null)
|
||||||
|
{
|
||||||
|
_returnTo = new ReturnTarget(meterId, name, SourceParam, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_returnTo is not null)
|
||||||
|
{
|
||||||
|
_returnTo = _returnTo with
|
||||||
|
{
|
||||||
|
SourceId = SourceParam,
|
||||||
|
SourceType = Enum.TryParse<SourceType>(SourceTypeParam, ignoreCase: true, out var sourceType) ? sourceType : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(NewParam) || EditParam is not null)
|
||||||
|
{
|
||||||
|
_pendingOpen = (Enum.TryParse<EndpointType>(NewParam, ignoreCase: true, out var type) ? type : null, EditParam);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_droppingOpen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens a deep-linked connector dialog. The request is dropped from the address first and the dialog
|
||||||
|
/// opened once that navigation has come back — the order the meter page uses, for the same reason: a
|
||||||
|
/// circuit's first location change dismisses any dialog already open.
|
||||||
|
/// </summary>
|
||||||
|
protected override void OnAfterRender(bool firstRender)
|
||||||
|
{
|
||||||
|
if (_pendingOpen is not { } open || _endpoints is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(NewParam) || EditParam is not null)
|
||||||
|
{
|
||||||
|
if (!_droppingOpen)
|
||||||
|
{
|
||||||
|
_droppingOpen = true;
|
||||||
|
Nav.NavigateTo(Nav.GetUriWithQueryParameters(new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["new"] = null,
|
||||||
|
["edit"] = null,
|
||||||
|
}), replace: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_pendingOpen = null;
|
||||||
|
if (open.Edit is { } editId && _endpoints.FirstOrDefault(e => e.Id == editId) is { } endpoint)
|
||||||
|
{
|
||||||
|
OpenEdit(endpoint);
|
||||||
|
}
|
||||||
|
else if (open.New is { } newType)
|
||||||
|
{
|
||||||
|
OpenEdit(null);
|
||||||
|
_working.Type = newType;
|
||||||
|
}
|
||||||
|
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
private async Task LoadAsync()
|
private async Task LoadAsync()
|
||||||
{
|
{
|
||||||
await using var db = await DbFactory.CreateDbContextAsync();
|
await using var db = await DbFactory.CreateDbContextAsync();
|
||||||
_endpoints = await db.IngestionEndpoints.AsNoTracking().OrderBy(e => e.Name).ToListAsync();
|
_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)
|
private void OpenEdit(IngestionEndpoint? endpoint)
|
||||||
@@ -135,6 +306,12 @@ else
|
|||||||
{
|
{
|
||||||
Id = endpoint.Id, Type = endpoint.Type, Name = endpoint.Name, IsEnabled = endpoint.IsEnabled,
|
Id = endpoint.Id, Type = endpoint.Type, Name = endpoint.Name, IsEnabled = endpoint.IsEnabled,
|
||||||
BaseUrl = ha.BaseUrl, TokenEnv = ha.TokenEnv, UseWebSocket = ha.UseWebSocket,
|
BaseUrl = ha.BaseUrl, TokenEnv = ha.TokenEnv, UseWebSocket = ha.UseWebSocket,
|
||||||
|
// Carry the ciphertext through untouched and never send the secret to the browser:
|
||||||
|
// the field stays blank and only a typed value replaces what is stored.
|
||||||
|
TokenEnc = ha.TokenEnc,
|
||||||
|
UseDirectToken = !string.IsNullOrWhiteSpace(ha.TokenEnc),
|
||||||
|
// The host the stored token was saved against; a stored token is never sent anywhere else.
|
||||||
|
SavedBaseUrl = ha.BaseUrl,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -145,6 +322,9 @@ else
|
|||||||
Id = endpoint.Id, Type = endpoint.Type, Name = endpoint.Name, IsEnabled = endpoint.IsEnabled,
|
Id = endpoint.Id, Type = endpoint.Type, Name = endpoint.Name, IsEnabled = endpoint.IsEnabled,
|
||||||
Host = mqtt.Host, Port = mqtt.Port, Tls = mqtt.Tls,
|
Host = mqtt.Host, Port = mqtt.Port, Tls = mqtt.Tls,
|
||||||
UsernameEnv = mqtt.UsernameEnv, PasswordEnv = mqtt.PasswordEnv,
|
UsernameEnv = mqtt.UsernameEnv, PasswordEnv = mqtt.PasswordEnv,
|
||||||
|
Username = mqtt.Username, PasswordEnc = mqtt.PasswordEnc,
|
||||||
|
UseDirectCredentials =
|
||||||
|
!string.IsNullOrWhiteSpace(mqtt.Username) || !string.IsNullOrWhiteSpace(mqtt.PasswordEnc),
|
||||||
ExtraTopics = string.Join(", ", mqtt.ExtraTopics),
|
ExtraTopics = string.Join(", ", mqtt.ExtraTopics),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -157,7 +337,45 @@ else
|
|||||||
_testResult = null;
|
_testResult = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_testResult = await HaTester.TestAsync(_working.BaseUrl, _working.TokenEnv, _working.TestEntityId);
|
// Test what the connector would actually use — including a token typed but not yet
|
||||||
|
// saved, so a bad token is caught before it is stored.
|
||||||
|
if (_working.UseDirectToken)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(_working.Token))
|
||||||
|
{
|
||||||
|
_testResult = await HaTester.TestAsync(_working.BaseUrl, _working.Token, _working.TestEntityId);
|
||||||
|
}
|
||||||
|
else if (!SameOrigin(_working.BaseUrl, _working.SavedBaseUrl))
|
||||||
|
{
|
||||||
|
// Storing the token encrypted means the UI can decrypt something the operator
|
||||||
|
// can no longer read. Sending it to a Base URL edited in this dialog would turn
|
||||||
|
// "Test connection" into an exfiltration primitive — point it at any host and the
|
||||||
|
// token arrives as a Bearer header. A stored secret only ever goes to the origin
|
||||||
|
// it was saved for; testing elsewhere means typing the token again.
|
||||||
|
_testResult = new HaTestResult(false, S.Connectors_TestBaseUrlChanged);
|
||||||
|
}
|
||||||
|
else if (Secrets.TryUnprotect(_working.TokenEnc, out var stored) && stored is { Length: > 0 })
|
||||||
|
{
|
||||||
|
_testResult = await HaTester.TestAsync(_working.BaseUrl, stored, _working.TestEntityId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_testResult = new HaTestResult(false, S.Connectors_TestNoToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (string.IsNullOrWhiteSpace(_working.TokenEnv))
|
||||||
|
{
|
||||||
|
_testResult = new HaTestResult(false, S.Connectors_TestNoTokenEnv);
|
||||||
|
}
|
||||||
|
else if (Environment.GetEnvironmentVariable(_working.TokenEnv) is not { Length: > 0 } envToken)
|
||||||
|
{
|
||||||
|
_testResult = new HaTestResult(false,
|
||||||
|
Loc.F(S.Connectors_TestEnvVarMissing, _working.TokenEnv));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_testResult = await HaTester.TestAsync(_working.BaseUrl, envToken, _working.TestEntityId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -169,42 +387,76 @@ else
|
|||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(_working.Name))
|
if (string.IsNullOrWhiteSpace(_working.Name))
|
||||||
{
|
{
|
||||||
Snackbar.Add("Name is required.", Severity.Warning);
|
Snackbar.Add(S.Common_NameRequired, Severity.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_working.Type == EndpointType.HomeAssistant
|
||||||
|
&& _working.UseDirectToken
|
||||||
|
&& string.IsNullOrWhiteSpace(_working.Token)
|
||||||
|
&& !_working.HasStoredToken)
|
||||||
|
{
|
||||||
|
Snackbar.Add(S.Connectors_TokenRequired, Severity.Warning);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var config = _working.Type == EndpointType.HomeAssistant
|
var config = _working.Type == EndpointType.HomeAssistant
|
||||||
? new HaEndpointConfig { BaseUrl = Trim(_working.BaseUrl), TokenEnv = Trim(_working.TokenEnv), UseWebSocket = _working.UseWebSocket }.ToJson()
|
? new HaEndpointConfig
|
||||||
|
{
|
||||||
|
BaseUrl = Trim(_working.BaseUrl),
|
||||||
|
UseWebSocket = _working.UseWebSocket,
|
||||||
|
// Exactly one storage form survives a save: switching modes clears the other, so a
|
||||||
|
// stale token cannot linger and silently win at resolution time.
|
||||||
|
TokenEnv = _working.UseDirectToken ? null : Trim(_working.TokenEnv),
|
||||||
|
TokenEnc = _working.UseDirectToken ? ProtectOrKeep(_working.Token, _working.TokenEnc) : null,
|
||||||
|
}.ToJson()
|
||||||
: new EndpointConfig
|
: new EndpointConfig
|
||||||
{
|
{
|
||||||
Host = string.IsNullOrWhiteSpace(_working.Host) ? "localhost" : _working.Host.Trim(),
|
Host = string.IsNullOrWhiteSpace(_working.Host) ? "localhost" : _working.Host.Trim(),
|
||||||
Port = _working.Port,
|
Port = _working.Port,
|
||||||
Tls = _working.Tls,
|
Tls = _working.Tls,
|
||||||
UsernameEnv = Trim(_working.UsernameEnv),
|
UsernameEnv = _working.UseDirectCredentials ? null : Trim(_working.UsernameEnv),
|
||||||
PasswordEnv = Trim(_working.PasswordEnv),
|
PasswordEnv = _working.UseDirectCredentials ? null : Trim(_working.PasswordEnv),
|
||||||
|
Username = _working.UseDirectCredentials ? Trim(_working.Username) : null,
|
||||||
|
PasswordEnc = _working.UseDirectCredentials
|
||||||
|
? ProtectOrKeep(_working.Password, _working.PasswordEnc)
|
||||||
|
: null,
|
||||||
ExtraTopics = SplitTopics(_working.ExtraTopics),
|
ExtraTopics = SplitTopics(_working.ExtraTopics),
|
||||||
}.ToJson();
|
}.ToJson();
|
||||||
|
|
||||||
await using var db = await DbFactory.CreateDbContextAsync();
|
await using var db = await DbFactory.CreateDbContextAsync();
|
||||||
|
IngestionEndpoint saved;
|
||||||
if (_working.Id == 0)
|
if (_working.Id == 0)
|
||||||
{
|
{
|
||||||
db.IngestionEndpoints.Add(new IngestionEndpoint
|
saved = new IngestionEndpoint
|
||||||
{
|
{
|
||||||
Type = _working.Type, Name = _working.Name.Trim(), Config = config, IsEnabled = _working.IsEnabled,
|
Type = _working.Type, Name = _working.Name.Trim(), Config = config, IsEnabled = _working.IsEnabled,
|
||||||
});
|
};
|
||||||
|
db.IngestionEndpoints.Add(saved);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var existing = await db.IngestionEndpoints.FirstAsync(e => e.Id == _working.Id);
|
saved = await db.IngestionEndpoints.FirstAsync(e => e.Id == _working.Id);
|
||||||
existing.Type = _working.Type;
|
saved.Type = _working.Type;
|
||||||
existing.Name = _working.Name.Trim();
|
saved.Name = _working.Name.Trim();
|
||||||
existing.Config = config;
|
saved.Config = config;
|
||||||
existing.IsEnabled = _working.IsEnabled;
|
saved.IsEnabled = _working.IsEnabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
_editOpen = false;
|
_editOpen = false;
|
||||||
Snackbar.Add("Saved.", Severity.Success);
|
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();
|
await LoadAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,22 +464,51 @@ else
|
|||||||
{
|
{
|
||||||
await using var db = await DbFactory.CreateDbContextAsync();
|
await using var db = await DbFactory.CreateDbContextAsync();
|
||||||
var sourceCount = await db.MeterSources.CountAsync(s => s.EndpointId == endpoint.Id);
|
var sourceCount = await db.MeterSources.CountAsync(s => s.EndpointId == endpoint.Id);
|
||||||
var note = sourceCount > 0 ? $" {sourceCount} source(s) reference it and will be unlinked." : "";
|
// Routing is endpoint-scoped, so "unlinked" now means those sources stop ingesting entirely
|
||||||
if (!await Confirm.DeleteAsync(DialogService, "Delete connector", $"Delete '{endpoint.Name}'?{note}"))
|
// rather than falling back to any broker. Say so plainly.
|
||||||
|
var note = sourceCount > 0
|
||||||
|
? " " + Loc.F(S.Connectors_DeleteInUseNote, sourceCount)
|
||||||
|
: "";
|
||||||
|
if (!await Confirm.DeleteAsync(
|
||||||
|
DialogService, S.Connectors_DeleteTitle, Loc.F(S.Connectors_DeleteBody, endpoint.Name) + note))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.IngestionEndpoints.Where(e => e.Id == endpoint.Id).ExecuteDeleteAsync();
|
await db.IngestionEndpoints.Where(e => e.Id == endpoint.Id).ExecuteDeleteAsync();
|
||||||
Snackbar.Add("Deleted.", Severity.Success);
|
Snackbar.Add(S.Common_Deleted, Severity.Success);
|
||||||
await LoadAsync();
|
await LoadAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string? Trim(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
private static string? Trim(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether two URLs address the same host. Compares scheme, host and port rather than the raw
|
||||||
|
/// string, so a trailing slash or a path tweak does not force the token to be re-typed. Fails
|
||||||
|
/// closed: anything unparsable counts as a different origin.
|
||||||
|
/// </summary>
|
||||||
|
private static bool SameOrigin(string? a, string? b) =>
|
||||||
|
Uri.TryCreate(a, UriKind.Absolute, out var left)
|
||||||
|
&& Uri.TryCreate(b, UriKind.Absolute, out var right)
|
||||||
|
&& string.Equals(left.Scheme, right.Scheme, StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& string.Equals(left.Host, right.Host, StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& left.Port == right.Port;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Encrypts a newly typed secret, or keeps the stored ciphertext when the field was left blank.
|
||||||
|
/// The plaintext is never sent to the browser, so blank means "unchanged", not "cleared".
|
||||||
|
/// </summary>
|
||||||
|
private string? ProtectOrKeep(string? typed, string? existingCiphertext) =>
|
||||||
|
string.IsNullOrWhiteSpace(typed) ? existingCiphertext : Secrets.Protect(typed);
|
||||||
|
|
||||||
private static IReadOnlyList<string> SplitTopics(string? csv) =>
|
private static IReadOnlyList<string> SplitTopics(string? csv) =>
|
||||||
string.IsNullOrWhiteSpace(csv) ? [] : [.. csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)];
|
string.IsNullOrWhiteSpace(csv) ? [] : [.. csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)];
|
||||||
|
|
||||||
|
private sealed record ConnectorUser(int MeterId, string MeterName);
|
||||||
|
|
||||||
|
/// <summary>The meter source a user came here to set up a connector for.</summary>
|
||||||
|
private sealed record ReturnTarget(int MeterId, string MeterName, int? SourceId, SourceType? SourceType);
|
||||||
|
|
||||||
private sealed class EditModel
|
private sealed class EditModel
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
@@ -241,6 +522,20 @@ else
|
|||||||
public bool UseWebSocket { get; set; }
|
public bool UseWebSocket { get; set; }
|
||||||
public string? TestEntityId { get; set; }
|
public string? TestEntityId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>True to store the token here (encrypted); false to name an env var.</summary>
|
||||||
|
public bool UseDirectToken { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Typed token. Always blank on load — a stored secret is never sent to the browser.</summary>
|
||||||
|
public string? Token { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Stored ciphertext, round-tripped so leaving <see cref="Token"/> blank keeps it.</summary>
|
||||||
|
public string? TokenEnc { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Base URL as saved, so an edited one can be told from the token's own host.</summary>
|
||||||
|
public string? SavedBaseUrl { get; set; }
|
||||||
|
|
||||||
|
public bool HasStoredToken => !string.IsNullOrWhiteSpace(TokenEnc);
|
||||||
|
|
||||||
// MQTT broker
|
// MQTT broker
|
||||||
public string? Host { get; set; } = "localhost";
|
public string? Host { get; set; } = "localhost";
|
||||||
public int Port { get; set; } = 1883;
|
public int Port { get; set; } = 1883;
|
||||||
@@ -248,5 +543,35 @@ else
|
|||||||
public string? UsernameEnv { get; set; }
|
public string? UsernameEnv { get; set; }
|
||||||
public string? PasswordEnv { get; set; }
|
public string? PasswordEnv { get; set; }
|
||||||
public string? ExtraTopics { get; set; }
|
public string? ExtraTopics { get; set; }
|
||||||
|
|
||||||
|
public bool UseDirectCredentials { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Username entered directly — not a secret, so shown when editing.</summary>
|
||||||
|
public string? Username { get; set; }
|
||||||
|
|
||||||
|
public string? Password { get; set; }
|
||||||
|
|
||||||
|
public string? PasswordEnc { get; set; }
|
||||||
|
|
||||||
|
public bool HasStoredPassword => !string.IsNullOrWhiteSpace(PasswordEnc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HaTestResult.Message stays English for the log; the reader gets the verdict in their own
|
||||||
|
// language. Anything the connector page decided for itself (Precondition) already carries its
|
||||||
|
// own localized wording, and HA's own diagnostics — an HTTP status, an exception — are passed
|
||||||
|
// through untranslated because they are not ours to reword.
|
||||||
|
private static string TestText(HaTestResult result) => result.Outcome switch
|
||||||
|
{
|
||||||
|
HaTestOutcome.Connected => S.Connectors_TestConnected,
|
||||||
|
HaTestOutcome.ConnectedWithValue => Loc.F(
|
||||||
|
S.Connectors_TestConnectedValue,
|
||||||
|
result.EntityId ?? string.Empty,
|
||||||
|
result.SampleValue is { } value ? Format.Number(value, 2) : string.Empty),
|
||||||
|
HaTestOutcome.BaseUrlMissing => S.Connectors_TestBaseUrlRequired,
|
||||||
|
HaTestOutcome.TokenMissing => S.Connectors_TestTokenMissing,
|
||||||
|
HaTestOutcome.HttpError => Loc.F(S.Connectors_TestHttpError, result.Detail ?? string.Empty),
|
||||||
|
HaTestOutcome.NoNumericState => Loc.F(S.Connectors_TestNoNumericState, result.EntityId ?? string.Empty),
|
||||||
|
HaTestOutcome.RequestFailed => Loc.F(S.Connectors_TestFailed, result.Detail ?? string.Empty),
|
||||||
|
_ => result.Message,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,17 +2,21 @@
|
|||||||
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
||||||
@inject ISnackbar Snackbar
|
@inject ISnackbar Snackbar
|
||||||
@inject IDialogService DialogService
|
@inject IDialogService DialogService
|
||||||
|
@inject NavState NavState
|
||||||
@using Microsoft.EntityFrameworkCore
|
@using Microsoft.EntityFrameworkCore
|
||||||
@using MudBlazor
|
@using MudBlazor
|
||||||
|
|
||||||
<PageTitle>MeterVault — Energy types</PageTitle>
|
<PageTitle>MeterVault — @S.EnergyTypes_Title</PageTitle>
|
||||||
|
|
||||||
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
@* Configuration, not analysis: the menu's "Energy types" group opens each type's analysis, this page edits what a type
|
||||||
<MudText Typo="Typo.h4">Energy types</MudText>
|
is. The title and the line below say which one the reader is on. *@
|
||||||
|
<div class="d-flex align-center justify-space-between mb-1 flex-wrap" style="gap:1rem">
|
||||||
|
<MudText Typo="Typo.h4" HtmlTag="h1">@S.EnergyTypes_Title</MudText>
|
||||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
|
||||||
Add energy type
|
@S.EnergyTypes_Add
|
||||||
</MudButton>
|
</MudButton>
|
||||||
</div>
|
</div>
|
||||||
|
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-4">@S.EnergyTypes_Description</MudText>
|
||||||
|
|
||||||
@if (_types is null)
|
@if (_types is null)
|
||||||
{
|
{
|
||||||
@@ -22,24 +26,24 @@ else
|
|||||||
{
|
{
|
||||||
<MudTable Items="_types" Dense="true" Hover="true" Elevation="2">
|
<MudTable Items="_types" Dense="true" Hover="true" Elevation="2">
|
||||||
<HeaderContent>
|
<HeaderContent>
|
||||||
<MudTh>Key</MudTh>
|
<MudTh>@S.EnergyTypes_Key</MudTh>
|
||||||
<MudTh>Display name</MudTh>
|
<MudTh>@S.EnergyTypes_DisplayName</MudTh>
|
||||||
<MudTh>Base unit</MudTh>
|
<MudTh>@S.EnergyTypes_BaseUnit</MudTh>
|
||||||
<MudTh>Default mode</MudTh>
|
<MudTh>@S.EnergyTypes_DefaultMode</MudTh>
|
||||||
<MudTh Style="text-align:right">Actions</MudTh>
|
<MudTh Style="text-align:right">@S.Common_Actions</MudTh>
|
||||||
</HeaderContent>
|
</HeaderContent>
|
||||||
<RowTemplate>
|
<RowTemplate>
|
||||||
<MudTd DataLabel="Key">@context.Key</MudTd>
|
<MudTd DataLabel="@S.EnergyTypes_Key">@context.Key</MudTd>
|
||||||
<MudTd DataLabel="Display name">
|
<MudTd DataLabel="@S.EnergyTypes_DisplayName">
|
||||||
@if (!string.IsNullOrWhiteSpace(context.ColorHex))
|
@if (!string.IsNullOrWhiteSpace(context.ColorHex))
|
||||||
{
|
{
|
||||||
<span style="display:inline-block;width:.8em;height:.8em;border-radius:2px;margin-right:.4em;background:@context.ColorHex"></span>
|
<span style="display:inline-block;width:.8em;height:.8em;border-radius:2px;margin-right:.4em;background:@context.ColorHex"></span>
|
||||||
}
|
}
|
||||||
@context.DisplayName
|
@context.DisplayName
|
||||||
</MudTd>
|
</MudTd>
|
||||||
<MudTd DataLabel="Base unit">@context.BaseUnit</MudTd>
|
<MudTd DataLabel="@S.EnergyTypes_BaseUnit">@context.BaseUnit</MudTd>
|
||||||
<MudTd DataLabel="Default mode">@context.DefaultMode</MudTd>
|
<MudTd DataLabel="@S.EnergyTypes_DefaultMode">@context.DefaultMode.Display()</MudTd>
|
||||||
<MudTd DataLabel="Actions" Style="text-align:right">
|
<MudTd DataLabel="@S.Common_Actions" Style="text-align:right">
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => OpenEdit(context))" />
|
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => OpenEdit(context))" />
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(context))" />
|
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(context))" />
|
||||||
</MudTd>
|
</MudTd>
|
||||||
@@ -49,24 +53,24 @@ else
|
|||||||
|
|
||||||
<MudDialog @bind-Visible="_editOpen" Options="_dialogOptions">
|
<MudDialog @bind-Visible="_editOpen" Options="_dialogOptions">
|
||||||
<TitleContent>
|
<TitleContent>
|
||||||
<MudText Typo="Typo.h6">@(_working.Id == 0 ? "New energy type" : $"Edit {_working.DisplayName}")</MudText>
|
<MudText Typo="Typo.h6">@(_working.Id == 0 ? S.EnergyTypes_NewTitle : Loc.F(S.EnergyTypes_EditTitle, _working.DisplayName))</MudText>
|
||||||
</TitleContent>
|
</TitleContent>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<MudTextField @bind-Value="_working.Key" Label="Key (stable machine key, e.g. electricity)" Required="true" Class="mb-2" />
|
<MudTextField @bind-Value="_working.Key" Label="@S.EnergyTypes_KeyLabel" Required="true" Class="mb-2" />
|
||||||
<MudTextField @bind-Value="_working.DisplayName" Label="Display name" Required="true" Class="mb-2" />
|
<MudTextField @bind-Value="_working.DisplayName" Label="@S.EnergyTypes_DisplayName" Required="true" Class="mb-2" />
|
||||||
<MudTextField @bind-Value="_working.BaseUnit" Label="Base unit (kWh, m3, L, h)" Required="true" Class="mb-2" />
|
<MudTextField @bind-Value="_working.BaseUnit" Label="@S.EnergyTypes_BaseUnitLabel" Required="true" Class="mb-2" />
|
||||||
<MudSelect T="MeterMode" @bind-Value="_working.Mode" Label="Default mode" Class="mb-2">
|
<MudSelect T="MeterMode" @bind-Value="_working.Mode" Label="@S.EnergyTypes_DefaultMode" Class="mb-2">
|
||||||
@foreach (var mode in Enum.GetValues<MeterMode>())
|
@foreach (var mode in Enum.GetValues<MeterMode>())
|
||||||
{
|
{
|
||||||
<MudSelectItem T="MeterMode" Value="mode">@mode</MudSelectItem>
|
<MudSelectItem T="MeterMode" Value="mode">@mode.Display()</MudSelectItem>
|
||||||
}
|
}
|
||||||
</MudSelect>
|
</MudSelect>
|
||||||
<MudTextField @bind-Value="_working.Icon" Label="Icon (optional)" Class="mb-2" />
|
<MudTextField @bind-Value="_working.Icon" Label="@S.EnergyTypes_IconLabel" Class="mb-2" />
|
||||||
<MudTextField @bind-Value="_working.ColorHex" Label="Color hex (optional, e.g. #4caf50)" />
|
<MudTextField @bind-Value="_working.ColorHex" Label="@S.EnergyTypes_ColorLabel" />
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<MudButton OnClick="@(() => _editOpen = false)">Cancel</MudButton>
|
<MudButton OnClick="@(() => _editOpen = false)">@S.Common_Cancel</MudButton>
|
||||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">Save</MudButton>
|
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">@S.Common_Save</MudButton>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</MudDialog>
|
</MudDialog>
|
||||||
|
|
||||||
@@ -105,14 +109,14 @@ else
|
|||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(_working.Key) || string.IsNullOrWhiteSpace(_working.DisplayName) || string.IsNullOrWhiteSpace(_working.BaseUnit))
|
if (string.IsNullOrWhiteSpace(_working.Key) || string.IsNullOrWhiteSpace(_working.DisplayName) || string.IsNullOrWhiteSpace(_working.BaseUnit))
|
||||||
{
|
{
|
||||||
Snackbar.Add("Key, display name and base unit are required.", Severity.Warning);
|
Snackbar.Add(S.EnergyTypes_RequiredFields, Severity.Warning);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await using var db = await DbFactory.CreateDbContextAsync();
|
await using var db = await DbFactory.CreateDbContextAsync();
|
||||||
if (await db.EnergyTypes.AnyAsync(t => t.Key == _working.Key && t.Id != _working.Id))
|
if (await db.EnergyTypes.AnyAsync(t => t.Key == _working.Key && t.Id != _working.Id))
|
||||||
{
|
{
|
||||||
Snackbar.Add($"Key '{_working.Key}' is already in use.", Severity.Error);
|
Snackbar.Add(Loc.F(S.EnergyTypes_KeyInUse, _working.Key), Severity.Error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,7 +145,8 @@ else
|
|||||||
|
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
_editOpen = false;
|
_editOpen = false;
|
||||||
Snackbar.Add("Saved.", Severity.Success);
|
Snackbar.Add(S.Common_Saved, Severity.Success);
|
||||||
|
NavState.NotifyEnergyTypesChanged();
|
||||||
await LoadAsync();
|
await LoadAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,21 +156,20 @@ else
|
|||||||
var meterCount = await db.Meters.CountAsync(m => m.EnergyTypeId == type.Id);
|
var meterCount = await db.Meters.CountAsync(m => m.EnergyTypeId == type.Id);
|
||||||
if (meterCount > 0)
|
if (meterCount > 0)
|
||||||
{
|
{
|
||||||
Snackbar.Add($"Cannot delete '{type.DisplayName}': {meterCount} meter(s) still use it.", Severity.Error);
|
Snackbar.Add(Loc.F(S.EnergyTypes_DeleteBlocked, type.DisplayName, meterCount), Severity.Error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!await Confirm.DeleteAsync(DialogService, "Delete energy type", $"Delete '{type.DisplayName}'? This cannot be undone."))
|
if (!await Confirm.DeleteAsync(DialogService, S.EnergyTypes_DeleteTitle, Loc.F(S.EnergyTypes_DeleteBody, type.DisplayName)))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var target = await db.EnergyTypes.FirstOrDefaultAsync(t => t.Id == type.Id);
|
// Its type-scoped prices go with it: tariff.scope_id has no foreign key.
|
||||||
if (target is not null)
|
if (await MeterVault.Infrastructure.Persistence.EntityDeletion.DeleteEnergyTypeAsync(db, type.Id))
|
||||||
{
|
{
|
||||||
db.EnergyTypes.Remove(target);
|
Snackbar.Add(S.Common_Deleted, Severity.Success);
|
||||||
await db.SaveChangesAsync();
|
NavState.NotifyEnergyTypesChanged();
|
||||||
Snackbar.Add("Deleted.", Severity.Success);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await LoadAsync();
|
await LoadAsync();
|
||||||
|
|||||||
@@ -1,30 +1,43 @@
|
|||||||
@page "/admin/settings"
|
@page "/admin/settings"
|
||||||
|
@using System.Text.Json
|
||||||
|
@using Microsoft.EntityFrameworkCore
|
||||||
|
@using MeterVault.Infrastructure.Analysis
|
||||||
|
@using MeterVault.Infrastructure.Normalization
|
||||||
|
@using MeterVault.Infrastructure.Persistence
|
||||||
@inject Microsoft.Extensions.Options.IOptions<MeterVault.Infrastructure.Options.MeterVaultOptions> Options
|
@inject Microsoft.Extensions.Options.IOptions<MeterVault.Infrastructure.Options.MeterVaultOptions> Options
|
||||||
|
@inject IDbContextFactory<MeterVaultDbContext> DbFactory
|
||||||
|
@inject AnalysisReader Reader
|
||||||
|
@inject InstanceCurrency Currency
|
||||||
|
@inject ILogger<Settings> Logger
|
||||||
@using MudBlazor
|
@using MudBlazor
|
||||||
|
|
||||||
<PageTitle>MeterVault — Settings</PageTitle>
|
<PageHeader Title="@S.Nav_Settings" Class="mb-2" />
|
||||||
|
|
||||||
<MudText Typo="Typo.h4" Class="mb-2">Settings</MudText>
|
|
||||||
<MudAlert Severity="Severity.Info" Class="mb-4" Dense="true">
|
<MudAlert Severity="Severity.Info" Class="mb-4" Dense="true">
|
||||||
These are the <b>effective</b> settings the running instance is using. They are configured via environment
|
@S.Settings_EffectiveLead <b>@S.Settings_EffectiveEmphasis</b> @S.Settings_EffectiveRest
|
||||||
variables (<code>MeterVault__Key</code> / <code>Section__Key</code>) or Docker/compose, not stored in the
|
(<code>MeterVault__Key</code> / <code>Section__Key</code>) @S.Settings_EffectiveTail
|
||||||
database — so config stays reproducible and secrets never land in the DB. Change them in your compose/env and restart.
|
|
||||||
</MudAlert>
|
</MudAlert>
|
||||||
|
|
||||||
<MudGrid>
|
<MudGrid>
|
||||||
<MudItem xs="12" md="6">
|
<MudItem xs="12" md="6">
|
||||||
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
|
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
|
||||||
<MudText Typo="Typo.h6" Class="mb-2">Locale & time</MudText>
|
<MudText Typo="Typo.h6" Class="mb-2">@S.Settings_LocaleAndTime</MudText>
|
||||||
<MudSimpleTable Dense="true">
|
<MudSimpleTable Dense="true">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr><td>Timezone</td><td style="text-align:right"><code>@_o.TimeZone</code></td></tr>
|
<tr><td>@S.Settings_Timezone</td><td style="text-align:right"><code>@Reader.Zone.Id</code></td></tr>
|
||||||
<tr><td>Locale</td><td style="text-align:right"><code>@_o.Locale</code></td></tr>
|
<tr><td>@S.Settings_Locale</td><td style="text-align:right"><code>@_o.Locale</code></td></tr>
|
||||||
<tr><td>Currency</td><td style="text-align:right"><code>@_o.Currency</code></td></tr>
|
<tr><td>@S.Common_Currency</td><td style="text-align:right"><code>@Currency.Code</code> (@Currency.Symbol)</td></tr>
|
||||||
<tr><td>Raw-reading retention</td><td style="text-align:right">@_o.RawRetentionDays days</td></tr>
|
<tr>
|
||||||
|
<td>@S.Settings_RawRetention</td>
|
||||||
|
<td style="text-align:right">
|
||||||
|
@* D-57: every recompute rebuilds a meter from its readings, so deleting old ones would erase history. *@
|
||||||
|
<MudChip T="string" Size="Size.Small" Color="Color.Default" Variant="Variant.Outlined">@S.Settings_RetentionNotEnforced</MudChip>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</MudSimpleTable>
|
</MudSimpleTable>
|
||||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mt-2">
|
<MudText Typo="Typo.caption" Class="mv-muted d-block mt-2">@Loc.F(S.Settings_RetentionReason, _o.RawRetentionDays)</MudText>
|
||||||
Env keys: <code>MeterVault__TimeZone</code>, <code>MeterVault__Locale</code>,
|
<MudText Typo="Typo.caption" Class="mv-muted mt-2">
|
||||||
|
@S.Settings_EnvKeysLabel <code>MeterVault__TimeZone</code>, <code>MeterVault__Locale</code>,
|
||||||
<code>MeterVault__Currency</code>, <code>MeterVault__RawRetentionDays</code>.
|
<code>MeterVault__Currency</code>, <code>MeterVault__RawRetentionDays</code>.
|
||||||
</MudText>
|
</MudText>
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
@@ -32,7 +45,7 @@
|
|||||||
|
|
||||||
<MudItem xs="12" md="6">
|
<MudItem xs="12" md="6">
|
||||||
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
|
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
|
||||||
<MudText Typo="Typo.h6" Class="mb-2">Access & ingestion</MudText>
|
<MudText Typo="Typo.h6" Class="mb-2">@S.Settings_AccessAndIngestion</MudText>
|
||||||
<MudSimpleTable Dense="true">
|
<MudSimpleTable Dense="true">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -40,33 +53,154 @@
|
|||||||
<td style="text-align:right">
|
<td style="text-align:right">
|
||||||
@if (_o.ApiKeys.Count > 0)
|
@if (_o.ApiKeys.Count > 0)
|
||||||
{
|
{
|
||||||
<MudChip T="string" Size="Size.Small" Color="Color.Success">@_o.ApiKeys.Count key(s) configured</MudChip>
|
<MudChip T="string" Size="Size.Small" Color="Color.Success">@Loc.F(S.Settings_ApiKeysConfigured, _o.ApiKeys.Count)</MudChip>
|
||||||
}
|
}
|
||||||
else if (_o.AllowAnonymousApi)
|
else if (_o.AllowAnonymousApi)
|
||||||
{
|
{
|
||||||
<MudChip T="string" Size="Size.Small" Color="Color.Warning">open (anonymous)</MudChip>
|
<MudChip T="string" Size="Size.Small" Color="Color.Warning">@S.Settings_ApiOpen</MudChip>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<MudChip T="string" Size="Size.Small" Color="Color.Default">closed (401)</MudChip>
|
<MudChip T="string" Size="Size.Small" Color="Color.Default">@S.Settings_ApiClosed</MudChip>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr><td>Reverse-proxy trust</td><td style="text-align:right">@(_o.ReverseProxyTrust ? "on" : "off")</td></tr>
|
<tr><td>@S.Settings_ReverseProxyTrust</td><td style="text-align:right">@(_o.ReverseProxyTrust ? S.Settings_On : S.Settings_Off)</td></tr>
|
||||||
<tr><td>Live ingestion workers</td><td style="text-align:right">@(_o.EnableLiveIngestion ? "on" : "off")</td></tr>
|
<tr><td>@S.Settings_LiveIngestionWorkers</td><td style="text-align:right">@(_o.EnableLiveIngestion ? S.Settings_On : S.Settings_Off)</td></tr>
|
||||||
<tr><td>Seed reference data on start</td><td style="text-align:right">@(_o.SeedReferenceData ? "on" : "off")</td></tr>
|
<tr><td>@S.Settings_SeedReferenceData</td><td style="text-align:right">@(_o.SeedReferenceData ? S.Settings_On : S.Settings_Off)</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</MudSimpleTable>
|
</MudSimpleTable>
|
||||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mt-2">
|
<MudText Typo="Typo.caption" Class="mv-muted mt-2">
|
||||||
Set API keys with <code>MeterVault__ApiKeys__0</code>. Keys themselves are never shown here.
|
@S.Settings_ApiKeysHintBefore <code>MeterVault__ApiKeys__0</code>. @S.Settings_ApiKeysHintAfter
|
||||||
API docs at <MudLink Href="/swagger" Target="_blank">/swagger</MudLink>.
|
@S.Settings_ApiDocsLabel <MudLink Href="/swagger" Target="_blank">/swagger</MudLink>.
|
||||||
</MudText>
|
</MudText>
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
|
|
||||||
|
<MudItem xs="12">
|
||||||
|
<MudPaper Class="pa-4" Elevation="2">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">@S.Settings_AnalysisData</MudText>
|
||||||
|
@if (_analysis is { } state)
|
||||||
|
{
|
||||||
|
<MudSimpleTable Dense="true">
|
||||||
|
<tbody>
|
||||||
|
<tr><td>@S.Settings_NormalizationRevision</td><td style="text-align:right">@RevisionText(state)</td></tr>
|
||||||
|
<tr><td>@S.Settings_NormalizationZone</td><td style="text-align:right">@ZoneText(state)</td></tr>
|
||||||
|
<tr>
|
||||||
|
<td>@S.Settings_MetersCurrent</td>
|
||||||
|
<td style="text-align:right">
|
||||||
|
@Loc.F(S.Settings_MetersCurrentValue, state.CurrentMeters, state.PhysicalMeters)
|
||||||
|
@if (state.PhysicalMeters > state.CurrentMeters)
|
||||||
|
{
|
||||||
|
<span class="mv-muted"> · @Loc.F(S.Settings_MetersPending, state.PhysicalMeters - state.CurrentMeters)</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@if (state.Retry.Count > 0)
|
||||||
|
{
|
||||||
|
<tr><td>@S.Settings_RebuildRetry</td><td style="text-align:right">@string.Join(", ", state.Retry)</td></tr>
|
||||||
|
}
|
||||||
|
<tr><td>@S.Settings_VirtualMeters</td><td style="text-align:right">@VirtualText(state)</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</MudSimpleTable>
|
||||||
|
}
|
||||||
|
else if (_analysisFailed)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Warning" Dense="true">@S.Settings_AnalysisUnavailable</MudAlert>
|
||||||
|
}
|
||||||
|
<MudText Typo="Typo.caption" Class="mv-muted d-block mt-2">@S.Settings_AnalysisHelp</MudText>
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
</MudGrid>
|
</MudGrid>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private MeterVault.Infrastructure.Options.MeterVaultOptions _o = new();
|
private MeterVault.Infrastructure.Options.MeterVaultOptions _o = new();
|
||||||
|
private AnalysisState? _analysis;
|
||||||
|
private bool _analysisFailed;
|
||||||
|
|
||||||
protected override void OnInitialized() => _o = Options.Value;
|
/// <summary>What the analysis data is built with (D-16) and how the calculated meters stand (D-26, D-28).</summary>
|
||||||
|
private sealed record AnalysisState(
|
||||||
|
int? Revision,
|
||||||
|
string? Zone,
|
||||||
|
int PhysicalMeters,
|
||||||
|
int CurrentMeters,
|
||||||
|
IReadOnlyList<string> Retry,
|
||||||
|
IReadOnlyList<(VirtualMeterStatus Status, int Count)> Virtual);
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
_o = Options.Value;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_analysis = await LoadAnalysisStateAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
// The settings themselves are configuration and always show; only this read-out depends on the database.
|
||||||
|
Logger.LogWarning(ex, "Could not read the analysis data state");
|
||||||
|
_analysisFailed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<AnalysisState> LoadAnalysisStateAsync()
|
||||||
|
{
|
||||||
|
await using var db = await DbFactory.CreateDbContextAsync();
|
||||||
|
var settings = await db.AppSettings.AsNoTracking()
|
||||||
|
.Where(s => s.Key == NormalizationUpgrade.SettingKey || s.Key == NormalizationUpgrade.ZoneSettingKey || s.Key == NormalizationUpgrade.PendingSettingKey)
|
||||||
|
.ToDictionaryAsync(s => s.Key, s => s.Value);
|
||||||
|
|
||||||
|
var catalog = await Reader.LoadCatalogAsync();
|
||||||
|
var physical = catalog.Meters.Values.Where(m => !m.IsVirtual).ToList();
|
||||||
|
var retryIds = Read<int[]>(settings, NormalizationUpgrade.PendingSettingKey) ?? [];
|
||||||
|
var virtualStates = catalog.Meters.Values
|
||||||
|
.Where(m => m.IsVirtual)
|
||||||
|
.GroupBy(m => m.VirtualStatus ?? VirtualMeterStatus.NeedsConfiguration)
|
||||||
|
.OrderBy(g => g.Key)
|
||||||
|
.Select(g => (g.Key, g.Count()))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new AnalysisState(
|
||||||
|
Read<int?>(settings, NormalizationUpgrade.SettingKey),
|
||||||
|
Read<string>(settings, NormalizationUpgrade.ZoneSettingKey),
|
||||||
|
physical.Count,
|
||||||
|
physical.Count(m => !m.IsPending),
|
||||||
|
[.. retryIds.Select(id => catalog.Find(id)?.Name ?? $"#{id}")],
|
||||||
|
virtualStates);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static T? Read<T>(IReadOnlyDictionary<string, string> settings, string key)
|
||||||
|
{
|
||||||
|
if (!settings.TryGetValue(key, out var json))
|
||||||
|
{
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize<T>(json);
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string RevisionText(AnalysisState state) => state.Revision switch
|
||||||
|
{
|
||||||
|
null => S.Settings_RevisionNone,
|
||||||
|
var revision when revision < NormalizationUpgrade.CurrentRevision =>
|
||||||
|
Loc.F(S.Settings_RevisionOutdated, revision, NormalizationUpgrade.CurrentRevision),
|
||||||
|
var revision => Loc.F(S.Settings_RevisionValue, revision),
|
||||||
|
};
|
||||||
|
|
||||||
|
private string ZoneText(AnalysisState state) => state.Zone switch
|
||||||
|
{
|
||||||
|
null => S.Settings_RevisionNone,
|
||||||
|
var zone when !string.Equals(zone, Reader.Zone.Id, StringComparison.Ordinal) => Loc.F(S.Settings_ZoneDiffers, zone),
|
||||||
|
var zone => zone,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string VirtualText(AnalysisState state) => state.Virtual.Count == 0
|
||||||
|
? S.Settings_None
|
||||||
|
: string.Join(" · ", state.Virtual.Select(v => $"{v.Status.Display()}: {v.Count}"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,37 @@
|
|||||||
@page "/admin/tariffs"
|
@page "/admin/tariffs"
|
||||||
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
|
||||||
@inject ISnackbar Snackbar
|
|
||||||
@inject IDialogService DialogService
|
|
||||||
@using Microsoft.EntityFrameworkCore
|
@using Microsoft.EntityFrameworkCore
|
||||||
@using MudBlazor
|
@using MudBlazor
|
||||||
|
@using MeterVault.App.TariffEditing
|
||||||
|
@using MeterVault.Core.Analysis.Quantities
|
||||||
|
@using MeterVault.Infrastructure.Analysis
|
||||||
|
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
||||||
|
@inject AnalysisReader Reader
|
||||||
|
@inject InstanceCurrency Currency
|
||||||
|
@inject InstanceClock Clock
|
||||||
|
@inject NavigationManager Nav
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
@inject IDialogService DialogService
|
||||||
|
@inject ILogger<Tariffs> Logger
|
||||||
|
|
||||||
<PageTitle>MeterVault — Tariffs</PageTitle>
|
<PageHeader Title="@S.Nav_Tariffs" Description="@S.Tariffs_Description" Class="mb-1">
|
||||||
|
<Actions>
|
||||||
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
|
||||||
<MudText Typo="Typo.h4">Tariffs</MudText>
|
|
||||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
|
||||||
Add tariff
|
@S.Tariffs_AddTariff
|
||||||
</MudButton>
|
</MudButton>
|
||||||
</div>
|
</Actions>
|
||||||
|
</PageHeader>
|
||||||
|
<MudText Typo="Typo.caption" Class="mv-muted d-block mb-3">@S.Tariffs_NotAppliedNote</MudText>
|
||||||
|
|
||||||
|
@if (_link.HasScope)
|
||||||
|
{
|
||||||
|
@* D-52: a link from a missing-cost explanation scopes the list to what can price that meter or type. *@
|
||||||
|
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-3">
|
||||||
|
<div class="d-flex flex-wrap align-center" style="gap:0.25rem 1rem">
|
||||||
|
<span>@FilterText</span>
|
||||||
|
<MudButton Variant="Variant.Text" Size="Size.Small" Color="Color.Primary" OnClick="ShowAll">@S.Tariffs_ShowAll</MudButton>
|
||||||
|
</div>
|
||||||
|
</MudAlert>
|
||||||
|
}
|
||||||
|
|
||||||
@if (_tariffs is null)
|
@if (_tariffs is null)
|
||||||
{
|
{
|
||||||
@@ -20,49 +39,70 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<MudTable Items="_tariffs" Dense="true" Hover="true" Elevation="2">
|
var shown = _tariffs.Where(t => _link.Lists(t, MeterEnergyType)).ToList();
|
||||||
|
<MudTable Items="shown" Dense="true" Hover="true" Elevation="2">
|
||||||
<HeaderContent>
|
<HeaderContent>
|
||||||
<MudTh>Scope</MudTh>
|
<MudTh>@S.Common_Scope</MudTh>
|
||||||
<MudTh>Component</MudTh>
|
<MudTh>@S.Tariffs_Component</MudTh>
|
||||||
<MudTh>Value</MudTh>
|
<MudTh>@S.Common_Value</MudTh>
|
||||||
<MudTh>Unit</MudTh>
|
<MudTh>@S.Common_Unit</MudTh>
|
||||||
<MudTh>Valid from</MudTh>
|
<MudTh>@S.Tariffs_ValidFrom</MudTh>
|
||||||
<MudTh>Valid to</MudTh>
|
<MudTh>@S.Tariffs_ValidTo</MudTh>
|
||||||
<MudTh Style="text-align:right">Actions</MudTh>
|
<MudTh Style="text-align:right">@S.Common_Actions</MudTh>
|
||||||
</HeaderContent>
|
</HeaderContent>
|
||||||
<RowTemplate>
|
<RowTemplate>
|
||||||
<MudTd DataLabel="Scope">@ScopeLabel(context)</MudTd>
|
<MudTd DataLabel="@S.Common_Scope">@ScopeLabel(context)</MudTd>
|
||||||
<MudTd DataLabel="Component">@context.Component</MudTd>
|
<MudTd DataLabel="@S.Tariffs_Component">
|
||||||
<MudTd DataLabel="Value">@Format.Number(context.Value, 4)</MudTd>
|
@context.Component.Display()
|
||||||
<MudTd DataLabel="Unit">@context.Unit</MudTd>
|
@if (IsNotApplied(context.Component))
|
||||||
<MudTd DataLabel="Valid from">@context.ValidFrom.ToString("yyyy-MM-dd")</MudTd>
|
{
|
||||||
<MudTd DataLabel="Valid to">@(context.ValidTo?.ToString("yyyy-MM-dd") ?? "open")</MudTd>
|
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined" Class="ml-1">@S.Tariffs_NotAppliedChip</MudChip>
|
||||||
<MudTd DataLabel="Actions" Style="text-align:right">
|
}
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => OpenEdit(context))" />
|
</MudTd>
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(context))" />
|
<MudTd DataLabel="@S.Common_Value">@Format.Number(context.Value, 4)</MudTd>
|
||||||
|
<MudTd DataLabel="@S.Common_Unit">
|
||||||
|
@context.Unit
|
||||||
|
@if (RowIssue(context) is { } issue)
|
||||||
|
{
|
||||||
|
<MudTooltip Text="@issue.Text">
|
||||||
|
<MudIcon Icon="@(issue.IsError ? Icons.Material.Filled.ErrorOutline : Icons.Material.Filled.WarningAmber)"
|
||||||
|
Color="@(issue.IsError ? Color.Error : Color.Warning)" Size="Size.Small" Class="ml-1"
|
||||||
|
Style="vertical-align:middle" aria-label="@issue.Text" />
|
||||||
|
</MudTooltip>
|
||||||
|
}
|
||||||
|
</MudTd>
|
||||||
|
<MudTd DataLabel="@S.Tariffs_ValidFrom" Style="white-space:nowrap">@Format.Date(context.ValidFrom)</MudTd>
|
||||||
|
<MudTd DataLabel="@S.Tariffs_ValidTo" Style="white-space:nowrap">@(_effectiveEnds.GetValueOrDefault(context.Id) is { } until ? Format.Date(until) : S.Tariffs_OpenEnded)</MudTd>
|
||||||
|
<MudTd DataLabel="@S.Common_Actions" Style="text-align:right">
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => OpenEdit(context))" aria-label="@S.Tariffs_EditTariff" />
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(context))" aria-label="@S.Tariffs_DeleteTitle" />
|
||||||
</MudTd>
|
</MudTd>
|
||||||
</RowTemplate>
|
</RowTemplate>
|
||||||
</MudTable>
|
</MudTable>
|
||||||
@if (_tariffs.Count == 0)
|
@if (_tariffs.Count == 0)
|
||||||
{
|
{
|
||||||
<MudAlert Severity="Severity.Info" Class="mt-4">No tariffs yet. Add one, or load the reference data from <MudLink Href="/import">Import</MudLink>.</MudAlert>
|
<MudAlert Severity="Severity.Info" Class="mt-4">@S.Tariffs_EmptyState <MudLink Href="/import">@S.Nav_Import</MudLink>.</MudAlert>
|
||||||
|
}
|
||||||
|
else if (shown.Count == 0)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Info" Class="mt-4">@S.Tariffs_FilterEmpty</MudAlert>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
<MudDialog @bind-Visible="_editOpen" Options="_dialogOptions">
|
<MudDialog @bind-Visible="_editOpen" Options="_dialogOptions">
|
||||||
<TitleContent>
|
<TitleContent>
|
||||||
<MudText Typo="Typo.h6">@(_working.Id == 0 ? "New tariff" : "Edit tariff")</MudText>
|
<MudText Typo="Typo.h6">@(_working.Id == 0 ? S.Tariffs_NewTariff : S.Tariffs_EditTariff)</MudText>
|
||||||
</TitleContent>
|
</TitleContent>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<MudSelect T="TariffScope" @bind-Value="_working.ScopeType" Label="Scope" Class="mb-2">
|
<MudSelect T="TariffScope" Value="_working.ScopeType" ValueChanged="SetScope" Label="@S.Common_Scope" Class="mb-2">
|
||||||
@foreach (var scope in Enum.GetValues<TariffScope>())
|
@foreach (var scope in Enum.GetValues<TariffScope>())
|
||||||
{
|
{
|
||||||
<MudSelectItem T="TariffScope" Value="scope">@scope</MudSelectItem>
|
<MudSelectItem T="TariffScope" Value="scope">@scope.Display()</MudSelectItem>
|
||||||
}
|
}
|
||||||
</MudSelect>
|
</MudSelect>
|
||||||
@if (_working.ScopeType == TariffScope.EnergyType)
|
@if (_working.ScopeType == TariffScope.EnergyType)
|
||||||
{
|
{
|
||||||
<MudSelect T="int?" @bind-Value="_working.ScopeId" Label="Energy type" Class="mb-2">
|
<MudSelect T="int?" Value="_working.ScopeId" ValueChanged="SetScopeId" Label="@S.Common_EnergyType" Class="mb-2">
|
||||||
@foreach (var t in _energyTypes)
|
@foreach (var t in _energyTypes)
|
||||||
{
|
{
|
||||||
<MudSelectItem T="int?" Value="@((int?)t.Id)">@t.DisplayName</MudSelectItem>
|
<MudSelectItem T="int?" Value="@((int?)t.Id)">@t.DisplayName</MudSelectItem>
|
||||||
@@ -71,63 +111,201 @@ else
|
|||||||
}
|
}
|
||||||
else if (_working.ScopeType == TariffScope.Meter)
|
else if (_working.ScopeType == TariffScope.Meter)
|
||||||
{
|
{
|
||||||
<MudSelect T="int?" @bind-Value="_working.ScopeId" Label="Meter" Class="mb-2">
|
<MudSelect T="int?" Value="_working.ScopeId" ValueChanged="SetScopeId" Label="@S.Common_Meter" Class="mb-2">
|
||||||
@foreach (var m in _meters)
|
@foreach (var m in _meters)
|
||||||
{
|
{
|
||||||
<MudSelectItem T="int?" Value="@((int?)m.Id)">@m.Name</MudSelectItem>
|
<MudSelectItem T="int?" Value="@((int?)m.Id)">@m.Name</MudSelectItem>
|
||||||
}
|
}
|
||||||
</MudSelect>
|
</MudSelect>
|
||||||
}
|
}
|
||||||
<MudSelect T="TariffComponent" @bind-Value="_working.Component" Label="Component" Class="mb-2">
|
<MudSelect T="TariffComponent" Value="_working.Component" ValueChanged="SetComponent" Label="@S.Tariffs_Component" Class="mb-2">
|
||||||
@foreach (var component in Enum.GetValues<TariffComponent>())
|
@foreach (var component in Enum.GetValues<TariffComponent>())
|
||||||
{
|
{
|
||||||
<MudSelectItem T="TariffComponent" Value="component">@component</MudSelectItem>
|
<MudSelectItem T="TariffComponent" Value="component">@component.Display()</MudSelectItem>
|
||||||
}
|
}
|
||||||
</MudSelect>
|
</MudSelect>
|
||||||
<MudNumericField T="double" @bind-Value="_working.Value" Label="Value" Format="0.####" Class="mb-2" />
|
@* D-38: a new tariff starts without a value, and saving needs one; a typed 0 is a deliberate free price, said so. *@
|
||||||
<MudTextField @bind-Value="_working.Unit" Label="Unit (e.g. EUR/kWh, EUR/m3, EUR/month)" Required="true" Class="mb-2" />
|
<MudNumericField T="double?" @bind-Value="_working.Value" Label="@S.Common_Value" Format="0.####" Class="mb-2"
|
||||||
<MudTextField @bind-Value="_working.Currency" Label="Currency" Class="mb-2" />
|
Required="true" RequiredError="@S.Tariffs_ValueRequired" Immediate="true"
|
||||||
<MudDatePicker @bind-Date="_working.ValidFrom" Label="Valid from" Class="mb-2" />
|
HelperText="@(_working.Value is null ? null : TariffValue.Note(ValueVerdict))" />
|
||||||
<MudDatePicker @bind-Date="_working.ValidTo" Label="Valid to (empty = open-ended)" Clearable="true" Class="mb-2" />
|
<MudTextField T="string" Value="_working.Unit" ValueChanged="SetUnit" Label="@S.Tariffs_UnitLabel" Required="true"
|
||||||
<MudTextField @bind-Value="_working.Notes" Label="Notes (optional)" />
|
Immediate="true" DebounceInterval="300" Class="mb-1" />
|
||||||
|
@* D-37: how the unit was read and whether it fits what this tariff would price, before it is saved. *@
|
||||||
|
<div class="mb-3" aria-live="polite">
|
||||||
|
@foreach (var (isError, text) in TariffUnitCheck.Describe(Verdict, Currency.Code))
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.caption" Color="@(isError ? Color.Error : Color.Default)" Class="@(isError ? "d-block" : "d-block mv-muted")">@text</MudText>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<MudTextField @bind-Value="_working.Currency" Label="@S.Common_Currency" Class="mb-2" />
|
||||||
|
<MudDatePicker @bind-Date="_working.ValidFrom" Label="@S.Tariffs_ValidFrom" Class="mb-2" />
|
||||||
|
<MudDatePicker @bind-Date="_working.ValidTo" Label="@S.Tariffs_ValidToLabel" Clearable="true" Class="mb-2" />
|
||||||
|
<MudTextField @bind-Value="_working.Notes" Label="@S.Tariffs_NotesLabel" />
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<MudButton OnClick="@(() => _editOpen = false)">Cancel</MudButton>
|
<MudButton OnClick="@(() => _editOpen = false)">@S.Common_Cancel</MudButton>
|
||||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">Save</MudButton>
|
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">@S.Common_Save</MudButton>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</MudDialog>
|
</MudDialog>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private List<Tariff>? _tariffs;
|
private List<Tariff>? _tariffs;
|
||||||
|
private IReadOnlyDictionary<int, DateOnly?> _effectiveEnds = new Dictionary<int, DateOnly?>();
|
||||||
private List<EnergyType> _energyTypes = [];
|
private List<EnergyType> _energyTypes = [];
|
||||||
private List<Meter> _meters = [];
|
private List<Meter> _meters = [];
|
||||||
|
private AnalysisCatalog? _catalog;
|
||||||
private bool _editOpen;
|
private bool _editOpen;
|
||||||
private EditModel _working = new();
|
private EditModel _working = new();
|
||||||
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
|
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
|
||||||
|
|
||||||
|
private TariffDeepLink _link = TariffDeepLink.None;
|
||||||
|
|
||||||
|
/// <summary>A new-tariff request from the link, held until the action has been dropped from the address.</summary>
|
||||||
|
private TariffDeepLink? _pendingNew;
|
||||||
|
|
||||||
|
private bool _droppingAction;
|
||||||
|
|
||||||
|
[SupplyParameterFromQuery(Name = TariffLinks.ParamScope)]
|
||||||
|
public string? ScopeParam { get; set; }
|
||||||
|
|
||||||
|
[SupplyParameterFromQuery(Name = TariffLinks.ParamId)]
|
||||||
|
public string? IdParam { get; set; }
|
||||||
|
|
||||||
|
[SupplyParameterFromQuery(Name = TariffLinks.ParamComponent)]
|
||||||
|
public string? ComponentParam { get; set; }
|
||||||
|
|
||||||
|
[SupplyParameterFromQuery(Name = TariffLinks.ParamFrom)]
|
||||||
|
public string? FromParam { get; set; }
|
||||||
|
|
||||||
|
/// <summary>A dialog to open once the page is interactive (<c>new</c>); dropped from the address when consumed.</summary>
|
||||||
|
[SupplyParameterFromQuery(Name = TariffLinks.ParamAction)]
|
||||||
|
public string? Action { get; set; }
|
||||||
|
|
||||||
protected override Task OnInitializedAsync() => LoadAsync();
|
protected override Task OnInitializedAsync() => LoadAsync();
|
||||||
|
|
||||||
|
protected override void OnParametersSet()
|
||||||
|
{
|
||||||
|
_link = TariffDeepLink.Parse(ScopeParam, IdParam, ComponentParam, FromParam, Action);
|
||||||
|
if (_link.OpenNew)
|
||||||
|
{
|
||||||
|
_pendingNew = _link;
|
||||||
|
}
|
||||||
|
else if (string.IsNullOrEmpty(Action))
|
||||||
|
{
|
||||||
|
_droppingAction = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens the deep-linked new-tariff dialog once the page is interactive (D-52). As on a meter's page, the action is
|
||||||
|
/// dropped from the address first and the dialog opened when that navigation has come back: a circuit's first
|
||||||
|
/// location change would otherwise dismiss the dialog, and a reload must not reopen it. The scope stays in the
|
||||||
|
/// address, so the list stays scoped.
|
||||||
|
/// </summary>
|
||||||
|
protected override void OnAfterRender(bool firstRender)
|
||||||
|
{
|
||||||
|
if (_pendingNew is not { } request || _tariffs is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(Action))
|
||||||
|
{
|
||||||
|
if (!_droppingAction)
|
||||||
|
{
|
||||||
|
_droppingAction = true;
|
||||||
|
Nav.NavigateTo(Nav.GetUriWithQueryParameters(new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
[TariffLinks.ParamAction] = null,
|
||||||
|
[TariffLinks.ParamComponent] = null,
|
||||||
|
[TariffLinks.ParamFrom] = null,
|
||||||
|
}), replace: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_pendingNew = null;
|
||||||
|
OpenNew(request);
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
private async Task LoadAsync()
|
private async Task LoadAsync()
|
||||||
{
|
{
|
||||||
await using var db = await DbFactory.CreateDbContextAsync();
|
await using var db = await DbFactory.CreateDbContextAsync();
|
||||||
_tariffs = await db.Tariffs.AsNoTracking().OrderBy(t => t.Component).ThenBy(t => t.ValidFrom).ToListAsync();
|
// By component, then the newest validity first (A-42): a price history is read from the price that applies now.
|
||||||
|
_tariffs = await db.Tariffs.AsNoTracking().OrderBy(t => t.Component).ThenByDescending(t => t.ValidFrom).ToListAsync();
|
||||||
|
_effectiveEnds = TariffValidity.EffectiveEnds(
|
||||||
|
_tariffs.Select(t => new TariffSpan(t.Id, t.ScopeType, t.ScopeId, t.Component, t.ValidFrom, t.ValidTo)));
|
||||||
_energyTypes = await db.EnergyTypes.AsNoTracking().OrderBy(t => t.DisplayName).ToListAsync();
|
_energyTypes = await db.EnergyTypes.AsNoTracking().OrderBy(t => t.DisplayName).ToListAsync();
|
||||||
_meters = await db.Meters.AsNoTracking().OrderBy(m => m.Name).ToListAsync();
|
_meters = await db.Meters.AsNoTracking().OrderBy(m => m.Name).ToListAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_catalog = await Reader.LoadCatalogAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Without it the unit is still parsed; only the check against the scope's units is skipped.
|
||||||
|
Logger.LogWarning(ex, "Could not load the meter catalog for the tariff unit check");
|
||||||
|
_catalog = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private string ScopeLabel(Tariff t) => t.ScopeType switch
|
private string ScopeLabel(Tariff t) => t.ScopeType switch
|
||||||
{
|
{
|
||||||
TariffScope.Global => "Global",
|
TariffScope.Global => S.Tariffs_ScopeGlobal,
|
||||||
TariffScope.EnergyType => $"Type: {_energyTypes.FirstOrDefault(x => x.Id == t.ScopeId)?.DisplayName ?? $"#{t.ScopeId}"}",
|
TariffScope.EnergyType => Loc.F(S.Tariffs_ScopeType, _energyTypes.FirstOrDefault(x => x.Id == t.ScopeId)?.DisplayName ?? $"#{t.ScopeId}"),
|
||||||
TariffScope.Meter => $"Meter: {_meters.FirstOrDefault(x => x.Id == t.ScopeId)?.Name ?? $"#{t.ScopeId}"}",
|
TariffScope.Meter => Loc.F(S.Tariffs_ScopeMeter, _meters.FirstOrDefault(x => x.Id == t.ScopeId)?.Name ?? $"#{t.ScopeId}"),
|
||||||
_ => t.ScopeType.ToString(),
|
_ => t.ScopeType.ToString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private string FilterText => _link.Scope switch
|
||||||
|
{
|
||||||
|
TariffScope.Meter => Loc.F(S.Tariffs_FilterMeter, _meters.FirstOrDefault(m => m.Id == _link.ScopeId)?.Name ?? $"#{_link.ScopeId}"),
|
||||||
|
TariffScope.EnergyType => Loc.F(S.Tariffs_FilterType, _energyTypes.FirstOrDefault(t => t.Id == _link.ScopeId)?.DisplayName ?? $"#{_link.ScopeId}"),
|
||||||
|
_ => S.Tariffs_FilterGlobal,
|
||||||
|
};
|
||||||
|
|
||||||
|
private int? MeterEnergyType(int meterId) => _meters.FirstOrDefault(m => m.Id == meterId)?.EnergyTypeId;
|
||||||
|
|
||||||
|
private static bool IsNotApplied(TariffComponent component) =>
|
||||||
|
component is TariffComponent.Bonus or TariffComponent.Discount or TariffComponent.Tax;
|
||||||
|
|
||||||
|
private void ShowAll() => Nav.NavigateTo(TariffLinks.Path);
|
||||||
|
|
||||||
|
/// <summary>What a tariff of this scope and component would price, by unit (D-20, D-34).</summary>
|
||||||
|
private IReadOnlyList<TariffUnitTarget> TargetsFor(TariffScope scope, int? scopeId, TariffComponent component) =>
|
||||||
|
_catalog is null
|
||||||
|
? []
|
||||||
|
: TariffUnitCheck.TargetsFor(_catalog, scope, scopeId, component, id =>
|
||||||
|
_energyTypes.FirstOrDefault(t => t.Id == id) is { } type ? (type.DisplayName, type.BaseUnit) : null);
|
||||||
|
|
||||||
|
private TariffUnitVerdict Verdict => TariffUnitCheck.Check(
|
||||||
|
_working.Unit, _working.Component, TargetsFor(_working.ScopeType, _working.ScopeId, _working.Component), Currency.Code);
|
||||||
|
|
||||||
|
/// <summary>A stored tariff whose unit would be refused or warned about now, with the first line that says why.</summary>
|
||||||
|
private (bool IsError, string Text)? RowIssue(Tariff tariff)
|
||||||
|
{
|
||||||
|
var verdict = TariffUnitCheck.Check(tariff.Unit, tariff.Component, TargetsFor(tariff.ScopeType, tariff.ScopeId, tariff.Component), Currency.Code);
|
||||||
|
if (verdict.Kind is TariffUnitVerdictKind.Fits or TariffUnitVerdictKind.NotApplied)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines = TariffUnitCheck.Describe(verdict, Currency.Code);
|
||||||
|
var reason = lines.FirstOrDefault(l => l.IsError);
|
||||||
|
return reason.Text is null ? (false, lines[^1].Text) : (true, reason.Text);
|
||||||
|
}
|
||||||
|
|
||||||
private void OpenEdit(Tariff? tariff)
|
private void OpenEdit(Tariff? tariff)
|
||||||
{
|
{
|
||||||
_working = tariff is null
|
if (tariff is null)
|
||||||
? new EditModel { ValidFrom = DateTime.Today }
|
{
|
||||||
: new EditModel
|
OpenNew(TariffDeepLink.None with { Scope = _link.Scope, ScopeId = _link.ScopeId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_working = new EditModel
|
||||||
{
|
{
|
||||||
Id = tariff.Id,
|
Id = tariff.Id,
|
||||||
ScopeType = tariff.ScopeType,
|
ScopeType = tariff.ScopeType,
|
||||||
@@ -135,6 +313,7 @@ else
|
|||||||
Component = tariff.Component,
|
Component = tariff.Component,
|
||||||
Value = tariff.Value,
|
Value = tariff.Value,
|
||||||
Unit = tariff.Unit,
|
Unit = tariff.Unit,
|
||||||
|
UnitTouched = true,
|
||||||
Currency = tariff.Currency,
|
Currency = tariff.Currency,
|
||||||
ValidFrom = tariff.ValidFrom.ToDateTime(TimeOnly.MinValue),
|
ValidFrom = tariff.ValidFrom.ToDateTime(TimeOnly.MinValue),
|
||||||
ValidTo = tariff.ValidTo?.ToDateTime(TimeOnly.MinValue),
|
ValidTo = tariff.ValidTo?.ToDateTime(TimeOnly.MinValue),
|
||||||
@@ -143,21 +322,91 @@ else
|
|||||||
_editOpen = true;
|
_editOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>A new tariff, prefilled from a link (D-52): its scope, component and first month, and a unit that fits.</summary>
|
||||||
|
private void OpenNew(TariffDeepLink request)
|
||||||
|
{
|
||||||
|
_working = new EditModel
|
||||||
|
{
|
||||||
|
ScopeType = request.Scope ?? TariffScope.EnergyType,
|
||||||
|
ScopeId = request.Scope == TariffScope.Global ? null : request.ScopeId,
|
||||||
|
Component = request.Component ?? TariffComponent.UnitPrice,
|
||||||
|
Currency = Currency.Code,
|
||||||
|
ValidFrom = (request.From ?? Clock.Today).ToDateTime(TimeOnly.MinValue),
|
||||||
|
};
|
||||||
|
SuggestUnit();
|
||||||
|
_editOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A unit nobody typed follows the scope and component: currency per the priced unit, or per month.</summary>
|
||||||
|
private void SuggestUnit()
|
||||||
|
{
|
||||||
|
if (_working.UnitTouched)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var target = TargetsFor(_working.ScopeType, _working.ScopeId, _working.Component).FirstOrDefault();
|
||||||
|
_working.Unit = TariffUnit.Suggest(_working.Component, target?.Unit ?? (_working.Component is TariffComponent.UnitPrice or TariffComponent.FeedIn ? "kWh" : null), Currency.Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetScope(TariffScope scope)
|
||||||
|
{
|
||||||
|
_working.ScopeType = scope;
|
||||||
|
_working.ScopeId = null;
|
||||||
|
SuggestUnit();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetScopeId(int? id)
|
||||||
|
{
|
||||||
|
_working.ScopeId = id;
|
||||||
|
SuggestUnit();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetComponent(TariffComponent component)
|
||||||
|
{
|
||||||
|
_working.Component = component;
|
||||||
|
SuggestUnit();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetUnit(string? unit)
|
||||||
|
{
|
||||||
|
_working.Unit = unit ?? "";
|
||||||
|
_working.UnitTouched = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>What the value field holds, for the save guard and the note under it (D-38).</summary>
|
||||||
|
private TariffValueVerdict ValueVerdict => TariffValue.Check(_working.Value, _working.Component);
|
||||||
|
|
||||||
private async Task SaveAsync()
|
private async Task SaveAsync()
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(_working.Unit) || _working.ValidFrom is null)
|
if (string.IsNullOrWhiteSpace(_working.Unit) || _working.ValidFrom is null)
|
||||||
{
|
{
|
||||||
Snackbar.Add("Unit and valid-from are required.", Severity.Warning);
|
Snackbar.Add(S.Tariffs_UnitAndValidFromRequired, Severity.Warning);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_working.ScopeType != TariffScope.Global && _working.ScopeId is null)
|
if (_working.ScopeType != TariffScope.Global && _working.ScopeId is null)
|
||||||
{
|
{
|
||||||
Snackbar.Add("Select the energy type or meter this tariff applies to.", Severity.Warning);
|
Snackbar.Add(S.Tariffs_ScopeTargetRequired, Severity.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// D-38: an untouched value is not a price. Without this, the missing-price deep link would save a free period.
|
||||||
|
if (ValueVerdict.BlocksSave())
|
||||||
|
{
|
||||||
|
Snackbar.Add(S.Tariffs_ValueRequired, Severity.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// D-37: a unit that is read and does not fit would leave the cost "unavailable (unit)"; refuse it here instead.
|
||||||
|
if (Verdict.Blocks)
|
||||||
|
{
|
||||||
|
Snackbar.Add(S.Tariffs_UnitBlocked, Severity.Warning);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var scopeId = _working.ScopeType == TariffScope.Global ? null : _working.ScopeId;
|
var scopeId = _working.ScopeType == TariffScope.Global ? null : _working.ScopeId;
|
||||||
|
var value = _working.Value!.Value;
|
||||||
|
|
||||||
await using var db = await DbFactory.CreateDbContextAsync();
|
await using var db = await DbFactory.CreateDbContextAsync();
|
||||||
if (_working.Id == 0)
|
if (_working.Id == 0)
|
||||||
@@ -167,9 +416,9 @@ else
|
|||||||
ScopeType = _working.ScopeType,
|
ScopeType = _working.ScopeType,
|
||||||
ScopeId = scopeId,
|
ScopeId = scopeId,
|
||||||
Component = _working.Component,
|
Component = _working.Component,
|
||||||
Value = _working.Value,
|
Value = value,
|
||||||
Unit = _working.Unit.Trim(),
|
Unit = _working.Unit.Trim(),
|
||||||
Currency = string.IsNullOrWhiteSpace(_working.Currency) ? "EUR" : _working.Currency.Trim(),
|
Currency = string.IsNullOrWhiteSpace(_working.Currency) ? Currency.Code : _working.Currency.Trim(),
|
||||||
ValidFrom = DateOnly.FromDateTime(_working.ValidFrom.Value),
|
ValidFrom = DateOnly.FromDateTime(_working.ValidFrom.Value),
|
||||||
ValidTo = _working.ValidTo is { } to ? DateOnly.FromDateTime(to) : null,
|
ValidTo = _working.ValidTo is { } to ? DateOnly.FromDateTime(to) : null,
|
||||||
Notes = string.IsNullOrWhiteSpace(_working.Notes) ? null : _working.Notes,
|
Notes = string.IsNullOrWhiteSpace(_working.Notes) ? null : _working.Notes,
|
||||||
@@ -181,9 +430,9 @@ else
|
|||||||
existing.ScopeType = _working.ScopeType;
|
existing.ScopeType = _working.ScopeType;
|
||||||
existing.ScopeId = scopeId;
|
existing.ScopeId = scopeId;
|
||||||
existing.Component = _working.Component;
|
existing.Component = _working.Component;
|
||||||
existing.Value = _working.Value;
|
existing.Value = value;
|
||||||
existing.Unit = _working.Unit.Trim();
|
existing.Unit = _working.Unit.Trim();
|
||||||
existing.Currency = string.IsNullOrWhiteSpace(_working.Currency) ? "EUR" : _working.Currency.Trim();
|
existing.Currency = string.IsNullOrWhiteSpace(_working.Currency) ? Currency.Code : _working.Currency.Trim();
|
||||||
existing.ValidFrom = DateOnly.FromDateTime(_working.ValidFrom.Value);
|
existing.ValidFrom = DateOnly.FromDateTime(_working.ValidFrom.Value);
|
||||||
existing.ValidTo = _working.ValidTo is { } to ? DateOnly.FromDateTime(to) : null;
|
existing.ValidTo = _working.ValidTo is { } to ? DateOnly.FromDateTime(to) : null;
|
||||||
existing.Notes = string.IsNullOrWhiteSpace(_working.Notes) ? null : _working.Notes;
|
existing.Notes = string.IsNullOrWhiteSpace(_working.Notes) ? null : _working.Notes;
|
||||||
@@ -191,13 +440,14 @@ else
|
|||||||
|
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
_editOpen = false;
|
_editOpen = false;
|
||||||
Snackbar.Add("Saved.", Severity.Success);
|
Snackbar.Add(S.Common_Saved, Severity.Success);
|
||||||
await LoadAsync();
|
await LoadAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task DeleteAsync(Tariff tariff)
|
private async Task DeleteAsync(Tariff tariff)
|
||||||
{
|
{
|
||||||
if (!await Confirm.DeleteAsync(DialogService, "Delete tariff", $"Delete this {tariff.Component} tariff ({Format.Number(tariff.Value, 4)} {tariff.Unit})?"))
|
if (!await Confirm.DeleteAsync(DialogService, S.Tariffs_DeleteTitle,
|
||||||
|
Loc.F(S.Tariffs_DeleteBody, tariff.Component.Display(), Format.Number(tariff.Value, 4), tariff.Unit)))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -208,7 +458,7 @@ else
|
|||||||
{
|
{
|
||||||
db.Tariffs.Remove(target);
|
db.Tariffs.Remove(target);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
Snackbar.Add("Deleted.", Severity.Success);
|
Snackbar.Add(S.Common_Deleted, Severity.Success);
|
||||||
}
|
}
|
||||||
|
|
||||||
await LoadAsync();
|
await LoadAsync();
|
||||||
@@ -220,8 +470,14 @@ else
|
|||||||
public TariffScope ScopeType { get; set; } = TariffScope.EnergyType;
|
public TariffScope ScopeType { get; set; } = TariffScope.EnergyType;
|
||||||
public int? ScopeId { get; set; }
|
public int? ScopeId { get; set; }
|
||||||
public TariffComponent Component { get; set; } = TariffComponent.UnitPrice;
|
public TariffComponent Component { get; set; } = TariffComponent.UnitPrice;
|
||||||
public double Value { get; set; }
|
/// <summary>The price; null until one is typed (a new tariff never defaults to a free 0, D-38).</summary>
|
||||||
|
public double? Value { get; set; }
|
||||||
|
|
||||||
public string Unit { get; set; } = "EUR/kWh";
|
public string Unit { get; set; } = "EUR/kWh";
|
||||||
|
|
||||||
|
/// <summary>True once the user typed a unit (or it is a stored tariff's): it is no longer re-suggested.</summary>
|
||||||
|
public bool UnitTouched { get; set; }
|
||||||
|
|
||||||
public string Currency { get; set; } = "EUR";
|
public string Currency { get; set; } = "EUR";
|
||||||
public DateTime? ValidFrom { get; set; }
|
public DateTime? ValidFrom { get; set; }
|
||||||
public DateTime? ValidTo { get; set; }
|
public DateTime? ValidTo { get; set; }
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
@using MeterVault.App.AnalysisPage
|
||||||
|
@using MeterVault.Core.Analysis
|
||||||
|
@using MeterVault.Core.Analysis.Costing
|
||||||
|
@using MeterVault.Infrastructure.Analysis
|
||||||
|
@inject InstanceCurrency Currency
|
||||||
|
|
||||||
|
@* The period figures of the Analysis page (brief §7.4, D-08): one card per series with its total — the status in words
|
||||||
|
when there is no number, never a zero — and its change against the comparison over the dates both periods cover. A cost
|
||||||
|
card says what it is made of (metered use, standing charges, manual costs, feed-in credit), so manual costs are visibly
|
||||||
|
counted once; a meter's cost names its rule (D-39). *@
|
||||||
|
|
||||||
|
<MudGrid Spacing="2" Class="mb-3">
|
||||||
|
@if (View.Kind == AnalysisPageViewKind.Quantity)
|
||||||
|
{
|
||||||
|
@foreach (var series in View.Series)
|
||||||
|
{
|
||||||
|
<MudItem xs="12" sm="6" md="4" lg="3">
|
||||||
|
<MetricCard Title="@View.NameOf(series)" Value="series.Total" Unit="@series.Unit"
|
||||||
|
Change="series.Comparison?.Change" Polarity="ChangePolarities.For(series.Kind)"
|
||||||
|
ChangeCaption="@ChangeCaption" Caption="@CaptionOf(series)"
|
||||||
|
Href="@HrefOf(series)" LinkText="@LinkTextOf(series)" />
|
||||||
|
</MudItem>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (View.MeterCost is { } meterCost)
|
||||||
|
{
|
||||||
|
<MudItem xs="12" sm="6" md="4" lg="3">
|
||||||
|
<MetricCard Title="@AnalysisMetric.Cost.Display()" Cost="meterCost.Total" Currency="@meterCost.Currency"
|
||||||
|
Caption="@RuleOf(meterCost)"
|
||||||
|
Change="CostChanges.ForCard(View.MeterCostChange)" Polarity="CostChanges.Polarity(View.MeterCostChange)"
|
||||||
|
ChangeCaption="@CostChanges.Caption(Shown, View.MeterCostChange)" />
|
||||||
|
</MudItem>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@foreach (var cost in View.Costs)
|
||||||
|
{
|
||||||
|
<MudItem xs="12" sm="6" md="4" lg="3">
|
||||||
|
<MetricCard Title="@cost.Name" Cost="cost.Current.Total" Currency="@cost.Current.Currency"
|
||||||
|
Change="CostChanges.ForCard(cost.CostChange)" Polarity="CostChanges.Polarity(cost.CostChange)"
|
||||||
|
ChangeCaption="@CostChanges.Caption(Shown, cost.CostChange)" Caption="@CaptionOf(cost)"
|
||||||
|
Href="@HrefOf(cost)" LinkText="@(HrefOf(cost) is null ? null : S.Analysis_OpenMeter)" />
|
||||||
|
</MudItem>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</MudGrid>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public AnalysisPageView View { get; set; } = null!;
|
||||||
|
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public AnalysisPageOptions Options { get; set; } = AnalysisPageOptions.Empty;
|
||||||
|
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public AnalysisQuery Shown { get; set; } = null!;
|
||||||
|
|
||||||
|
private string? ChangeCaption => View.Comparison?.IsApplicable == true ? Shown.Comparison.Display() : null;
|
||||||
|
|
||||||
|
/// <summary>A meter: its energy type and what it measures; a measure: the meters counted in it (D-22).</summary>
|
||||||
|
private string? CaptionOf(AnalysisSeries series)
|
||||||
|
{
|
||||||
|
if (series.MeterId is { } meterId)
|
||||||
|
{
|
||||||
|
var type = series.EnergyTypeId is { } typeId ? Options.TypeName(typeId) : null;
|
||||||
|
var what = series.Kind.Display();
|
||||||
|
var calculated = Options.Meter(meterId) is { IsVirtual: true } ? " · " + S.Analysis_Calculated : string.Empty;
|
||||||
|
return (type is null ? what : type + " · " + what) + calculated;
|
||||||
|
}
|
||||||
|
|
||||||
|
return series.MemberIds.Count > 0
|
||||||
|
? Loc.F(S.Analysis_Counted, string.Join(", ", series.MemberIds.Select(Options.MeterName)))
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A meter's page, or — on the portfolio — the energy type in this page.</summary>
|
||||||
|
private string? HrefOf(AnalysisSeries series)
|
||||||
|
{
|
||||||
|
if (series.MeterId is { } meterId)
|
||||||
|
{
|
||||||
|
return View.Selection.Scope.Kind == QueryScopeKind.Meter ? null : MeterLinks.Analysis(meterId, Shown);
|
||||||
|
}
|
||||||
|
|
||||||
|
return View.Selection.Scope.Kind == QueryScopeKind.Portfolio && series.EnergyTypeId is { } typeId
|
||||||
|
? AnalysisLinks.Analysis(QueryScope.ForEnergyType(typeId), null, Shown)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? LinkTextOf(AnalysisSeries series) =>
|
||||||
|
series.MeterId is not null ? S.Analysis_OpenMeter
|
||||||
|
: series.EnergyTypeId is { } typeId ? Loc.F(S.Analysis_AnalyseScope, Options.TypeName(typeId))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
/// <summary>What a cost is made of, and for a type its billing basis, for a meter its rule.</summary>
|
||||||
|
private string? CaptionOf(AnalysisCostSeries cost)
|
||||||
|
{
|
||||||
|
var current = cost.Current;
|
||||||
|
if (current.Meter is { } meter)
|
||||||
|
{
|
||||||
|
return RuleOf(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
var parts = PartsOf(current.Total);
|
||||||
|
if (current.Request.Scope.Kind == CostScopeKind.EnergyType && current.EnergyTypes.FirstOrDefault() is { } type)
|
||||||
|
{
|
||||||
|
parts = type.Basis.Display() + (parts is null ? string.Empty : " · " + parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? HrefOf(AnalysisCostSeries cost) =>
|
||||||
|
cost.Current.Request.Scope is { Kind: CostScopeKind.Meter, Id: { } meterId } && View.Selection.Scope.Kind != QueryScopeKind.Meter
|
||||||
|
? MeterLinks.Analysis(meterId, Shown)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
/// <summary>"Metered use 1.234,00 € · Standing charges 96,00 € · Manual costs 800,00 €" — only the parts there are.</summary>
|
||||||
|
private string? PartsOf(CostAmount total)
|
||||||
|
{
|
||||||
|
List<string> parts = [];
|
||||||
|
if (total.Usage is { } usage)
|
||||||
|
{
|
||||||
|
parts.Add(Loc.F(S.Analysis_PartUsage, Currency.Format(usage)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (total.StandingCharge is { } standing)
|
||||||
|
{
|
||||||
|
parts.Add(Loc.F(S.Analysis_PartStanding, Currency.Format(standing)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (total.Manual is { } manual)
|
||||||
|
{
|
||||||
|
parts.Add(Loc.F(S.Analysis_PartManual, Currency.Format(manual)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (total.FeedInCredit is { } credit)
|
||||||
|
{
|
||||||
|
parts.Add(Loc.F(S.Analysis_PartCredit, Currency.Format(credit)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.Count > 0 ? string.Join(" · ", parts) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>How a meter's cost is formed (D-39), or why it has none.</summary>
|
||||||
|
private static string? RuleOf(Infrastructure.Costing.CostAnalysis cost) => cost.Meter switch
|
||||||
|
{
|
||||||
|
{ Rule: Infrastructure.Costing.MeterCostRule.None } meter => Loc.F(S.Analysis_NotCosted, meter.NotCosted.Display()),
|
||||||
|
{ } meter => Loc.F(S.Analysis_CostRule, meter.Rule.Display()),
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
@using MeterVault.App.AnalysisPage
|
||||||
|
@using MeterVault.Infrastructure.Analysis
|
||||||
|
|
||||||
|
@* Why the Analysis page shows no figures for its address, and what would work (brief §7.4, §4.3): a scope that no longer
|
||||||
|
exists, more meters than can be compared, or a cost category whose meters cannot be one quantity — named per kind and
|
||||||
|
unit, with the comparison of each group and the category's cost one click away. Never a silently different view. *@
|
||||||
|
|
||||||
|
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Class="mb-3" role="status">
|
||||||
|
@switch (Selection.Refusal)
|
||||||
|
{
|
||||||
|
case AnalysisPageRefusal.UnknownScope:
|
||||||
|
<div>@S.Analysis_RefusalUnknownScope</div>
|
||||||
|
<div class="mv-analysis-refusal__actions">
|
||||||
|
<MudButton Variant="Variant.Outlined" Size="Size.Small" OnClick="() => Show(Shown.WithScope(QueryScope.Portfolio).WithMetric(null))">
|
||||||
|
@S.Analysis_ShowAll
|
||||||
|
</MudButton>
|
||||||
|
</div>
|
||||||
|
break;
|
||||||
|
|
||||||
|
case AnalysisPageRefusal.TooManyMeters:
|
||||||
|
<div>@Loc.F(S.Analysis_TooManyMeters, AnalysisLimits.MaxSeries)</div>
|
||||||
|
<div class="mv-analysis-refusal__actions">
|
||||||
|
<MudButton Variant="Variant.Outlined" Size="Size.Small"
|
||||||
|
OnClick="() => Show(Shown.WithScope(QueryScope.ForMeters(Selection.Scope.MeterIds.Take(AnalysisLimits.MaxSeries))))">
|
||||||
|
@Loc.F(S.Analysis_CompareFirst, AnalysisLimits.MaxSeries)
|
||||||
|
</MudButton>
|
||||||
|
</div>
|
||||||
|
break;
|
||||||
|
|
||||||
|
case AnalysisPageRefusal.CategoryMixed:
|
||||||
|
<div>@Loc.F(S.Analysis_RefusalCategoryMixed, Selection.ScopeName ?? string.Empty)</div>
|
||||||
|
<ul class="mv-analysis-refusal__groups">
|
||||||
|
@foreach (var group in Selection.Groups)
|
||||||
|
{
|
||||||
|
<li>
|
||||||
|
<span>@Loc.F(S.Analysis_Group, group.Kind.Display(), group.Unit, string.Join(", ", group.MeterIds.Select(Options.MeterName)))</span>
|
||||||
|
@if (group.MeterIds.Count <= AnalysisLimits.MaxSeries)
|
||||||
|
{
|
||||||
|
<MudButton Variant="Variant.Text" Size="Size.Small"
|
||||||
|
OnClick="() => Show(Shown.WithScope(QueryScope.ForMeters(group.MeterIds)).WithMetric(group.Metric))">
|
||||||
|
@S.Analysis_CompareGroup
|
||||||
|
</MudButton>
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
<div class="mv-analysis-refusal__actions">@ShowCost</div>
|
||||||
|
break;
|
||||||
|
|
||||||
|
case AnalysisPageRefusal.CategoryWithoutMeters:
|
||||||
|
<div>@Loc.F(S.Analysis_RefusalCategoryWithoutMeters, Selection.ScopeName ?? string.Empty)</div>
|
||||||
|
<div class="mv-analysis-refusal__actions">@ShowCost</div>
|
||||||
|
break;
|
||||||
|
|
||||||
|
case AnalysisPageRefusal.CategoryTooManyMeters:
|
||||||
|
<div>@Loc.F(S.Analysis_RefusalCategoryTooManyMeters, Selection.ScopeName ?? string.Empty, CategoryMeterCount, AnalysisLimits.MaxSeries)</div>
|
||||||
|
<div class="mv-analysis-refusal__actions">@ShowCost</div>
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
</MudAlert>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public AnalysisSelection Selection { get; set; } = null!;
|
||||||
|
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public AnalysisPageOptions Options { get; set; } = AnalysisPageOptions.Empty;
|
||||||
|
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public AnalysisQuery Shown { get; set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>Shows the chosen alternative (replacing the address).</summary>
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback<AnalysisQuery> OnShow { get; set; }
|
||||||
|
|
||||||
|
private int CategoryMeterCount => Options.Category(Selection.Scope.Id)?.MeterIds.Count ?? 0;
|
||||||
|
|
||||||
|
private RenderFragment ShowCost => __builder =>
|
||||||
|
{
|
||||||
|
<MudButton Variant="Variant.Outlined" Size="Size.Small" OnClick="() => Show(Shown.WithMetric(null))">@S.Analysis_ShowCost</MudButton>
|
||||||
|
};
|
||||||
|
|
||||||
|
private Task Show(AnalysisQuery query) => OnShow.InvokeAsync(query);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.mv-analysis-refusal__actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
|
||||||
|
.mv-analysis-refusal__groups { margin: 8px 0 0 0; padding-left: 1.25rem; }
|
||||||
|
.mv-analysis-refusal__groups li { overflow-wrap: anywhere; }
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
@using MeterVault.App.AnalysisPage
|
||||||
|
@using MeterVault.Core.Analysis.Totals
|
||||||
|
@using MeterVault.Infrastructure.Analysis
|
||||||
|
|
||||||
|
@* What the figures of the Analysis page are — and are not (brief §6.1, D-22, D-42): a category's meters are shown side by
|
||||||
|
side and never added; a comparison names the selected meters it does not show for this measure; total use and grid
|
||||||
|
import are side by side, never summed; an overlapping category is a view on the bill, not a slice of it. *@
|
||||||
|
|
||||||
|
@if (_notes.Count > 0)
|
||||||
|
{
|
||||||
|
<ul class="mv-analysis-notes mb-3">
|
||||||
|
@foreach (var note in _notes)
|
||||||
|
{
|
||||||
|
<li>
|
||||||
|
<MudIcon Icon="@Icons.Material.Outlined.Info" Size="Size.Small" aria-hidden="true" />
|
||||||
|
<span>@note</span>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public AnalysisPageView View { get; set; } = null!;
|
||||||
|
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public AnalysisPageOptions Options { get; set; } = AnalysisPageOptions.Empty;
|
||||||
|
|
||||||
|
private List<string> _notes = [];
|
||||||
|
|
||||||
|
protected override void OnParametersSet()
|
||||||
|
{
|
||||||
|
var selection = View.Selection;
|
||||||
|
_notes = [];
|
||||||
|
|
||||||
|
if (selection.ShowsMeters && selection.Scope.Kind == QueryScopeKind.Category && View.Series.Count > 1)
|
||||||
|
{
|
||||||
|
_notes.Add(S.Analysis_CategorySideBySide);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selection.HiddenMeterIds.Count > 0)
|
||||||
|
{
|
||||||
|
var names = string.Join(", ", selection.HiddenMeterIds.Select(Options.MeterName));
|
||||||
|
_notes.Add(selection.IsCost
|
||||||
|
? Loc.F(S.Analysis_NotShownNoCost, names)
|
||||||
|
: Loc.F(S.Analysis_NotShownForMetric, selection.Metric?.Display() ?? string.Empty, names));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Total use and grid import answer different questions: side by side, never added (D-22).
|
||||||
|
if (!selection.ShowsMeters && View.Kind == AnalysisPageViewKind.Quantity
|
||||||
|
&& View.Series.Any(s => s.Key.Measure == TotalsMeasure.Use) && View.Series.Any(s => s.Key.Measure == TotalsMeasure.GridImport))
|
||||||
|
{
|
||||||
|
_notes.Add(S.Analysis_UseAndGridApart);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (View.Costs.FirstOrDefault()?.Current.Category is { IsOverlappingView: true })
|
||||||
|
{
|
||||||
|
_notes.Add(S.Analysis_OverlappingView);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.mv-analysis-notes { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.mv-analysis-notes li { display: flex; align-items: flex-start; gap: 6px; font-size: 0.875rem; color: var(--mud-palette-text-secondary); }
|
||||||
|
.mv-analysis-notes li span { min-width: 0; overflow-wrap: anywhere; }
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
@using MeterVault.App.AnalysisPage
|
||||||
|
@using MeterVault.Core.Analysis
|
||||||
|
@using MeterVault.Core.Analysis.Costing
|
||||||
|
@using MeterVault.Infrastructure.Analysis
|
||||||
|
@inject NavigationManager Nav
|
||||||
|
|
||||||
|
@* Everything the Analysis page shows for one committed load (brief §7.4, §4.3): an explanation instead of figures when the
|
||||||
|
selection cannot be one quantity, the attention items, the empty and pending states, the period figures with their
|
||||||
|
change, which dates are compared, the chart (a click drills into a bucket, D-51) and the table with its drill-down
|
||||||
|
links. Everything comes from the one view, so a title never sits above another selection's chart. *@
|
||||||
|
|
||||||
|
@if (View.Selection.Refusal != AnalysisPageRefusal.None)
|
||||||
|
{
|
||||||
|
<AnalysisExplanation Selection="View.Selection" Options="Options" Shown="Shown" OnShow="OnShow" />
|
||||||
|
}
|
||||||
|
else if (View.ReaderRefusal == AnalysisRefusal.TooManySeries)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Warning" Class="mb-3">@Loc.F(S.Analysis_TooManyMeters, AnalysisLimits.MaxSeries)</MudAlert>
|
||||||
|
}
|
||||||
|
else if (View.ReaderRefusal == AnalysisRefusal.TooManyPoints)
|
||||||
|
{
|
||||||
|
@* The toolbar says which interval would work and offers it. *@
|
||||||
|
<MudText Typo="Typo.body2" Class="mv-muted">@S.Analysis_ChooseCoarserBucket</MudText>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<AttentionList Problems="View.Problems" CostAttention="View.CostAttention" Names="_names" Query="Shown" MaxItems="5" Class="mb-3" />
|
||||||
|
|
||||||
|
@if (View.NotYetOccurred)
|
||||||
|
{
|
||||||
|
<EmptyPeriodState NotYetOccurred="true" />
|
||||||
|
}
|
||||||
|
else if (View.IsPending)
|
||||||
|
{
|
||||||
|
<PendingState OnRefresh="OnRefresh" />
|
||||||
|
}
|
||||||
|
else if (View.HasNoData)
|
||||||
|
{
|
||||||
|
<EmptyPeriodState Availability="View.Availability" LatestHref="@LatestHref">
|
||||||
|
@if (View.Availability is null)
|
||||||
|
{
|
||||||
|
@* Nothing at all yet: where data comes from. With older data, the dates and "Go to latest data" say it. *@
|
||||||
|
<MudText Typo="Typo.body2" Class="mv-muted mt-1">
|
||||||
|
@S.Analysis_NoDataYetHint
|
||||||
|
<MudLink Href="/import" Typo="Typo.body2">@S.Nav_Import</MudLink>
|
||||||
|
</MudText>
|
||||||
|
}
|
||||||
|
</EmptyPeriodState>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<AnalysisCards View="View" Options="Options" Shown="Shown" />
|
||||||
|
|
||||||
|
<ComparisonSummary Period="View.Period" Resolution="View.Comparison" Matched="View.Matched" Class="mb-3" />
|
||||||
|
|
||||||
|
<AnalysisNotes View="View" Options="Options" />
|
||||||
|
|
||||||
|
<MudPaper Outlined="true" Elevation="0" Class="pa-3 mb-3">
|
||||||
|
<MudText Typo="Typo.subtitle1" Class="mb-2">@ChartTitle</MudText>
|
||||||
|
<AnalysisChart Buckets="View.Plan!.Buckets" Series="View.Chart" ComparisonPairs="View.Pairs" Title="@ChartTitle"
|
||||||
|
OnBucketClick="_onBucketClick" Resolution="View.Resolution" OnUseBucket="size => OnShow.InvokeAsync(Shown.WithBucket(size))" />
|
||||||
|
@if (HiddenOverlays)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.caption" Class="mv-muted">@Loc.F(S.Analysis_ComparisonInTable, AnalysisPageLoader.MaxOverlaidSeries)</MudText>
|
||||||
|
}
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
<MudPaper Outlined="true" Elevation="0" Class="pa-3 mb-3">
|
||||||
|
<MudText Typo="Typo.subtitle1" Class="mb-2">@S.Analysis_TableTitle</MudText>
|
||||||
|
<AnalysisTable Buckets="View.Plan!.Buckets" Series="View.Table" ComparisonPairs="View.Pairs" DrillHref="_drillHref"
|
||||||
|
Caption="@Loc.F(S.Analysis_TableCaption, ChartTitle)" />
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
@if (View.Series is [{ Basis: SeriesBasis.Virtual or SeriesBasis.LegacyVirtual } only])
|
||||||
|
{
|
||||||
|
<MudPaper Outlined="true" Elevation="0" Class="pa-3 mb-3">
|
||||||
|
<SeriesContributions Series="only" Query="Shown" MeterName="@(id => Options.Meter(id)?.Name)"
|
||||||
|
UnitOfSource="@(id => Options.Meter(id)?.Unit)" />
|
||||||
|
</MudPaper>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public AnalysisPageView View { get; set; } = null!;
|
||||||
|
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public AnalysisPageOptions Options { get; set; } = AnalysisPageOptions.Empty;
|
||||||
|
|
||||||
|
/// <summary>The address as shown when the view was read: drill-downs and links build on it.</summary>
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public AnalysisQuery Shown { get; set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>The page defaults, which an address leaves out.</summary>
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public AnalysisDefaults Defaults { get; set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>Shows another state (an action of an explanation), replacing the address.</summary>
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback<AnalysisQuery> OnShow { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Reads the same state again (analysis being prepared: check whether it is ready).</summary>
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback OnRefresh { get; set; }
|
||||||
|
|
||||||
|
private AttentionNames _names = new();
|
||||||
|
private EventCallback<AnalysisBucket> _onBucketClick;
|
||||||
|
private Func<AnalysisBucket, string?>? _drillHref;
|
||||||
|
|
||||||
|
protected override void OnParametersSet()
|
||||||
|
{
|
||||||
|
_names = new AttentionNames(Options.MeterNames, Options.TypeNames, Options.Categories.ToDictionary(c => c.Id, c => c.Name));
|
||||||
|
|
||||||
|
// Buckets are clickable, and the table has its drill column, only when some bucket leads somewhere (D-51): a
|
||||||
|
// comparison of monthly imports has nothing finer to open.
|
||||||
|
var drills = View.Plan is { } plan && plan.Buckets.Any(b => DrillHref(b) is not null);
|
||||||
|
_onBucketClick = drills ? EventCallback.Factory.Create<AnalysisBucket>(this, DrillAsync) : default;
|
||||||
|
_drillHref = drills ? DrillHref : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>"Consumption — Strom", "Cost — All energy types".</summary>
|
||||||
|
private string ChartTitle
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var selection = View.Selection;
|
||||||
|
var what = selection.Metric?.Display() ?? View.Series.FirstOrDefault()?.Kind.Display() ?? string.Empty;
|
||||||
|
var scope = selection.ScopeName ?? selection.Scope.Kind.Display();
|
||||||
|
return what.Length == 0 ? scope : what + " — " + scope;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool HiddenOverlays =>
|
||||||
|
View.Pairs is not null && (View.Kind == AnalysisPageViewKind.Cost ? View.Costs.Count : View.Series.Count) > AnalysisPageLoader.MaxOverlaidSeries;
|
||||||
|
|
||||||
|
private string? LatestHref =>
|
||||||
|
AnalysisNavigation.LatestData(Shown, View.Availability) is { } latest ? AnalysisNavigation.UriFor(Nav, latest, Defaults) : null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Where a bucket leads (D-51): the same view over the bucket in the next finer size the data resolves; for one meter
|
||||||
|
/// whose data is too coarse for that, its records of the bucket; otherwise nowhere.
|
||||||
|
/// </summary>
|
||||||
|
private string? DrillHref(AnalysisBucket bucket)
|
||||||
|
{
|
||||||
|
if (AnalysisNavigation.DrillInto(Shown, bucket, View.Resolution) is { } next)
|
||||||
|
{
|
||||||
|
return AnalysisNavigation.UriFor(Nav, next, Defaults);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (View.Selection.Scope.Kind == QueryScopeKind.Meter && View.Selection.Scope.Id is { } meterId)
|
||||||
|
{
|
||||||
|
var (first, last) = AnalysisNavigation.DaysOf(bucket);
|
||||||
|
return Options.Meter(meterId) is { IsVirtual: true }
|
||||||
|
? MeterLinks.Analysis(meterId, PeriodResolver.IsValidCustomRange(first, last) ? Shown.WithCustomRange(first, last) : Shown)
|
||||||
|
: AnalysisNavigation.NormalizedData(meterId, Shown, bucket);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task DrillAsync(AnalysisBucket bucket)
|
||||||
|
{
|
||||||
|
// A drill-down is a new history entry (D-46): Back returns to the coarser view.
|
||||||
|
if (DrillHref(bucket) is { } href)
|
||||||
|
{
|
||||||
|
Nav.NavigateTo(href);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
@* A group heading inside a MudSelect's list (the meters of one energy type). MudSelect renders its items twice — once in
|
||||||
|
a hidden "shadow" pass that only registers them, once in the dropdown — and a plain MudListSubheader would show up in
|
||||||
|
the page from the first pass. This one renders only in the dropdown. *@
|
||||||
|
|
||||||
|
@if (!HideContent)
|
||||||
|
{
|
||||||
|
<MudListSubheader Class="mv-picker-group">@ChildContent</MudListSubheader>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>
|
||||||
|
/// True in MudSelect's hidden registration pass, where nothing may be drawn: MudSelect cascades it by this name to its
|
||||||
|
/// items (MudSelectItem.HideContent).
|
||||||
|
/// </summary>
|
||||||
|
[CascadingParameter(Name = "HideContent")]
|
||||||
|
public bool HideContent { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public RenderFragment? ChildContent { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
@using MeterVault.App.AnalysisPage
|
||||||
|
@using MeterVault.Infrastructure.Analysis
|
||||||
|
@* What the Analysis page analyses (brief §7.4, D-47): everything, an energy type, a cost category, one meter or up to
|
||||||
|
six meters side by side — and which measure of it. Meters are picked by name, grouped by energy type, with calculated
|
||||||
|
and retired meters marked. Nothing here navigates: every choice raises QueryChanged, and the page writes it into its
|
||||||
|
address (replace), so reload, Back and a shared link restore it. A seventh meter is refused with an explanation,
|
||||||
|
never dropped silently. *@
|
||||||
|
|
||||||
|
<div class="mv-scope" role="group" aria-label="@S.Analysis_ScopeGroup">
|
||||||
|
<div class="mv-scope__field">
|
||||||
|
<MudSelect T="QueryScopeKind" Value="Selection.Scope.Kind" ValueChanged="OnKindChanged" Label="@S.Analysis_ScopeLabel"
|
||||||
|
Variant="Variant.Outlined" Margin="Margin.Dense" Dense="true" ToStringFunc="@(k => k.Display())">
|
||||||
|
@foreach (var kind in Kinds)
|
||||||
|
{
|
||||||
|
<MudSelectItem T="QueryScopeKind" Value="kind" Disabled="@(!IsOffered(kind))">@kind.Display()</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@switch (Selection.Scope.Kind)
|
||||||
|
{
|
||||||
|
case QueryScopeKind.EnergyType:
|
||||||
|
<div class="mv-scope__field">
|
||||||
|
<MudSelect T="int" Value="@(Selection.Scope.Id ?? 0)" ValueChanged="id => ChangeScope(QueryScope.ForEnergyType(id))"
|
||||||
|
Label="@QueryScopeKind.EnergyType.Display()" Variant="Variant.Outlined" Margin="Margin.Dense" Dense="true"
|
||||||
|
ToStringFunc="@(id => Options.Type(id)?.Name ?? string.Empty)">
|
||||||
|
@foreach (var type in Options.Types)
|
||||||
|
{
|
||||||
|
<MudSelectItem T="int" Value="type.Id">@type.Name</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
</div>
|
||||||
|
break;
|
||||||
|
|
||||||
|
case QueryScopeKind.Category:
|
||||||
|
<div class="mv-scope__field">
|
||||||
|
<MudSelect T="int" Value="@(Selection.Scope.Id ?? 0)" ValueChanged="id => ChangeScope(QueryScope.ForCategory(id))"
|
||||||
|
Label="@QueryScopeKind.Category.Display()" Variant="Variant.Outlined" Margin="Margin.Dense" Dense="true"
|
||||||
|
ToStringFunc="@(id => Options.Category(id)?.Name ?? string.Empty)">
|
||||||
|
@foreach (var category in Options.Categories)
|
||||||
|
{
|
||||||
|
<MudSelectItem T="int" Value="category.Id">@category.Name</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
</div>
|
||||||
|
break;
|
||||||
|
|
||||||
|
case QueryScopeKind.Meter:
|
||||||
|
<div class="mv-scope__field mv-scope__field--wide">
|
||||||
|
<MudSelect T="int" Value="@(Selection.Scope.Id ?? 0)" ValueChanged="id => ChangeScope(QueryScope.ForMeter(id))"
|
||||||
|
Label="@S.Common_Meter" Variant="Variant.Outlined" Margin="Margin.Dense" Dense="true"
|
||||||
|
ToStringFunc="@(id => MeterLabel(id))" MaxHeight="420">
|
||||||
|
@MeterItems
|
||||||
|
</MudSelect>
|
||||||
|
</div>
|
||||||
|
break;
|
||||||
|
|
||||||
|
case QueryScopeKind.Meters:
|
||||||
|
<div class="mv-scope__field mv-scope__field--wide">
|
||||||
|
<MudSelect @key="_pickerGeneration" T="int" MultiSelection="true" SelectedValues="_meters" SelectedValuesChanged="OnMetersChanged"
|
||||||
|
Label="@Loc.F(S.Analysis_MetersLabel, AnalysisLimits.MaxSeries)" Variant="Variant.Outlined"
|
||||||
|
Margin="Margin.Dense" Dense="true" MaxHeight="420"
|
||||||
|
MultiSelectionTextFunc="@(ids => MetersText(ids))"
|
||||||
|
Error="@(_meterHint is not null)" ErrorText="@_meterHint">
|
||||||
|
@MeterItems
|
||||||
|
</MudSelect>
|
||||||
|
</div>
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (Selection.Metric is { } shown && (Selection.Metrics.Count > 1 || !Selection.Metrics.Contains(shown)))
|
||||||
|
{
|
||||||
|
<div class="mv-scope__field">
|
||||||
|
<MudSelect T="AnalysisMetric" Value="shown" ValueChanged="OnMetricChanged" Label="@S.Toolbar_Metric"
|
||||||
|
Variant="Variant.Outlined" Margin="Margin.Dense" Dense="true" ToStringFunc="@(m => m.Display())">
|
||||||
|
@* A category quantity that cannot be shown is still what the address asks for: listed, so the way back to
|
||||||
|
the cost is one choice away. *@
|
||||||
|
@foreach (var metric in Selection.Metrics.Contains(shown) ? Selection.Metrics : [shown, .. Selection.Metrics])
|
||||||
|
{
|
||||||
|
<MudSelectItem T="AnalysisMetric" Value="metric">@metric.Display()</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>The meters, energy types and categories to choose from.</summary>
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public AnalysisPageOptions Options { get; set; } = AnalysisPageOptions.Empty;
|
||||||
|
|
||||||
|
/// <summary>The page's current reading of its address.</summary>
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public AnalysisSelection Selection { get; set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>The address as shown (<see cref="AnalysisSelection.Shown"/>): every choice starts from it.</summary>
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public AnalysisQuery Query { get; set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>Raised with the new state; the page writes it into its address (replace).</summary>
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback<AnalysisQuery> QueryChanged { get; set; }
|
||||||
|
|
||||||
|
private static readonly IReadOnlyList<QueryScopeKind> Kinds =
|
||||||
|
[QueryScopeKind.Portfolio, QueryScopeKind.EnergyType, QueryScopeKind.Category, QueryScopeKind.Meter, QueryScopeKind.Meters];
|
||||||
|
|
||||||
|
private IReadOnlyCollection<int> _meters = [];
|
||||||
|
private AnalysisQuery? _synced;
|
||||||
|
private string? _meterHint;
|
||||||
|
|
||||||
|
/// <summary>Re-creates the multi-select after a refused pick, so it drops the box it ticked on its own.</summary>
|
||||||
|
private int _pickerGeneration;
|
||||||
|
|
||||||
|
protected override void OnParametersSet()
|
||||||
|
{
|
||||||
|
// The multi-select follows the address; a new address also clears the reason for a refused pick.
|
||||||
|
if (Query != _synced)
|
||||||
|
{
|
||||||
|
_synced = Query;
|
||||||
|
_meters = Selection.Scope.Kind == QueryScopeKind.Meters ? [.. Selection.Scope.MeterIds] : [];
|
||||||
|
_meterHint = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsOffered(QueryScopeKind kind) => kind switch
|
||||||
|
{
|
||||||
|
QueryScopeKind.EnergyType => Options.Types.Count > 0,
|
||||||
|
QueryScopeKind.Category => Options.Categories.Count > 0,
|
||||||
|
QueryScopeKind.Meter or QueryScopeKind.Meters => Options.Meters.Count > 0,
|
||||||
|
_ => true,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>The meters grouped by energy type, calculated and retired ones marked.</summary>
|
||||||
|
private RenderFragment MeterItems => __builder =>
|
||||||
|
{
|
||||||
|
foreach (var group in Options.Meters.GroupBy(m => m.EnergyTypeId))
|
||||||
|
{
|
||||||
|
<PickerGroupHeader>@Options.TypeName(group.Key)</PickerGroupHeader>
|
||||||
|
foreach (var meter in group)
|
||||||
|
{
|
||||||
|
<MudSelectItem T="int" Value="meter.Id">@MeterLabel(meter.Id)</MudSelectItem>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private string MeterLabel(int id)
|
||||||
|
{
|
||||||
|
if (Options.Meter(id) is not { } meter)
|
||||||
|
{
|
||||||
|
return Options.MeterName(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
var label = meter.IsVirtual ? Loc.F(S.Analysis_CalculatedMeter, meter.Name) : meter.Name;
|
||||||
|
return meter.IsRetired ? Loc.F(S.Analysis_RetiredMeter, label) : label;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string MetersText(IReadOnlyList<string?> ids) =>
|
||||||
|
string.Join(", ", ids.Select(text =>
|
||||||
|
int.TryParse(text, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var id) ? MeterLabel(id) : text));
|
||||||
|
|
||||||
|
private async Task OnKindChanged(QueryScopeKind kind)
|
||||||
|
{
|
||||||
|
if (kind == Selection.Scope.Kind)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var current = Selection.Scope;
|
||||||
|
var meterIds = current.MeterIds.Where(id => Options.Meter(id) is not null).ToList();
|
||||||
|
var typeId = Selection.EnergyTypeId ?? meterIds.Select(id => Options.Meter(id)!.EnergyTypeId).Cast<int?>().FirstOrDefault();
|
||||||
|
QueryScope? next = kind switch
|
||||||
|
{
|
||||||
|
QueryScopeKind.Portfolio => QueryScope.Portfolio,
|
||||||
|
QueryScopeKind.EnergyType => (typeId ?? Options.Types.FirstOrDefault()?.Id) is { } type ? QueryScope.ForEnergyType(type) : null,
|
||||||
|
QueryScopeKind.Category => Options.Categories.FirstOrDefault() is { } category ? QueryScope.ForCategory(category.Id) : null,
|
||||||
|
QueryScopeKind.Meter => FirstMeter(meterIds, typeId) is { } meter ? QueryScope.ForMeter(meter) : null,
|
||||||
|
_ => MetersFor(meterIds, typeId) is { Count: > 0 } ids ? QueryScope.ForMeters(ids) : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (next is not null)
|
||||||
|
{
|
||||||
|
await ChangeScope(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The meter a switch to one meter starts with: the one shown, else the type's first, else the first.</summary>
|
||||||
|
private int? FirstMeter(IReadOnlyList<int> meterIds, int? typeId) =>
|
||||||
|
meterIds.Count > 0 ? meterIds[0]
|
||||||
|
: (Options.Meters.FirstOrDefault(m => m.EnergyTypeId == typeId && !m.IsVirtual && !m.IsRetired)
|
||||||
|
?? Options.Meters.FirstOrDefault(m => !m.IsVirtual && !m.IsRetired)
|
||||||
|
?? Options.Meters.FirstOrDefault())?.Id;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The meters a switch to a comparison starts with: those shown, else the energy type's meters of the measure shown
|
||||||
|
/// (when they fit), else one meter to add others to.
|
||||||
|
/// </summary>
|
||||||
|
private List<int> MetersFor(IReadOnlyList<int> meterIds, int? typeId)
|
||||||
|
{
|
||||||
|
if (meterIds.Count > 0)
|
||||||
|
{
|
||||||
|
return [.. meterIds];
|
||||||
|
}
|
||||||
|
|
||||||
|
var ofType = Options.Meters
|
||||||
|
.Where(m => m.EnergyTypeId == typeId && !m.IsRetired && (Selection.IsCost ? m.IsCostable : m.Metric == Selection.Metric))
|
||||||
|
.Select(m => m.Id)
|
||||||
|
.ToList();
|
||||||
|
if (ofType.Count is > 0 and <= AnalysisLimits.MaxSeries)
|
||||||
|
{
|
||||||
|
return ofType;
|
||||||
|
}
|
||||||
|
|
||||||
|
return FirstMeter([], typeId) is { } first ? [first] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ChangeScope(QueryScope scope)
|
||||||
|
{
|
||||||
|
if (scope.Equals(Selection.Scope))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The measure goes along when the new scope has it; otherwise the new scope's natural one.
|
||||||
|
var next = Query.WithScope(scope);
|
||||||
|
if (next.Metric is { } metric && !AnalysisSelection.Resolve(next, Options).Metrics.Contains(metric))
|
||||||
|
{
|
||||||
|
next = next.WithMetric(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
await QueryChanged.InvokeAsync(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnMetersChanged(IReadOnlyCollection<int> values)
|
||||||
|
{
|
||||||
|
var chosen = values.ToList();
|
||||||
|
if (chosen.Count > AnalysisLimits.MaxSeries)
|
||||||
|
{
|
||||||
|
// Refused with the reason; the selection stays as it was.
|
||||||
|
_meterHint = Loc.F(S.Analysis_TooManyMeters, AnalysisLimits.MaxSeries);
|
||||||
|
_meters = [.. Selection.Scope.MeterIds];
|
||||||
|
_pickerGeneration++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chosen.Count == 0)
|
||||||
|
{
|
||||||
|
_meterHint = S.Analysis_AtLeastOneMeter;
|
||||||
|
_meters = [.. Selection.Scope.MeterIds];
|
||||||
|
_pickerGeneration++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_meterHint = null;
|
||||||
|
_meters = chosen;
|
||||||
|
|
||||||
|
// Keep the order in which meters were picked: the ones already shown first.
|
||||||
|
var ordered = Selection.Scope.MeterIds.Where(chosen.Contains).Concat(chosen.Where(id => !Selection.Scope.MeterIds.Contains(id))).ToList();
|
||||||
|
await ChangeScope(QueryScope.ForMeters(ordered));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnMetricChanged(AnalysisMetric metric)
|
||||||
|
{
|
||||||
|
if (metric != Selection.Metric)
|
||||||
|
{
|
||||||
|
await QueryChanged.InvokeAsync(Query.WithMetric(metric == Selection.NaturalMetric ? null : metric));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/* The "what" row of the Analysis page: wraps like the period toolbar, one field per row on a phone. */
|
||||||
|
.mv-scope { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 8px 12px; }
|
||||||
|
.mv-scope__field { flex: 1 1 180px; min-width: 160px; max-width: 280px; }
|
||||||
|
.mv-scope__field--wide { flex: 2 1 280px; max-width: 560px; }
|
||||||
|
@media (max-width: 599.98px) {
|
||||||
|
.mv-scope__field, .mv-scope__field--wide { flex: 1 1 100%; min-width: 0; max-width: none; }
|
||||||
|
}
|
||||||
@@ -1,185 +1,126 @@
|
|||||||
@page "/consumables"
|
@page "/consumables"
|
||||||
|
@using MeterVault.App.Components.Pages.Specialized
|
||||||
|
@using MeterVault.Core.Analysis
|
||||||
|
@implements IDisposable
|
||||||
|
@inject NavigationManager Nav
|
||||||
|
@inject InstanceClock Clock
|
||||||
@inject ConsumableService ConsumablesSvc
|
@inject ConsumableService ConsumablesSvc
|
||||||
@using MudBlazor
|
@inject ILogger<Consumables> Logger
|
||||||
|
|
||||||
<PageTitle>MeterVault — Consumables</PageTitle>
|
@* Tanks & consumables (brief §7.5, D-54): the shared header, toolbar and missing-data semantics around every tank's
|
||||||
|
specialised measures. Each tank keeps its state now — the last dipstick as measured, the contents estimated from it,
|
||||||
|
the forecast as a projection — apart from the selected period, which has its own usage, deliveries, burner runtime,
|
||||||
|
cost and, for a period that is over, the contents at its end. *@
|
||||||
|
|
||||||
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
<PageHeader Title="@S.Nav_Consumables" Description="@S.Consumables_Description">
|
||||||
<MudText Typo="Typo.h4">Oil / consumables</MudText>
|
<Breadcrumbs>
|
||||||
<MudSelect T="int" Value="_months" ValueChanged="OnRangeChanged" Label="Range" Dense="true" Style="max-width:200px">
|
<AnalysisBreadcrumbs Query="_query" Current="@S.Nav_Consumables" />
|
||||||
<MudSelectItem T="int" Value="12">Last 12 months</MudSelectItem>
|
</Breadcrumbs>
|
||||||
<MudSelectItem T="int" Value="24">Last 24 months</MudSelectItem>
|
</PageHeader>
|
||||||
<MudSelectItem T="int" Value="60">Last 5 years</MudSelectItem>
|
|
||||||
<MudSelectItem T="int" Value="1200">All time</MudSelectItem>
|
|
||||||
</MudSelect>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (_items is null)
|
@if (_query is not null)
|
||||||
{
|
{
|
||||||
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
|
<PeriodToolbar Query="_query" Period="_state.Value?.Analysis.Period" Plan="_state.Value?.Analysis.Plan" Defaults="Defaults"
|
||||||
|
QueryChanged="OnQueryChanged" ExportHref="@_state.Value?.ExportHref" Class="mb-4" />
|
||||||
}
|
}
|
||||||
else if (_items.Count == 0)
|
|
||||||
{
|
<LoadPanel State="_state" OnRetry="RetryAsync" Context="view">
|
||||||
<MudAlert Severity="Severity.Info">
|
@* A consumable meter without a tank has no capacity or calibration, so it cannot be summarised — but it must not just
|
||||||
No consumable meters found. Add a meter with mode <b>ConsumableBalance</b> and a tank, or load the reference
|
be missing from the page, with nothing saying where it went. *@
|
||||||
data from <MudLink Href="/import">Import</MudLink>.
|
@foreach (var meter in view.Analysis.Unconfigured)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Warning" Class="mb-3">
|
||||||
|
@Loc.F(S.Consumables_TankNotConfigured, meter.Name)
|
||||||
|
<MudButton Size="Size.Small" Variant="Variant.Text" Color="Color.Warning" Class="ml-2"
|
||||||
|
Href="@MeterLinks.Detail(meter.MeterId, action: MeterLinks.ActionEdit)">@S.Consumables_SetUpTank</MudButton>
|
||||||
</MudAlert>
|
</MudAlert>
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
@foreach (var item in _items)
|
|
||||||
{
|
|
||||||
<MudPaper Class="pa-4 mb-4" Elevation="2">
|
|
||||||
<MudText Typo="Typo.h6" Class="mb-3">@item.Name</MudText>
|
|
||||||
<MudGrid>
|
|
||||||
<MudItem xs="12" md="4">
|
|
||||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Tank level</MudText>
|
|
||||||
<MudText Typo="Typo.h5">
|
|
||||||
@(item.CurrentLevel is { } l ? $"{Format.Number(l, 0)} {item.Unit}" : "—")
|
|
||||||
</MudText>
|
|
||||||
<MudProgressLinear Color="@FillColor(item.FillFraction)" Value="@(item.FillFraction * 100)" Class="my-2" Size="Size.Large" />
|
|
||||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
|
||||||
@Format.Number(item.FillFraction * 100, 0)% of @Format.Number(item.Capacity, 0) @item.Unit
|
|
||||||
@if (item.PhysicalLevel is { } cm && item.PhysicalUnit is "cm")
|
|
||||||
{
|
|
||||||
<text> · @Format.Number(cm, 0) cm</text>
|
|
||||||
}
|
}
|
||||||
@if (item.LevelAsOf is { } asOf)
|
|
||||||
{
|
|
||||||
<text> · as of @asOf.ToString("yyyy-MM-dd")</text>
|
|
||||||
}
|
|
||||||
</MudText>
|
|
||||||
</MudItem>
|
|
||||||
|
|
||||||
<MudItem xs="12" md="8">
|
@if (view.Analysis.Tanks.Count == 0 && view.Analysis.Unconfigured.Count == 0)
|
||||||
<MudGrid>
|
|
||||||
<MudItem xs="6" sm="3">
|
|
||||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Used (range)</MudText>
|
|
||||||
<MudText Typo="Typo.subtitle1">@Format.Number(item.ConsumptionInRange, 0) @item.Unit</MudText>
|
|
||||||
</MudItem>
|
|
||||||
<MudItem xs="6" sm="3">
|
|
||||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Burner runtime</MudText>
|
|
||||||
<MudText Typo="Typo.subtitle1">@(item.BurnerHours is { } h ? $"{Format.Number(h, 0)} h" : "—")</MudText>
|
|
||||||
</MudItem>
|
|
||||||
<MudItem xs="6" sm="3">
|
|
||||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Effective rate</MudText>
|
|
||||||
<MudText Typo="Typo.subtitle1">
|
|
||||||
@if (item.FixedRate is { } fr)
|
|
||||||
{
|
{
|
||||||
<text>@Format.Number(fr, 2) @item.Unit/h</text>
|
<div class="mv-empty" role="status">
|
||||||
}
|
<MudIcon Icon="@Icons.Material.Outlined.PropaneTank" Class="mv-empty__icon" aria-hidden="true" />
|
||||||
else if (item.EffectiveRate is { } er)
|
<div class="mv-empty__body">
|
||||||
{
|
<MudText Typo="Typo.subtitle1">@S.Consumables_NoTanksTitle</MudText>
|
||||||
<text>@Format.Number(er, 2) @item.Unit/h</text>
|
<MudText Typo="Typo.body2" Class="mv-muted">@Loc.F(S.Consumables_NoTanksHelp, MeterMode.ConsumableBalance.Display())</MudText>
|
||||||
}
|
<div class="d-flex flex-wrap mt-2" style="gap:.5rem">
|
||||||
else
|
<MudButton Variant="Variant.Outlined" Color="Color.Primary" Size="Size.Small" Href="/meters">@S.Nav_Meters</MudButton>
|
||||||
{
|
<MudButton Variant="Variant.Text" Color="Color.Primary" Size="Size.Small" Href="/import">@S.Nav_Import</MudButton>
|
||||||
<text>—</text>
|
</div>
|
||||||
}
|
</div>
|
||||||
</MudText>
|
|
||||||
<MudText Typo="Typo.caption" Color="Color.Secondary">@(item.RateMode)</MudText>
|
|
||||||
</MudItem>
|
|
||||||
<MudItem xs="6" sm="3">
|
|
||||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Cost (range)</MudText>
|
|
||||||
<MudText Typo="Typo.subtitle1">@Format.Euro(item.CostInRange)</MudText>
|
|
||||||
</MudItem>
|
|
||||||
<MudItem xs="12" sm="6">
|
|
||||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Forecast to empty</MudText>
|
|
||||||
<MudText Typo="Typo.subtitle1">
|
|
||||||
@(item.ForecastEmpty is { } fe ? fe.ToString("yyyy-MM-dd") : "—")
|
|
||||||
@if (item.AveragePerDay is { } apd)
|
|
||||||
{
|
|
||||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="d-inline">
|
|
||||||
(@Format.Number(apd, 1) @item.Unit/day)
|
|
||||||
</MudText>
|
|
||||||
}
|
|
||||||
</MudText>
|
|
||||||
</MudItem>
|
|
||||||
</MudGrid>
|
|
||||||
</MudItem>
|
|
||||||
|
|
||||||
<MudItem xs="12" md="7">
|
|
||||||
<MudText Typo="Typo.subtitle2" Class="mb-2">Consumption by month</MudText>
|
|
||||||
<SeriesChart Series="@ChartFor(item)" Decimals="0" Height="260" />
|
|
||||||
</MudItem>
|
|
||||||
|
|
||||||
<MudItem xs="12" md="5">
|
|
||||||
<MudText Typo="Typo.subtitle2" Class="mb-2">Deliveries (@item.Deliveries.Count)</MudText>
|
|
||||||
@if (item.Deliveries.Count == 0)
|
|
||||||
{
|
|
||||||
<MudText Typo="Typo.body2" Color="Color.Secondary">No deliveries recorded.</MudText>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<div style="max-height:260px; overflow-y:auto">
|
|
||||||
<MudSimpleTable Dense="true" Hover="true">
|
|
||||||
<thead>
|
|
||||||
<tr><th>Date</th><th style="text-align:right">Amount</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@foreach (var delivery in item.Deliveries)
|
|
||||||
{
|
|
||||||
<tr>
|
|
||||||
<td>@delivery.Time.ToString("yyyy-MM-dd")</td>
|
|
||||||
<td style="text-align:right">@Format.Number(delivery.Amount, 0) @(delivery.Unit ?? item.Unit)</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</MudSimpleTable>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</MudItem>
|
else if (!view.Analysis.IsRefused)
|
||||||
</MudGrid>
|
{
|
||||||
</MudPaper>
|
@foreach (var tank in view.Tanks)
|
||||||
|
{
|
||||||
|
<TankSection @key="tank.Tank.MeterId" View="tank" Query="view.Query" Period="view.Analysis.Period"
|
||||||
|
Problems="ProblemsOf(view.Analysis, tank.Tank)" Currency="@view.Analysis.Currency" OnRefresh="RetryAsync" />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
</LoadPanel>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private int _months = 60;
|
private static readonly AnalysisDefaults Defaults = AnalysisDefaults.History;
|
||||||
private bool _loading;
|
|
||||||
private IReadOnlyList<ConsumableSummary>? _items;
|
|
||||||
|
|
||||||
protected override Task OnInitializedAsync() => LoadAsync();
|
private readonly LoadSequencer _loads = new();
|
||||||
|
private readonly LoadState<ConsumablesPageView> _state = new();
|
||||||
|
private AnalysisQuery? _query;
|
||||||
|
|
||||||
private async Task OnRangeChanged(int months)
|
/// <summary>One committed result: the query it answers, the read model, every tank's series and the export link.</summary>
|
||||||
|
private sealed record ConsumablesPageView(AnalysisQuery Query, ConsumableAnalysis Analysis, IReadOnlyList<TankView> Tanks, string? ExportHref);
|
||||||
|
|
||||||
|
protected override void OnInitialized() => Nav.LocationChanged += OnLocationChanged;
|
||||||
|
|
||||||
|
protected override Task OnParametersSetAsync() => ReloadIfChangedAsync();
|
||||||
|
|
||||||
|
private void OnLocationChanged(object? sender, LocationChangedEventArgs e) => _ = InvokeAsync(ReloadIfChangedAsync);
|
||||||
|
|
||||||
|
private async Task ReloadIfChangedAsync()
|
||||||
{
|
{
|
||||||
_months = months;
|
var query = AnalysisQuery.Parse(Nav.Uri, Defaults);
|
||||||
await LoadAsync();
|
if (query == _query)
|
||||||
}
|
|
||||||
|
|
||||||
private async Task LoadAsync()
|
|
||||||
{
|
|
||||||
if (_loading)
|
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_loading = true;
|
_query = query;
|
||||||
_items = null;
|
await LoadAsync(query);
|
||||||
try
|
StateHasChanged();
|
||||||
{
|
|
||||||
var asOf = DateOnly.FromDateTime(DateTime.UtcNow);
|
|
||||||
var from = asOf.AddMonths(-_months);
|
|
||||||
_items = await ConsumablesSvc.GetConsumablesAsync(new DateOnly(from.Year, from.Month, 1), asOf.AddMonths(1));
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_loading = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IReadOnlyList<SeriesChart.SeriesDef> ChartFor(ConsumableSummary item)
|
private Task RetryAsync() => _query is null ? Task.CompletedTask : LoadAsync(_query);
|
||||||
|
|
||||||
|
private Task LoadAsync(AnalysisQuery query) => _loads.RunAsync(_state, async token =>
|
||||||
{
|
{
|
||||||
var points = item.Months
|
// "Now" is read once per load (D-01); "all" spans what the tanks have data for (D-19).
|
||||||
.Select(m => new SeriesChart.Point(m.Period.ToString("MMM yy", System.Globalization.CultureInfo.InvariantCulture), m.Consumption))
|
var now = Clock.Now;
|
||||||
.ToList();
|
var availability = query.Period == PeriodPreset.AllHistory ? await ConsumablesSvc.GetAvailabilityAsync(now, token) : null;
|
||||||
return [new SeriesChart.SeriesDef($"{item.Unit} used", ApexCharts.SeriesType.Bar, points)];
|
var period = query.Resolve(now, ConsumablesSvc.Zone, availability);
|
||||||
|
var analysis = await ConsumablesSvc.GetAsync(new ConsumableRequest(period) { Bucket = query.Bucket, Comparison = query.Comparison }, token);
|
||||||
|
var tanks = analysis.Tanks.Select(t => TankView.Build(t, analysis.Quantities, query, analysis.Currency)).ToList();
|
||||||
|
|
||||||
|
// The CSV export of what the tables show: every tank's usage (D-55), within the chart's series limit.
|
||||||
|
var ids = analysis.Tanks.Select(t => t.MeterId).ToList();
|
||||||
|
var export = ids.Count is > 0 and <= MeterVault.Infrastructure.Analysis.AnalysisLimits.MaxSeries
|
||||||
|
? AnalysisLinks.Export(query.WithScope(QueryScope.ForMeters(ids)).WithMetric(AnalysisMetric.Consumption))
|
||||||
|
: null;
|
||||||
|
return new ConsumablesPageView(query, analysis, tanks, export);
|
||||||
|
}, Logger);
|
||||||
|
|
||||||
|
private void OnQueryChanged(AnalysisQuery query) => AnalysisNavigation.Replace(Nav, query, Defaults);
|
||||||
|
|
||||||
|
/// <summary>The reader's problems about one tank and the burners it feeds.</summary>
|
||||||
|
private static IReadOnlyList<MeterVault.Infrastructure.Analysis.AnalysisProblem> ProblemsOf(ConsumableAnalysis analysis, TankAnalysis tank)
|
||||||
|
{
|
||||||
|
var ids = tank.Runtime.Select(r => r.MeterId).OfType<int>().Append(tank.MeterId).ToHashSet();
|
||||||
|
var problems = (analysis.Quantities?.Problems ?? []).Concat(tank.Cost?.QuantityProblems ?? []);
|
||||||
|
return [.. problems.Where(p => p.MeterId is { } id ? ids.Contains(id) : p.MeterIds.Any(ids.Contains))];
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Color FillColor(double fraction) => fraction switch
|
public void Dispose()
|
||||||
{
|
{
|
||||||
< 0.15 => Color.Error,
|
Nav.LocationChanged -= OnLocationChanged;
|
||||||
< 0.30 => Color.Warning,
|
_loads.Dispose();
|
||||||
_ => Color.Success,
|
}
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,101 +1,251 @@
|
|||||||
@page "/"
|
@page "/"
|
||||||
|
@using MeterVault.App.Components.Pages.Overview
|
||||||
|
@using MeterVault.Core.Analysis
|
||||||
|
@using MeterVault.Core.Analysis.Coverage
|
||||||
|
@using Microsoft.AspNetCore.Components.Routing
|
||||||
|
@inject NavigationManager Nav
|
||||||
|
@inject InstanceClock Clock
|
||||||
|
@inject AnalysisPeriods Periods
|
||||||
@inject DashboardService Dash
|
@inject DashboardService Dash
|
||||||
|
@inject ILogger<Dashboard> Logger
|
||||||
|
@implements IDisposable
|
||||||
|
|
||||||
<PageTitle>MeterVault — Overview</PageTitle>
|
@* The Overview (brief §7.1): what happened in the chosen period, what changed, and where to look — the portfolio's
|
||||||
|
quantities per energy type in their own units and the bill for the same resolved period and buckets, with the
|
||||||
|
comparison over the coverage both periods share. The toolbar's range applies to every panel; a period without data
|
||||||
|
says so and offers the latest data as its own period instead of silently showing it (D-19). The update banner sits in
|
||||||
|
the header, apart from the analytical status. *@
|
||||||
|
|
||||||
<MudText Typo="Typo.h4" Class="mb-4">Overview</MudText>
|
<PageHeader Title="@S.Nav_Overview" Description="@S.Overview_Description">
|
||||||
|
<UpdateBanner />
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
@if (_summary is null)
|
<div class="mv-ov">
|
||||||
{
|
<PeriodToolbar Query="_query!" Period="_state.Value?.Period" Plan="_state.Value?.Data.Plan" Defaults="Defaults"
|
||||||
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
|
QueryChanged="OnQueryChanged" ExportHref="@ExportHref" />
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<MudGrid>
|
|
||||||
<MudItem xs="12" sm="4">
|
|
||||||
<MudPaper Class="pa-4" Elevation="2">
|
|
||||||
<MudText Typo="Typo.overline" Color="Color.Secondary">This month</MudText>
|
|
||||||
<MudText Typo="Typo.h5">@Format.Euro(_summary.Month.Current)</MudText>
|
|
||||||
<DeltaChip Kpi="_summary.Month" />
|
|
||||||
</MudPaper>
|
|
||||||
</MudItem>
|
|
||||||
<MudItem xs="12" sm="4">
|
|
||||||
<MudPaper Class="pa-4" Elevation="2">
|
|
||||||
<MudText Typo="Typo.overline" Color="Color.Secondary">This year</MudText>
|
|
||||||
<MudText Typo="Typo.h5">@Format.Euro(_summary.Year.Current)</MudText>
|
|
||||||
<DeltaChip Kpi="_summary.Year" />
|
|
||||||
</MudPaper>
|
|
||||||
</MudItem>
|
|
||||||
<MudItem xs="12" sm="4">
|
|
||||||
<MudPaper Class="pa-4" Elevation="2">
|
|
||||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Latest month with data</MudText>
|
|
||||||
<MudText Typo="Typo.h5">@Format.Euro(_summary.LatestMonthCost)</MudText>
|
|
||||||
</MudPaper>
|
|
||||||
</MudItem>
|
|
||||||
|
|
||||||
<MudItem xs="12" md="6">
|
<LoadPanel State="_state" OnRetry="RetryAsync" Context="view" Class="mt-2">
|
||||||
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
|
@if (!view.Data.IsRefused)
|
||||||
<MudText Typo="Typo.h6" Class="mb-2">What costs most (this year)</MudText>
|
|
||||||
@if (_breakdown is { Count: > 0 })
|
|
||||||
{
|
{
|
||||||
<CategoryDonut Slices="_breakdown" />
|
<OverviewCoverage Data="view.Data" />
|
||||||
<MudSimpleTable Dense="true" Hover="true" Class="mt-2">
|
|
||||||
<tbody>
|
|
||||||
@foreach (var slice in _breakdown)
|
|
||||||
{
|
|
||||||
<tr>
|
|
||||||
<td>@slice.Name</td>
|
|
||||||
<td style="text-align:right">@Format.Euro(slice.Cost)</td>
|
|
||||||
</tr>
|
|
||||||
}
|
}
|
||||||
</tbody>
|
|
||||||
</MudSimpleTable>
|
@if (view.Data.IsRefused)
|
||||||
|
{
|
||||||
|
@* The toolbar explains the refused bucket size and offers a coarser one; nothing was read. *@
|
||||||
|
}
|
||||||
|
else if (view.Data.IsPending)
|
||||||
|
{
|
||||||
|
<PendingState OnRefresh="RetryAsync" Class="mb-4" />
|
||||||
|
<AttentionList Problems="view.Problems" CostAttention="view.Data.Cost.Attention" Names="view.Names" Query="view.Query" />
|
||||||
|
}
|
||||||
|
else if (view.Data.NotYetOccurred || view.Data.HasNoData)
|
||||||
|
{
|
||||||
|
<EmptyPeriodState NotYetOccurred="view.Data.NotYetOccurred" Availability="view.Data.Availability"
|
||||||
|
LatestHref="@LatestHref(view)" Class="mb-4">
|
||||||
|
@if (view.Data.Availability is null)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2" Class="mv-muted mt-1">
|
||||||
|
@S.Dashboard_SetupNoMeters
|
||||||
|
<MudLink Href="/meters" Typo="Typo.body2">@S.Nav_Meters</MudLink> ·
|
||||||
|
<MudLink Href="/import" Typo="Typo.body2">@S.Nav_Import</MudLink>
|
||||||
|
</MudText>
|
||||||
|
}
|
||||||
|
</EmptyPeriodState>
|
||||||
|
@if (view.Data.Types.Count > 0)
|
||||||
|
{
|
||||||
|
<nav class="mv-ov-typelinks mb-4" aria-label="@S.Nav_EnergyTypes">
|
||||||
|
<span class="mv-muted">@S.Overview_TypesInPeriod</span>
|
||||||
|
@foreach (var type in view.Data.Types)
|
||||||
|
{
|
||||||
|
<MudLink Href="@AnalysisLinks.EnergyType(type.Type.Id, null, view.Query)" Typo="Typo.body2">@type.Type.Name</MudLink>
|
||||||
|
}
|
||||||
|
</nav>
|
||||||
|
}
|
||||||
|
<AttentionList Problems="view.Problems" CostAttention="view.Data.Cost.Attention" Names="view.Names" Query="view.Query" MaxItems="5" />
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<MudText Typo="Typo.body2" Color="Color.Secondary">No cost data yet — import a sheet or add tariffs.</MudText>
|
<MudGrid Spacing="3">
|
||||||
}
|
<MudItem xs="12" sm="6" lg="3">
|
||||||
</MudPaper>
|
<div class="mv-ov-lead">
|
||||||
</MudItem>
|
<OverviewCostCard Data="view.Data" Query="view.Query" />
|
||||||
|
@if (HasAttention(view))
|
||||||
<MudItem xs="12" md="6">
|
|
||||||
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
|
|
||||||
<MudText Typo="Typo.h6" Class="mb-2">What cost more / less (year vs last year)</MudText>
|
|
||||||
<MudSimpleTable Dense="true" Hover="true">
|
|
||||||
<thead>
|
|
||||||
<tr><th>Category</th><th style="text-align:right">Now</th><th style="text-align:right">Prev</th><th style="text-align:right">Δ</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@foreach (var row in _difference)
|
|
||||||
{
|
{
|
||||||
<tr>
|
<MudPaper Outlined="true" Elevation="0" Class="pa-4 mv-ov-attention">
|
||||||
<td>@row.Name</td>
|
@if (view.Data.Setup?.FirstGap is CostSetupGap.NoMeters or CostSetupGap.NoTariffs)
|
||||||
<td style="text-align:right">@Format.Euro(row.Current)</td>
|
{
|
||||||
<td style="text-align:right">@Format.Euro(row.Previous)</td>
|
<div class="mb-3">
|
||||||
<td style="text-align:right" class="@(row.Delta >= 0 ? "mv-up" : "mv-down")">
|
<h2 class="mud-typography mud-typography-subtitle2 mb-1">@S.Overview_SetupTitle</h2>
|
||||||
@Format.DirectionIcon(Math.Sign(row.Delta)) @Format.Euro(Math.Abs(row.Delta))
|
<MudText Typo="Typo.body2">
|
||||||
</td>
|
@if (view.Data.Setup.FirstGap == CostSetupGap.NoMeters)
|
||||||
</tr>
|
{
|
||||||
|
@S.Dashboard_SetupNoMeters
|
||||||
|
<MudLink Href="/meters" Typo="Typo.body2">@S.Nav_Meters</MudLink><span> · </span>
|
||||||
|
<MudLink Href="/import" Typo="Typo.body2">@S.Nav_Import</MudLink>
|
||||||
}
|
}
|
||||||
</tbody>
|
else
|
||||||
</MudSimpleTable>
|
{
|
||||||
|
@S.Dashboard_SetupNoTariffs
|
||||||
|
<MudLink Href="/admin/tariffs" Typo="Typo.body2">@S.Nav_Tariffs</MudLink>
|
||||||
|
}
|
||||||
|
</MudText>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<AttentionList Problems="view.Problems" CostAttention="view.Data.Cost.Attention" Names="view.Names"
|
||||||
|
Query="view.Query" MaxItems="4" />
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</MudItem>
|
||||||
|
@foreach (var type in view.Data.Types)
|
||||||
|
{
|
||||||
|
<MudItem xs="12" sm="6" lg="3" @key="type.Type.Id">
|
||||||
|
<OverviewTypeCard Figures="type" Query="view.Query" Currency="@view.Currency" Zone="view.Period.Zone" />
|
||||||
|
</MudItem>
|
||||||
|
}
|
||||||
|
</MudGrid>
|
||||||
|
|
||||||
|
@if (TypesWithoutMeters(view) is { Count: > 0 } empty)
|
||||||
|
{
|
||||||
|
<p class="mv-ov-typelinks mt-2">
|
||||||
|
<span class="mv-muted">@S.Overview_TypesWithoutMeters</span>
|
||||||
|
@foreach (var type in empty)
|
||||||
|
{
|
||||||
|
<MudLink Href="@AnalysisLinks.EnergyType(type.Id, null, view.Query)" Typo="Typo.body2">@type.Name</MudLink>
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* The ranges compared; the matched stretch only when less than the whole periods is compared (D-07). *@
|
||||||
|
<ComparisonSummary Period="view.Period" Resolution="view.Data.Comparison"
|
||||||
|
Matched="@(view.Data.CostChange.IsPartial || view.Data.CostChange.Basis == CostChangeBasis.NotComparable ? view.Data.CostChange.Matched : null)"
|
||||||
|
Subject="@S.AnalysisTable_Cost" Class="mt-3" />
|
||||||
|
|
||||||
|
<MudGrid Spacing="3" Class="mt-1">
|
||||||
|
<MudItem xs="12">
|
||||||
|
<OverviewHistory View="view" ChartKey="@_chartKey" ChartKeyChanged="OnChartKeyChanged" OnDrill="Drill" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" lg="7">
|
||||||
|
<OverviewChanges View="view" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" lg="5">
|
||||||
|
<OverviewComposition View="view" />
|
||||||
</MudItem>
|
</MudItem>
|
||||||
</MudGrid>
|
</MudGrid>
|
||||||
}
|
}
|
||||||
|
</LoadPanel>
|
||||||
|
</div>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private DashboardSummary? _summary;
|
private static readonly AnalysisDefaults Defaults = AnalysisDefaults.Overview;
|
||||||
private IReadOnlyList<CategorySlice> _breakdown = [];
|
|
||||||
private IReadOnlyList<DifferenceRow> _difference = [];
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
private readonly LoadSequencer _loads = new();
|
||||||
|
private readonly LoadState<OverviewView> _state = new();
|
||||||
|
private AnalysisQuery? _query;
|
||||||
|
private AnalysisQuery? _requested;
|
||||||
|
private string? _chartKey;
|
||||||
|
|
||||||
|
private string? ExportHref
|
||||||
{
|
{
|
||||||
var asOf = DateOnly.FromDateTime(DateTime.UtcNow);
|
get
|
||||||
_summary = await Dash.GetSummaryAsync(asOf);
|
{
|
||||||
|
if (_query is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
var yearStart = new DateOnly(asOf.Year, 1, 1);
|
var option = _state.Value?.Option(_chartKey);
|
||||||
_breakdown = await Dash.GetCategoryBreakdownAsync(yearStart, asOf.AddMonths(1));
|
var scoped = _query.WithScope(option?.Scope ?? QueryScope.Portfolio).WithMetric(option?.Metric ?? AnalysisMetric.Cost);
|
||||||
_difference = await Dash.GetCategoryDifferenceAsync(yearStart, yearStart.AddYears(-1), asOf.AddMonths(1));
|
return AnalysisLinks.Export(scoped);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
_query = AnalysisQuery.Parse(Nav.Uri, Defaults);
|
||||||
|
_chartKey = OverviewView.ChartKeyOf(Nav.Uri);
|
||||||
|
Nav.LocationChanged += OnLocationChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override Task OnParametersSetAsync() => ReloadIfChangedAsync();
|
||||||
|
|
||||||
|
private void OnLocationChanged(object? sender, LocationChangedEventArgs e) => _ = InvokeAsync(async () =>
|
||||||
|
{
|
||||||
|
_chartKey = OverviewView.ChartKeyOf(Nav.Uri);
|
||||||
|
StateHasChanged();
|
||||||
|
await ReloadIfChangedAsync();
|
||||||
|
StateHasChanged();
|
||||||
|
});
|
||||||
|
|
||||||
|
/// <summary>Loads when the analysis state changed; a chart selection alone is not a new analysis (D-46).</summary>
|
||||||
|
private async Task ReloadIfChangedAsync()
|
||||||
|
{
|
||||||
|
var query = AnalysisQuery.Parse(Nav.Uri, Defaults);
|
||||||
|
if (query == _requested)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_requested = query;
|
||||||
|
_query = query;
|
||||||
|
await LoadAsync(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task RetryAsync() => LoadAsync(_query!);
|
||||||
|
|
||||||
|
private Task LoadAsync(AnalysisQuery query) => _loads.RunAsync(_state, async token =>
|
||||||
|
{
|
||||||
|
// "Now" once per load (D-01); the whole page answers this one period.
|
||||||
|
var now = Clock.Now;
|
||||||
|
var period = query.Period == PeriodPreset.AllHistory
|
||||||
|
? query.Resolve(now, Periods.Zone, await AllHistoryAsync(query, now, token))
|
||||||
|
: await Periods.ResolveAsync(query, now, token);
|
||||||
|
var data = await Dash.GetOverviewAsync(period, query.Bucket, query.Comparison, token);
|
||||||
|
return OverviewView.Build(query, data);
|
||||||
|
}, Logger);
|
||||||
|
|
||||||
|
/// <summary>All history on the Overview spans both what the meters measured and the bill (manual costs included, D-19).</summary>
|
||||||
|
private async Task<AvailableRange?> AllHistoryAsync(AnalysisQuery query, DateTimeOffset now, CancellationToken token)
|
||||||
|
{
|
||||||
|
var quantities = await Periods.AvailabilityAsync(query.WithMetric(null), now, token);
|
||||||
|
var costs = await Periods.AvailabilityAsync(query.WithMetric(AnalysisMetric.Cost), now, token);
|
||||||
|
return AvailableRange.Union([quantities, costs], Periods.Zone);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnQueryChanged(AnalysisQuery query) => AnalysisNavigation.Replace(Nav, query, Defaults);
|
||||||
|
|
||||||
|
/// <summary>The chart selection goes into the address (replace): a reload or a shared link shows the same (D-46).</summary>
|
||||||
|
private void OnChartKeyChanged(string key)
|
||||||
|
{
|
||||||
|
_chartKey = key == OverviewView.CostKey ? null : key;
|
||||||
|
Nav.NavigateTo(Nav.GetUriWithQueryParameter(OverviewView.ChartParameter, _chartKey), replace: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A clicked bucket opens on the Overview itself, one size finer (D-51) — a drill-down pushes.</summary>
|
||||||
|
private void Drill((AnalysisBucket Bucket, ResolutionClass? Resolution) click)
|
||||||
|
{
|
||||||
|
if (_query is not null && AnalysisNavigation.DrillInto(_query, click.Bucket, click.Resolution) is { } next)
|
||||||
|
{
|
||||||
|
Nav.NavigateTo(AnalysisNavigation.UriFor(Nav, next, Defaults));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// "Go to latest data": a period of the same kind ending with the latest data — for month to date the latest month
|
||||||
|
/// with data — opened as its own period on the Overview (brief §4.3). Nothing moves by itself.
|
||||||
|
/// </summary>
|
||||||
|
private static string? LatestHref(OverviewView view) =>
|
||||||
|
AnalysisNavigation.LatestData(view.Query, view.Data.Availability) is { } target ? AnalysisLinks.Overview(target) : null;
|
||||||
|
|
||||||
|
private static bool HasAttention(OverviewView view) =>
|
||||||
|
view.AttentionCount > 0 || view.Data.Setup?.FirstGap is CostSetupGap.NoMeters or CostSetupGap.NoTariffs;
|
||||||
|
|
||||||
|
private static List<OverviewEnergyType> TypesWithoutMeters(OverviewView view) => [.. view.Data.EnergyTypes.Where(t => !t.HasMeters)];
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Nav.LocationChanged -= OnLocationChanged;
|
||||||
|
_loads.Dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
/* The Overview's panels (Components/Pages/Overview): one look for their heads and footers. Palette variables only, so
|
||||||
|
light and dark mode both work; everything wraps down to 360px and wide tables scroll inside their own box. */
|
||||||
|
|
||||||
|
.mv-ov ::deep .mv-ov-panel {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-ov ::deep .mv-ov-panel__head {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px 16px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-ov ::deep .mv-ov-panel__title {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-ov ::deep .mv-ov-panel__controls {
|
||||||
|
flex: 0 1 320px;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-ov ::deep .mv-ov-panel__foot {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 4px 16px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-ov ::deep .mv-ov-typelinks {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 4px 12px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The first column: the period's cost, and under it what needs attention, together as tall as the type cards. */
|
||||||
|
.mv-ov ::deep .mv-ov-lead {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-ov ::deep .mv-ov-lead .mv-metric {
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-ov ::deep .mv-ov-lead .mv-metric:only-child {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-ov ::deep .mv-ov-attention {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-ov ::deep .mv-ov-card {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* In the narrow first column: the icon beside the words, the action under them. */
|
||||||
|
.mv-ov ::deep .mv-ov-attention .mv-attention__item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
column-gap: 8px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-ov ::deep .mv-ov-attention .mv-attention__icon {
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-ov ::deep .mv-ov-attention .mv-attention__action {
|
||||||
|
grid-column: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The change table and the composition: names and status words wrap, amounts do not. The tone classes win over the
|
||||||
|
table cell's own text colour; the total row is set apart. */
|
||||||
|
.mv-ov ::deep .mv-ov-amount {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-ov ::deep .mv-ov-changes th.mv-num,
|
||||||
|
.mv-ov ::deep .mv-ov-changes td.mv-num,
|
||||||
|
.mv-ov ::deep .mv-ov-composition th.mv-num,
|
||||||
|
.mv-ov ::deep .mv-ov-composition td.mv-num {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-ov ::deep .mv-ov-changes td.mv-change-good { color: var(--mud-palette-success); }
|
||||||
|
.mv-ov ::deep .mv-ov-changes td.mv-change-bad { color: var(--mud-palette-error); }
|
||||||
|
.mv-ov ::deep .mv-ov-changes td.mv-change-neutral { color: var(--mud-palette-text-secondary); }
|
||||||
|
|
||||||
|
.mv-ov ::deep .mv-ov-total > td {
|
||||||
|
font-weight: 600;
|
||||||
|
border-top: 2px solid var(--mud-palette-lines-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 600px) {
|
||||||
|
.mv-ov ::deep .mv-ov-changes td.mv-num,
|
||||||
|
.mv-ov ::deep .mv-ov-composition td.mv-num {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 599.98px) {
|
||||||
|
.mv-ov ::deep .mv-ov-changes .mud-table-row + .mud-table-row > td:first-child,
|
||||||
|
.mv-ov ::deep .mv-ov-composition .mud-table-row + .mud-table-row > td:first-child {
|
||||||
|
border-top: 1px solid var(--mud-palette-lines-default);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
@using MeterVault.App.Energy
|
||||||
|
@using MeterVault.Core.Analysis
|
||||||
|
|
||||||
|
@* The energy type's Flow (brief §7.3, D-30): the Sankey as a topology tool, fed with the canonical period totals — a
|
||||||
|
virtual sum drawn from its calculation inputs (marked calculated), a meter below several others split in proportion
|
||||||
|
(marked estimated) — and its table equivalent: every ribbon with what it is, every meter with its signed value or its
|
||||||
|
status in words, and why a meter is not drawn. "Manage connections" edits the topology. No topology never stops the
|
||||||
|
analysis: the other tabs do not depend on it. *@
|
||||||
|
|
||||||
|
@if (Analysis.Flow is { } flow)
|
||||||
|
{
|
||||||
|
<div class="mv-energy-flow">
|
||||||
|
<div class="mv-energy-flow__head">
|
||||||
|
<div class="mv-energy-flow__intro">
|
||||||
|
<MudText Typo="Typo.h6">@S.EnergyView_Flow</MudText>
|
||||||
|
<MudText Typo="Typo.body2" Class="mv-muted">@Loc.F(S.EnergyView_FlowCaption, flow.Unit)</MudText>
|
||||||
|
</div>
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Hub"
|
||||||
|
OnClick="() => ManageConnections.InvokeAsync()">@S.EnergyView_ManageConnections</MudButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!flow.HasChain)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-3">@S.EnergyView_NoConnections</MudAlert>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<SankeyChart Nodes="flow.Nodes" Links="flow.Links" Unit="@flow.Unit" />
|
||||||
|
<ul class="mv-energy-flow__legend" aria-label="@S.EnergyView_Legend">
|
||||||
|
<li><span class="mv-swatch mv-swatch--measured" aria-hidden="true"></span>@S.EnergyView_LegendMeasured</li>
|
||||||
|
<li><span class="mv-swatch mv-swatch--calculated" aria-hidden="true"></span>@S.EnergyView_LegendCalculated</li>
|
||||||
|
<li><span class="mv-swatch mv-swatch--estimated" aria-hidden="true"></span>@S.EnergyView_LegendEstimated</li>
|
||||||
|
<li><span class="mv-swatch mv-swatch--other" aria-hidden="true"></span>@S.EnergyView_LegendOther</li>
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
|
||||||
|
<MudText Typo="Typo.subtitle1" Class="mt-4 mb-1">@S.EnergyView_FlowTable</MudText>
|
||||||
|
<MudGrid Spacing="3">
|
||||||
|
@if (flow.Links.Count > 0)
|
||||||
|
{
|
||||||
|
<MudItem xs="12" lg="7">
|
||||||
|
<div class="mv-table-scroll" role="region" aria-label="@S.EnergyView_Connections" tabindex="0">
|
||||||
|
<MudSimpleTable Dense="true" Hover="true" Class="mv-analysis-table">
|
||||||
|
<caption class="mv-sr-only">@S.EnergyView_Connections</caption>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">@S.EnergyView_ColFrom</th>
|
||||||
|
<th scope="col">@S.EnergyView_ColTo</th>
|
||||||
|
<th scope="col" class="mv-num">@S.Common_Amount</th>
|
||||||
|
<th scope="col">@S.Common_Type</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var link in flow.Links.OrderBy(l => FlowText.NodeName(flow, l.From), StringComparer.CurrentCultureIgnoreCase).ThenByDescending(l => l.Value))
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td>@FlowText.NodeName(flow, link.From)</td>
|
||||||
|
<td>@FlowText.NodeName(flow, link.To)</td>
|
||||||
|
<td class="mv-num">@Format.Quantity(link.Value, flow.Unit)</td>
|
||||||
|
<td class="mv-energy-flow__kind">@FlowText.EdgeKind(flow, link)</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</MudSimpleTable>
|
||||||
|
</div>
|
||||||
|
</MudItem>
|
||||||
|
}
|
||||||
|
<MudItem xs="12" lg="@(flow.Links.Count > 0 ? 5 : 12)">
|
||||||
|
<div class="mv-table-scroll" role="region" aria-label="@S.Common_Meters" tabindex="0">
|
||||||
|
<MudSimpleTable Dense="true" Hover="true" Class="mv-analysis-table">
|
||||||
|
<caption class="mv-sr-only">@S.Common_Meters</caption>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">@S.Common_Meter</th>
|
||||||
|
<th scope="col" class="mv-num">@S.Meters_ColValue</th>
|
||||||
|
<th scope="col">@S.EnergyView_ColDiagram</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var meter in flow.Meters.OrderBy(m => m.Name, StringComparer.CurrentCultureIgnoreCase))
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td><MudLink Href="@MeterLinks.Analysis(meter.MeterId, Query)" Typo="Typo.body2">@meter.Name</MudLink></td>
|
||||||
|
<td class="mv-num @(meter.Value is null ? "mv-unknown" : null)">@FlowText.MeterValue(meter)</td>
|
||||||
|
<td class="mv-energy-flow__kind">@(FlowText.NotDrawnReason(flow, meter) ?? S.EnergyView_InDiagram)</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</MudSimpleTable>
|
||||||
|
</div>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>The page's committed value.</summary>
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public EnergyAnalysis Analysis { get; set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>The page's analysis state (meter links carry its period).</summary>
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public AnalysisQuery Query { get; set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>Opens the page's "Manage connections" dialog.</summary>
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback ManageConnections { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
.mv-energy-flow__head {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-energy-flow__intro {
|
||||||
|
flex: 1 1 320px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-energy-flow__legend {
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 16px;
|
||||||
|
margin: 8px 0 0 0;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--mud-palette-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-energy-flow__legend li {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-swatch {
|
||||||
|
display: inline-block;
|
||||||
|
width: 22px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 2px;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-swatch--measured {
|
||||||
|
background: var(--mud-palette-text-secondary);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-swatch--calculated {
|
||||||
|
border: 1.5px dashed var(--mud-palette-text-secondary);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-swatch--estimated {
|
||||||
|
border: 1.5px dotted var(--mud-palette-text-primary);
|
||||||
|
background: var(--mud-palette-action-disabled-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-swatch--other {
|
||||||
|
background: #78909C;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-energy-flow ::deep td.mv-energy-flow__kind {
|
||||||
|
white-space: normal;
|
||||||
|
min-width: 24ch;
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
@using MeterVault.App.Energy
|
||||||
|
@using MeterVault.Core.Analysis
|
||||||
|
@using MeterVault.Infrastructure.Analysis
|
||||||
|
@inject NavigationManager Nav
|
||||||
|
|
||||||
|
@* The energy type's History (brief §7.3): the shared chart and table for the chosen metric, over the page's period and
|
||||||
|
interval, compared with the chosen period (a calendar year for a year). "Total" charts the type's measures — never a
|
||||||
|
breakdown on top of its parent or a calculated view on top of its sources (D-22); "Individual meters" charts the
|
||||||
|
meters side by side and says how each one counts, so their bars are not read as adding up. Signed values stay signed.
|
||||||
|
A bucket opens its finer detail (D-51). *@
|
||||||
|
|
||||||
|
<div class="mv-energy-history">
|
||||||
|
<div class="mv-energy-history__views">
|
||||||
|
<MudToggleGroup T="string" Value="@View" ValueChanged="OnViewChangedAsync" SelectionMode="SelectionMode.SingleSelection"
|
||||||
|
Outlined="true" Color="Color.Primary" Size="Size.Small" aria-label="@S.EnergyView_ViewLabel"
|
||||||
|
Class="mv-energy-history__toggle">
|
||||||
|
<MudToggleItem T="string" Value="@EnergyPageKeys.ViewTotal" Text="@S.EnergyView_ViewTotal" />
|
||||||
|
<MudToggleItem T="string" Value="@EnergyPageKeys.ViewMeters" Text="@S.EnergyView_ViewMeters" />
|
||||||
|
</MudToggleGroup>
|
||||||
|
<MudText Typo="Typo.caption" Class="mv-muted">
|
||||||
|
@(_view?.IsIndividual == true ? S.EnergyView_ViewMetersHelp : S.EnergyView_ViewTotalHelp)
|
||||||
|
</MudText>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_view is null || _quantities is null)
|
||||||
|
{
|
||||||
|
@* Nothing read yet: the page shows its own loading state. *@
|
||||||
|
}
|
||||||
|
else if (_view.IsEmpty)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Info" Dense="true">@EmptyText()</MudAlert>
|
||||||
|
}
|
||||||
|
else if (_view.Main is { IsPending: true })
|
||||||
|
{
|
||||||
|
<PendingState OnRefresh="OnRefresh" />
|
||||||
|
}
|
||||||
|
else if (_quantities.NotYetOccurred || NoData())
|
||||||
|
{
|
||||||
|
<EmptyPeriodState NotYetOccurred="_quantities.NotYetOccurred" Availability="Availability()" LatestHref="@LatestHref()" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<ComparisonSummary Period="Analysis.Period" Resolution="ComparisonResolution()" Matched="_view.Main?.Comparison?.Matched"
|
||||||
|
Subject="@MainName()" Class="mb-3" />
|
||||||
|
<AnalysisChart Buckets="_buckets" Series="_view.Chart" ComparisonPairs="_pairs" Title="@_title" OnBucketClick="_onBucketClick"
|
||||||
|
Resolution="_view.Coarsest" OnUseBucket="UseBucket" />
|
||||||
|
|
||||||
|
@if (_view.IsIndividual)
|
||||||
|
{
|
||||||
|
@if (_view.Hidden > 0)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2" Class="mv-muted mt-2">
|
||||||
|
@Loc.F(S.EnergyView_MoreMeters, _view.Shown.Count, _view.Shown.Count + _view.Hidden)
|
||||||
|
<MudLink Href="@AnalysisLinks.Analysis(QueryScope.ForEnergyType(Analysis.EnergyTypeId), _view.Metric, Query)" Typo="Typo.body2">@S.Nav_Analysis</MudLink>
|
||||||
|
</MudText>
|
||||||
|
}
|
||||||
|
@if (_view.Memberships.Count > 0)
|
||||||
|
{
|
||||||
|
<section class="mv-energy-history__counts" aria-labelledby="@_countsId">
|
||||||
|
<MudText Typo="Typo.subtitle2" id="@_countsId">@S.EnergyView_HowCounted</MudText>
|
||||||
|
<ul>
|
||||||
|
@foreach (var (series, membership) in _view.Memberships)
|
||||||
|
{
|
||||||
|
<li>
|
||||||
|
<MudIcon Icon="@MeterListRows.MembershipIcon(membership)" Size="Size.Small" aria-hidden="true" Class="mv-muted" />
|
||||||
|
<span>
|
||||||
|
<MudLink Href="@MeterLinks.Analysis(series.MeterId!.Value, Query)" Typo="Typo.body2">@series.Name</MudLink>:
|
||||||
|
@membership.Label@(string.IsNullOrEmpty(membership.Detail) ? null : " — " + membership.Detail)
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (_view.Metric == AnalysisMetric.Cost && Analysis.Cost is { } cost)
|
||||||
|
{
|
||||||
|
<AttentionList CostAttention="cost.Attention" Problems="cost.QuantityProblems" Names="Analysis.Names" Query="Query"
|
||||||
|
MaxItems="3" Class="mt-3" />
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<AnalysisTable Buckets="_buckets" Series="_view.Table" ComparisonPairs="_pairs" Caption="@_title" DrillHref="_drillHref" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>The page's committed value.</summary>
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public EnergyAnalysis Analysis { get; set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>The page's analysis state.</summary>
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public AnalysisQuery Query { get; set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>The page's defaults (drill-downs and "latest data" keep the page's other keys).</summary>
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public AnalysisDefaults Defaults { get; set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>The metric shown (<see cref="EnergyMetrics.Effective"/>).</summary>
|
||||||
|
[Parameter]
|
||||||
|
public AnalysisMetric Metric { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The view key (<see cref="EnergyPageKeys.ViewTotal"/> or <see cref="EnergyPageKeys.ViewMeters"/>).</summary>
|
||||||
|
[Parameter]
|
||||||
|
public string View { get; set; } = EnergyPageKeys.ViewTotal;
|
||||||
|
|
||||||
|
/// <summary>The view was switched; the page writes it into its address.</summary>
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback<string> ViewChanged { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Loads again (analysis being prepared).</summary>
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback OnRefresh { get; set; }
|
||||||
|
|
||||||
|
private readonly string _countsId = "mv-counts-" + Guid.NewGuid().ToString("N")[..8];
|
||||||
|
private object? _builtFrom;
|
||||||
|
private EnergyHistoryView? _view;
|
||||||
|
private AnalysisResult? _quantities;
|
||||||
|
private IReadOnlyList<AnalysisBucket> _buckets = [];
|
||||||
|
private IReadOnlyList<BucketPair>? _pairs;
|
||||||
|
private string _title = string.Empty;
|
||||||
|
private EventCallback<AnalysisBucket> _onBucketClick;
|
||||||
|
private Func<AnalysisBucket, string?>? _drillHref;
|
||||||
|
|
||||||
|
protected override void OnParametersSet()
|
||||||
|
{
|
||||||
|
// Rebuilt only for a new value, metric, view or comparison: the chart re-keys on a new list.
|
||||||
|
var source = (Analysis, Metric, View, Query.Comparison);
|
||||||
|
if (Equals(_builtFrom, source))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_builtFrom = source;
|
||||||
|
_quantities = Analysis.Quantities;
|
||||||
|
_view = EnergyHistoryView.Build(Analysis, Metric, View == EnergyPageKeys.ViewMeters, Query.Comparison);
|
||||||
|
if (_view.Metric == AnalysisMetric.Cost && Analysis.Cost is { } cost)
|
||||||
|
{
|
||||||
|
_buckets = cost.Plan.Buckets;
|
||||||
|
_pairs = Analysis.CostComparison?.Pairs is { Count: > 0 } pairs ? pairs : null;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_buckets = _quantities?.Plan.Buckets ?? [];
|
||||||
|
_pairs = _quantities?.Comparison?.Buckets;
|
||||||
|
}
|
||||||
|
|
||||||
|
var typeName = Analysis.Type?.Name ?? string.Empty;
|
||||||
|
_title = Loc.F(S.EnergyView_ChartTitle, Metric.Display(), typeName);
|
||||||
|
|
||||||
|
// Buckets are clickable, and the table has its drill column, only when some bucket leads somewhere (D-51): monthly
|
||||||
|
// data has no days to open, and a click that does nothing is a dead end.
|
||||||
|
var drills = _buckets.Any(b => DrillHref(b) is not null);
|
||||||
|
_onBucketClick = drills ? EventCallback.Factory.Create<AnalysisBucket>(this, DrillAsync) : default;
|
||||||
|
_drillHref = drills ? DrillHref : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The buckets are finer than the data: open the interval that shows it (replacing the address, D-46).</summary>
|
||||||
|
private void UseBucket(BucketSize size) => AnalysisNavigation.Replace(Nav, Query.WithBucket(size), Defaults);
|
||||||
|
|
||||||
|
private async Task OnViewChangedAsync(string? view) =>
|
||||||
|
await ViewChanged.InvokeAsync(EnergyPageKeys.ResolveView(view));
|
||||||
|
|
||||||
|
private string EmptyText()
|
||||||
|
{
|
||||||
|
if (_view!.Metric == AnalysisMetric.Cost)
|
||||||
|
{
|
||||||
|
return S.EnergyView_CostPerMeterNote;
|
||||||
|
}
|
||||||
|
|
||||||
|
return _view.IsIndividual
|
||||||
|
? Loc.F(S.EnergyView_NoMetersForMetric, _view.Metric.Display())
|
||||||
|
: Loc.F(S.EnergyView_NoTotalForMetric, _view.Metric.Display());
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool NoData()
|
||||||
|
{
|
||||||
|
if (_view!.Metric == AnalysisMetric.Cost)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var shown = _view.IsIndividual ? _view.Shown : EnergyMetrics.MeasuresOf(_quantities, _view.Metric);
|
||||||
|
return _view.HasNoData(shown);
|
||||||
|
}
|
||||||
|
|
||||||
|
private MeterVault.Core.Analysis.Coverage.AvailableRange? Availability() =>
|
||||||
|
_view?.Main?.Availability ?? _quantities?.Availability.Quantity;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The name of the series the comparison line speaks of, when the view charts several whose matched coverage
|
||||||
|
/// may differ (A-41); null when it charts one and the line cannot be misread.
|
||||||
|
/// </summary>
|
||||||
|
private string? MainName() => _view is { Table.Count: > 1 } view ? view.Table[0].Name : null;
|
||||||
|
|
||||||
|
private ComparisonResolution? ComparisonResolution() =>
|
||||||
|
_view?.Metric == AnalysisMetric.Cost ? Analysis.CostComparison?.Resolution : _quantities?.Comparison?.Resolution;
|
||||||
|
|
||||||
|
private string? LatestHref() =>
|
||||||
|
AnalysisNavigation.LatestData(Query, Availability()) is { } latest ? AnalysisNavigation.UriFor(Nav, latest, Defaults) : null;
|
||||||
|
|
||||||
|
private string? DrillHref(AnalysisBucket bucket) =>
|
||||||
|
AnalysisNavigation.DrillInto(Query, bucket, _view?.Coarsest) is { } next ? AnalysisNavigation.UriFor(Nav, next, Defaults) : null;
|
||||||
|
|
||||||
|
/// <summary>A chart bucket opens its finer detail (D-51), pushing a history entry so Back returns here.</summary>
|
||||||
|
private void DrillAsync(AnalysisBucket bucket)
|
||||||
|
{
|
||||||
|
if (DrillHref(bucket) is { } href)
|
||||||
|
{
|
||||||
|
Nav.NavigateTo(href);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
.mv-energy-history__views {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-energy-history__views ::deep .mv-energy-history__toggle {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-energy-history__counts {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-energy-history__counts ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 4px 0 0 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-energy-history__counts li {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 6px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
@using MeterVault.App.Energy
|
||||||
|
@using MeterVault.Core.Analysis
|
||||||
|
@using MeterVault.Core.Analysis.Coverage
|
||||||
|
@using MeterVault.Core.Analysis.Totals
|
||||||
|
@using MeterVault.Infrastructure.Analysis
|
||||||
|
@inject NavigationManager Nav
|
||||||
|
|
||||||
|
@* The energy type's Overview (brief §7.3): one card per measure — use, grid import, export, generation, runtime, each
|
||||||
|
with its own unit and status, never added to each other (D-22) — the type's bill with its basis and standing charge
|
||||||
|
(D-34, D-40), the comparison over matched coverage (D-07), a compact trend, the data's coverage, and the meters that
|
||||||
|
changed most. *@
|
||||||
|
|
||||||
|
@if (_quantities is not null)
|
||||||
|
{
|
||||||
|
<div class="mv-energy-overview">
|
||||||
|
<AttentionList Problems="Analysis.Problems" CostAttention="Analysis.Cost?.Attention" Names="Analysis.Names" Query="Query"
|
||||||
|
MaxItems="4" Class="mb-4" />
|
||||||
|
|
||||||
|
@if (_pending)
|
||||||
|
{
|
||||||
|
<PendingState OnRefresh="OnRefresh" Class="mb-4" />
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (_measures.Count == 0)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-4">
|
||||||
|
@S.EnergyView_NoMeasures
|
||||||
|
<MudLink Href="@AnalysisLinks.EnergyType(Analysis.EnergyTypeId, AnalysisLinks.EnergyTabMeters, Query)">@S.Nav_Meters</MudLink>
|
||||||
|
</MudAlert>
|
||||||
|
}
|
||||||
|
else if (_quantities.NotYetOccurred || _noData)
|
||||||
|
{
|
||||||
|
<EmptyPeriodState NotYetOccurred="_quantities.NotYetOccurred" Availability="_quantities.Availability.Quantity"
|
||||||
|
LatestHref="@LatestHref" Class="mb-4" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudGrid Spacing="3" Class="mb-1">
|
||||||
|
@foreach (var series in _measures)
|
||||||
|
{
|
||||||
|
<MudItem xs="12" sm="6" lg="3">
|
||||||
|
<MetricCard Title="@EnergyHistoryView.MeasureName(series, _measures)" Value="series.Total" Unit="@series.Unit"
|
||||||
|
Change="@ChangeOf(series)" Polarity="ChangePolarities.For(series.Kind)" ChangeCaption="@_comparisonCaption"
|
||||||
|
Caption="@MembersCaption(series)" Href="@HistoryHref(AnalysisMetrics.MetricOf(series.Kind))"
|
||||||
|
LinkText="@S.EnergyView_OpenHistory" />
|
||||||
|
</MudItem>
|
||||||
|
}
|
||||||
|
@if (Analysis.Cost is { } cost && Analysis.Metrics.Contains(AnalysisMetric.Cost))
|
||||||
|
{
|
||||||
|
<MudItem xs="12" sm="6" lg="3">
|
||||||
|
<MetricCard Title="@S.AnalysisTable_Cost" Cost="cost.Total" Currency="@cost.Currency" Caption="@_costBasis"
|
||||||
|
Change="_costChange" Polarity="_costPolarity" ChangeCaption="@_costCaption"
|
||||||
|
Href="@HistoryHref(AnalysisMetric.Cost)" LinkText="@S.EnergyView_OpenHistory">
|
||||||
|
@foreach (var line in _costLines)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.caption" Class="mv-muted d-block">@line</MudText>
|
||||||
|
}
|
||||||
|
</MetricCard>
|
||||||
|
</MudItem>
|
||||||
|
}
|
||||||
|
</MudGrid>
|
||||||
|
|
||||||
|
<ComparisonSummary Period="Analysis.Period" Resolution="_quantities.Comparison?.Resolution" Matched="_main?.Comparison?.Matched"
|
||||||
|
Subject="@_comparisonSubject" Class="mt-3 mb-4" />
|
||||||
|
|
||||||
|
<MudGrid Spacing="3">
|
||||||
|
<MudItem xs="12" md="8">
|
||||||
|
<MudPaper Outlined="true" Elevation="0" Class="pa-4 mv-energy-panel">
|
||||||
|
<div class="mv-energy-panel__head">
|
||||||
|
<MudText Typo="Typo.h6" Class="mv-energy-panel__title">@_trendTitle</MudText>
|
||||||
|
<MudLink Href="@HistoryHref(_main is null ? null : AnalysisMetrics.MetricOf(_main.Kind))" Typo="Typo.body2">@S.EnergyView_OpenHistory</MudLink>
|
||||||
|
</div>
|
||||||
|
<AnalysisChart Buckets="_quantities.Plan.Buckets" Series="_trend" ComparisonPairs="_quantities.Comparison?.Buckets"
|
||||||
|
Title="@_trendTitle" Height="240" Resolution="_main?.Resolution" />
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="4">
|
||||||
|
<MudPaper Outlined="true" Elevation="0" Class="pa-4 mv-energy-panel">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">@S.EnergyView_Coverage</MudText>
|
||||||
|
@if (_quantities.Availability.Quantity is { } available)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2">@Loc.F(S.Empty_AvailableRange, Format.Date(available.FirstDay), Format.Date(available.LastDay))</MudText>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2">@S.Empty_NoDataYet</MudText>
|
||||||
|
}
|
||||||
|
<dl class="mv-energy-coverage">
|
||||||
|
@foreach (var series in _measures)
|
||||||
|
{
|
||||||
|
var status = FigureText.Of(series.Total, Analysis.Names.MeterOrNull);
|
||||||
|
<dt>@EnergyHistoryView.MeasureName(series, _measures)</dt>
|
||||||
|
<dd>
|
||||||
|
@status.Summary
|
||||||
|
@if (series.Resolution is { } resolution)
|
||||||
|
{
|
||||||
|
<span> · @resolution.Display()</span>
|
||||||
|
}
|
||||||
|
@if (series.Freshness.State != FreshnessState.NoData)
|
||||||
|
{
|
||||||
|
<span> · @series.Freshness.State.Display()</span>
|
||||||
|
}
|
||||||
|
@if (status.IsQualified && status.Detail is { } detail)
|
||||||
|
{
|
||||||
|
<div class="mv-cell-secondary">@detail</div>
|
||||||
|
}
|
||||||
|
@* Its own dates whenever they are not the scope's: a measure counted by a tank dipped
|
||||||
|
once a year must not be read against the range of the burner beside it (A-41). *@
|
||||||
|
@if (OwnRange(series) is { } own)
|
||||||
|
{
|
||||||
|
<div class="mv-cell-secondary">@Loc.F(S.Empty_AvailableRange, Format.Date(own.FirstDay), Format.Date(own.LastDay))</div>
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
}
|
||||||
|
</dl>
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12">
|
||||||
|
<MudPaper Outlined="true" Elevation="0" Class="pa-4">
|
||||||
|
@* A-43: the ranked changes, then every other meter that has data in either period — with its values and
|
||||||
|
the status that says why there is no change. A meter with data is never silently left out. *@
|
||||||
|
<MudText Typo="Typo.h6">@S.EnergyView_LargestChanges</MudText>
|
||||||
|
@if (_quantities.Comparison is not { IsApplicable: true })
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2" Class="mv-muted mt-1">@S.EnergyView_NoComparison</MudText>
|
||||||
|
}
|
||||||
|
else if (_changes.IsEmpty)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2" Class="mv-muted mt-1">@S.EnergyView_NoComparableMeters</MudText>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@if (_changes.Ranked.Count > 0)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.caption" Class="mv-muted d-block mb-2">@S.EnergyView_LargestChangesNote</MudText>
|
||||||
|
}
|
||||||
|
<div class="mv-table-scroll" role="region" aria-label="@S.EnergyView_LargestChanges" tabindex="0">
|
||||||
|
<MudSimpleTable Dense="true" Hover="true" Class="mv-analysis-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">@S.Common_Meter</th>
|
||||||
|
<th scope="col" class="mv-num">@S.EnergyView_ColCurrent</th>
|
||||||
|
<th scope="col" class="mv-num">@S.AnalysisTable_Comparison</th>
|
||||||
|
<th scope="col">@S.AnalysisTable_Change</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var change in _changes.Ranked)
|
||||||
|
{
|
||||||
|
var series = change.Series;
|
||||||
|
<tr>
|
||||||
|
<th scope="row" class="mv-row-label">@MeterCell(series)</th>
|
||||||
|
<td class="mv-num">
|
||||||
|
@Format.Quantity(change.Current, series.Unit)
|
||||||
|
<div class="mv-cell-secondary">@Format.DateRange(change.Matched.Current!.FirstDay, change.Matched.Current.LastDay)</div>
|
||||||
|
</td>
|
||||||
|
<td class="mv-num">
|
||||||
|
@Format.Quantity(change.Previous, series.Unit)
|
||||||
|
<div class="mv-cell-secondary">@Format.DateRange(change.Matched.Comparison!.FirstDay, change.Matched.Comparison.LastDay)</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<ChangeChip Change="change.Change" Polarity="ChangePolarities.For(series.Kind)"
|
||||||
|
FormatMagnitude="@(v => Format.Quantity(v, series.Unit))" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
@if (_changes.Rest.Count > 0)
|
||||||
|
{
|
||||||
|
<tbody>
|
||||||
|
@if (_changes.Ranked.Count > 0)
|
||||||
|
{
|
||||||
|
<tr class="mv-row-group">
|
||||||
|
<th scope="colgroup" colspan="4">
|
||||||
|
@S.EnergyView_ChangesRest
|
||||||
|
<div class="mv-cell-secondary">@S.EnergyView_ChangesRestNote</div>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
@foreach (var row in _changes.Rest)
|
||||||
|
{
|
||||||
|
var series = row.Series;
|
||||||
|
<tr>
|
||||||
|
<th scope="row" class="mv-row-label">@MeterCell(series)</th>
|
||||||
|
@FigureCell(row.Current, series)
|
||||||
|
@FigureCell(row.Comparison, series)
|
||||||
|
<td>
|
||||||
|
<ChangeChip Change="row.Change" Polarity="ChangePolarities.For(series.Kind)"
|
||||||
|
FormatMagnitude="@(v => Format.Quantity(v, series.Unit))" />
|
||||||
|
@if (row.SharesNoDays)
|
||||||
|
{
|
||||||
|
<div class="mv-cell-secondary">@S.Comparison_NotComparable</div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
}
|
||||||
|
</MudSimpleTable>
|
||||||
|
</div>
|
||||||
|
@if (_changes.Hidden > 0)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2" Class="mv-muted mt-2">
|
||||||
|
@Loc.F(S.EnergyView_MoreMeters, _changes.Shown, _changes.Shown + _changes.Hidden)
|
||||||
|
<MudLink Href="@AnalysisLinks.EnergyType(Analysis.EnergyTypeId, AnalysisLinks.EnergyTabMeters, Query)" Typo="Typo.body2">@S.Nav_Meters</MudLink>
|
||||||
|
</MudText>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>The page's committed value.</summary>
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public EnergyAnalysis Analysis { get; set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>The page's analysis state (links carry its period).</summary>
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public AnalysisQuery Query { get; set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>The page's defaults, for "go to latest data" on the page itself.</summary>
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public AnalysisDefaults Defaults { get; set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>Loads again (analysis being prepared).</summary>
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback OnRefresh { get; set; }
|
||||||
|
|
||||||
|
private const int MaxChanges = 6;
|
||||||
|
|
||||||
|
private EnergyAnalysis? _builtFor;
|
||||||
|
private AnalysisResult? _quantities;
|
||||||
|
private List<AnalysisSeries> _measures = [];
|
||||||
|
private AnalysisSeries? _main;
|
||||||
|
private IReadOnlyList<AnalysisChartSeries> _trend = [];
|
||||||
|
private MeterChangeList _changes = MeterChangeList.None;
|
||||||
|
private string _trendTitle = string.Empty;
|
||||||
|
private string? _comparisonCaption;
|
||||||
|
private string? _comparisonSubject;
|
||||||
|
private string? _costBasis;
|
||||||
|
private List<string> _costLines = [];
|
||||||
|
private Change? _costChange;
|
||||||
|
private string? _costCaption;
|
||||||
|
private ChangePolarity _costPolarity = ChangePolarity.HigherIsWorse;
|
||||||
|
private bool _pending;
|
||||||
|
private bool _noData;
|
||||||
|
|
||||||
|
protected override void OnParametersSet()
|
||||||
|
{
|
||||||
|
if (ReferenceEquals(_builtFor, Analysis))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Built once per committed value (the chart re-keys on a new list): every text in the reader's culture.
|
||||||
|
_builtFor = Analysis;
|
||||||
|
_quantities = Analysis.Quantities;
|
||||||
|
if (_quantities is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_measures = [.. _quantities.Measures.OrderBy(s => s.Key.Measure).ThenBy(s => s.Unit, StringComparer.Ordinal)];
|
||||||
|
_main = _measures.FirstOrDefault(s => s.Key.Measure == TotalsMeasure.Use) ?? _measures.FirstOrDefault();
|
||||||
|
_pending = _measures.Any(s => s.IsPending);
|
||||||
|
_noData = _measures.Count > 0 && _measures.All(s => s.Total.Status == BucketStatus.Missing && !s.IsPending);
|
||||||
|
_comparisonCaption = _quantities.Comparison is { IsApplicable: true } ? Query.Comparison.Display() : null;
|
||||||
|
|
||||||
|
_comparisonSubject = _measures.Count > 1 && _main is { } named ? EnergyHistoryView.MeasureName(named, _measures) : null;
|
||||||
|
|
||||||
|
if (_main is { } main)
|
||||||
|
{
|
||||||
|
var name = EnergyHistoryView.MeasureName(main, _measures);
|
||||||
|
_trendTitle = Loc.F(S.EnergyView_TrendOf, name);
|
||||||
|
List<AnalysisChartSeries> trend = [AnalysisChartSeries.ForSeries(main, name)];
|
||||||
|
if (AnalysisChartSeries.ComparisonOf(main, AnalysisChartSeries.ComparisonName(name, Query.Comparison)) is { } overlay)
|
||||||
|
{
|
||||||
|
trend.Add(overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
_trend = trend;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_trendTitle = S.EnergyView_Trend;
|
||||||
|
_trend = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
_changes = MeterChanges.Of(_quantities.Series, MaxChanges);
|
||||||
|
BuildCost();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A measure's own availability when it differs from the scope's (A-41); null when they are the same.</summary>
|
||||||
|
private AvailableRange? OwnRange(AnalysisSeries series) =>
|
||||||
|
series.Availability is { } own && (_quantities?.Availability.Quantity is not { } scope || own.FirstDay != scope.FirstDay || own.LastDay != scope.LastDay)
|
||||||
|
? own
|
||||||
|
: null;
|
||||||
|
|
||||||
|
private void BuildCost()
|
||||||
|
{
|
||||||
|
_costBasis = null;
|
||||||
|
_costLines = [];
|
||||||
|
_costChange = null;
|
||||||
|
_costCaption = null;
|
||||||
|
if (Analysis.Cost is not { } cost)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var figure = cost.EnergyTypes.FirstOrDefault(t => t.EnergyTypeId == Analysis.EnergyTypeId);
|
||||||
|
var billed = cost.Lines.Where(l => l.Kind != BillLineKind.FeedIn).Select(l => l.Name).Distinct().ToList();
|
||||||
|
if (figure is { } type)
|
||||||
|
{
|
||||||
|
_costBasis = billed.Count > 0 ? Loc.F(S.EnergyView_CostBasis, type.Basis.Display(), string.Join(", ", billed)) : type.Basis.Display();
|
||||||
|
}
|
||||||
|
|
||||||
|
var credited = cost.Lines.Where(l => l.Kind == BillLineKind.FeedIn).Select(l => l.Name).Distinct().ToList();
|
||||||
|
if (credited.Count > 0)
|
||||||
|
{
|
||||||
|
_costLines.Add(Loc.F(S.EnergyView_CostCredit, string.Join(", ", credited)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every standing charge of the bill (D-40) — the type's rows and the meter fees on its lines — as the Overview and
|
||||||
|
// the Analysis page show it for the same scope.
|
||||||
|
if (Analysis.StandingCharge is { } standing && standing != 0)
|
||||||
|
{
|
||||||
|
_costLines.Add(Loc.F(S.EnergyView_StandingCharge, Format.Money(standing, cost.Currency)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cost.ManualCosts.Bookings.Count > 0)
|
||||||
|
{
|
||||||
|
_costLines.Add(Loc.F(S.EnergyView_ManualCosts, Format.Money(cost.ManualCosts.Total.Cost, cost.Currency)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The change over what both bills cover completely, by the rule every page uses (D-07).
|
||||||
|
var change = Analysis.CostChange;
|
||||||
|
_costChange = CostChanges.ForCard(change);
|
||||||
|
_costPolarity = CostChanges.Polarity(change);
|
||||||
|
_costCaption = CostChanges.Caption(Query, change);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Change? ChangeOf(AnalysisSeries series) =>
|
||||||
|
_quantities?.Comparison is { IsApplicable: true } ? series.Comparison?.Change ?? Change.Unavailable : null;
|
||||||
|
|
||||||
|
/// <summary>The meter's name, linked to its own analysis over the same period, and — when it is not counted in the totals — why.</summary>
|
||||||
|
private RenderFragment MeterCell(AnalysisSeries series) => __builder =>
|
||||||
|
{
|
||||||
|
<MudLink Href="@MeterLinks.Analysis(series.MeterId!.Value, Query)">@series.Name</MudLink>
|
||||||
|
@if (Analysis.TotalsOf(series.MeterId!.Value) is { IsCounted: false } entry)
|
||||||
|
{
|
||||||
|
<div class="mv-cell-secondary">@entry.Class.Display()</div>
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A total of a meter that is not ranked: its number, or the status in words instead of a fabricated 0 (§4.3). A
|
||||||
|
/// value that is not plain keeps its status beside it — that is the reason there is no change.
|
||||||
|
/// </summary>
|
||||||
|
private RenderFragment FigureCell(BucketValue value, AnalysisSeries series) => __builder =>
|
||||||
|
{
|
||||||
|
var status = FigureText.Of(value, Analysis.Names.MeterOrNull);
|
||||||
|
<td class="@(status.IsKnown ? "mv-num" : "mv-num mv-unknown")">
|
||||||
|
@(status.IsKnown ? Format.Quantity(value.Value, series.Unit) : Format.Unknown)
|
||||||
|
@if (!status.IsComplete)
|
||||||
|
{
|
||||||
|
<div class="mv-cell-secondary">@status.Summary</div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
};
|
||||||
|
|
||||||
|
private string? MembersCaption(AnalysisSeries series) =>
|
||||||
|
series.MemberIds.Count == 0 ? null : Loc.F(S.EnergyView_CountedMeters, string.Join(", ", series.MemberIds.Select(Analysis.MeterName)));
|
||||||
|
|
||||||
|
private string HistoryHref(AnalysisMetric? metric) =>
|
||||||
|
AnalysisLinks.EnergyType(Analysis.EnergyTypeId, AnalysisLinks.EnergyTabHistory, metric is { } m ? Query.WithMetric(m) : Query);
|
||||||
|
|
||||||
|
private string? LatestHref =>
|
||||||
|
AnalysisNavigation.LatestData(Query, _quantities?.Availability.Quantity) is { } latest
|
||||||
|
? AnalysisNavigation.UriFor(Nav, latest, Defaults)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
.mv-energy-overview ::deep .mv-energy-panel {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-energy-panel__head {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 4px 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-energy-coverage {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
gap: 2px;
|
||||||
|
margin: 12px 0 0 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-energy-coverage dt {
|
||||||
|
font-weight: 500;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-energy-coverage dd {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--mud-palette-text-secondary);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
@using MeterVault.App.Energy
|
||||||
|
@using Microsoft.EntityFrameworkCore
|
||||||
|
@inject IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
@inject ILogger<ManageConnectionsDialog> Logger
|
||||||
|
|
||||||
|
@* "Manage connections" (brief §3.2, D-30): an energy type's flow topology with clear source → destination names, where
|
||||||
|
a connection is added or removed on the spot. The rules (MeterLinkRules) refuse a meter into itself, a duplicate, a
|
||||||
|
link across types, a loop — the verdict names it before anything is saved — and any change to the incoming
|
||||||
|
connections of a virtual meter still calculated from them. A connection is topology only: it never writes or changes
|
||||||
|
a calculated meter's formula (D-25); one that mirrors a formula input says so. *@
|
||||||
|
|
||||||
|
<MudDialog Visible="_open" VisibleChanged="OnVisibleChanged" Options="_options">
|
||||||
|
<TitleContent>
|
||||||
|
<MudText Typo="Typo.h6">@Loc.F(S.EnergyView_ConnectionsTitle, EnergyTypeName)</MudText>
|
||||||
|
</TitleContent>
|
||||||
|
<DialogContent>
|
||||||
|
<MudText Typo="Typo.body2" Class="mv-muted mb-3">@S.EnergyView_ConnectionsIntro</MudText>
|
||||||
|
|
||||||
|
@if (_error)
|
||||||
|
{
|
||||||
|
<PanelError HasStaleValue="false" OnRetry="LoadAsync" Class="mb-2" />
|
||||||
|
}
|
||||||
|
else if (_topology is null)
|
||||||
|
{
|
||||||
|
<MudProgressLinear Indeterminate="true" Color="Color.Primary" aria-label="@S.Refresh_Updating" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.subtitle2" Class="mb-1">@S.EnergyView_Connections</MudText>
|
||||||
|
@if (_topology.Links.Count == 0)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2" Class="mv-muted mb-3">@S.EnergyView_ConnectionsEmpty</MudText>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<ul class="mv-connections">
|
||||||
|
@foreach (var link in _topology.Links)
|
||||||
|
{
|
||||||
|
var from = Name(link.FromMeterId);
|
||||||
|
var to = Name(link.ToMeterId);
|
||||||
|
var removal = _topology.CheckRemove(link);
|
||||||
|
<li class="mv-connections__item">
|
||||||
|
<div class="mv-connections__names">
|
||||||
|
<span class="mv-connections__meter">@from</span>
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.ArrowForward" Size="Size.Small" aria-hidden="true" />
|
||||||
|
<span class="mv-sr-only">@S.EnergyView_FlowsInto</span>
|
||||||
|
<span class="mv-connections__meter">@to</span>
|
||||||
|
</div>
|
||||||
|
<div class="mv-connections__note">
|
||||||
|
@if (!removal.IsAllowed)
|
||||||
|
{
|
||||||
|
<span>@FlowText.Refusal(removal, Name, link.ToMeterId)</span>
|
||||||
|
<MudLink Href="@MeterLinks.Detail(link.ToMeterId, MeterLinks.TabCalculation, null)" Typo="Typo.caption">@S.EnergyView_EditCalculation</MudLink>
|
||||||
|
}
|
||||||
|
else if (_topology.MirrorsCalculation(link))
|
||||||
|
{
|
||||||
|
<span>@Loc.F(S.EnergyView_MirrorsCalculation, to, from)</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.LinkOff" Size="Size.Small" Color="Color.Error"
|
||||||
|
Disabled="@(_busy || !removal.IsAllowed)" OnClick="() => RemoveAsync(link)"
|
||||||
|
aria-label="@Loc.F(S.EnergyView_RemoveConnection, from, to)"
|
||||||
|
title="@Loc.F(S.EnergyView_RemoveConnection, from, to)" Class="mv-connections__remove" />
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
|
||||||
|
<MudText Typo="Typo.subtitle2" Class="mt-4 mb-1">@S.EnergyView_AddConnection</MudText>
|
||||||
|
@if (_topology.Meters.Count < 2)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2" Class="mv-muted">@S.EnergyView_ConnectionsNeedTwo</MudText>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="mv-connections__add">
|
||||||
|
<div class="mv-connections__select">
|
||||||
|
<MudSelect T="int" Value="_from" ValueChanged="OnFromChanged" Label="@S.EnergyView_From" HelperText="@S.EnergyView_FromHelp"
|
||||||
|
Variant="Variant.Outlined" Margin="Margin.Dense" Dense="true">
|
||||||
|
<MudSelectItem T="int" Value="0">@S.EnergyView_ChooseMeter</MudSelectItem>
|
||||||
|
@foreach (var meter in _topology.Meters)
|
||||||
|
{
|
||||||
|
<MudSelectItem T="int" Value="meter.Id">@Label(meter)</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
</div>
|
||||||
|
<div class="mv-connections__select">
|
||||||
|
<MudSelect T="int" Value="_to" ValueChanged="OnToChanged" Label="@S.EnergyView_To" HelperText="@S.EnergyView_ToHelp"
|
||||||
|
Variant="Variant.Outlined" Margin="Margin.Dense" Dense="true">
|
||||||
|
<MudSelectItem T="int" Value="0">@S.EnergyView_ChooseMeter</MudSelectItem>
|
||||||
|
@foreach (var meter in _topology.Meters)
|
||||||
|
{
|
||||||
|
<MudSelectItem T="int" Value="meter.Id">@Label(meter)</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
</div>
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.AddLink"
|
||||||
|
Disabled="@(_busy || _verdict is not { IsAllowed: true })" OnClick="AddAsync"
|
||||||
|
Class="mv-connections__button">@S.EnergyView_AddConnectionAction</MudButton>
|
||||||
|
</div>
|
||||||
|
@* A fixed slot, so the verdict appearing does not move the button under the pointer. *@
|
||||||
|
<div class="mv-connections__verdict" role="status" aria-live="polite">
|
||||||
|
@if (_verdict is { IsAllowed: false } refused)
|
||||||
|
{
|
||||||
|
<MudIcon Icon="@Icons.Material.Outlined.Block" Size="Size.Small" aria-hidden="true" />
|
||||||
|
<span>@FlowText.Refusal(refused, Name, _to)</span>
|
||||||
|
@if (refused.Refusal == MeterLinkRefusal.CalculatedFromLinks)
|
||||||
|
{
|
||||||
|
<MudLink Href="@MeterLinks.Detail(_to, MeterLinks.TabCalculation, null)" Typo="Typo.caption">@S.EnergyView_EditCalculation</MudLink>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (_verdict is { IsAllowed: true })
|
||||||
|
{
|
||||||
|
<span>@Loc.F(S.EnergyView_ConnectionPreview, Name(_from), Name(_to))</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<MudButton OnClick="CloseAsync">@S.EnergyView_Done</MudButton>
|
||||||
|
</DialogActions>
|
||||||
|
</MudDialog>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>The energy type whose connections are edited.</summary>
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public int EnergyTypeId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Its name, for the title (user data).</summary>
|
||||||
|
[Parameter]
|
||||||
|
public string EnergyTypeName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Raised when the dialog closes after at least one change, so the page reads the analysis again.</summary>
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback Changed { get; set; }
|
||||||
|
|
||||||
|
private readonly DialogOptions _options = new() { MaxWidth = MaxWidth.Small, FullWidth = true, CloseButton = true, CloseOnEscapeKey = true };
|
||||||
|
private MeterLinkService? _service;
|
||||||
|
private MeterLinkTopology? _topology;
|
||||||
|
private MeterLinkCheck? _verdict;
|
||||||
|
private bool _open;
|
||||||
|
private bool _busy;
|
||||||
|
private bool _error;
|
||||||
|
private bool _changed;
|
||||||
|
private int _from;
|
||||||
|
private int _to;
|
||||||
|
|
||||||
|
private MeterLinkService Service => _service ??= new MeterLinkService(DbFactory);
|
||||||
|
|
||||||
|
/// <summary>Opens the dialog and reads the type's connections afresh.</summary>
|
||||||
|
public async Task OpenAsync()
|
||||||
|
{
|
||||||
|
_open = true;
|
||||||
|
_changed = false;
|
||||||
|
_from = 0;
|
||||||
|
_to = 0;
|
||||||
|
_verdict = null;
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadAsync()
|
||||||
|
{
|
||||||
|
_error = false;
|
||||||
|
_topology = null;
|
||||||
|
StateHasChanged();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_topology = await Service.GetAsync(EnergyTypeId);
|
||||||
|
Check();
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
Logger.LogError(ex, "Loading the connections of energy type {EnergyTypeId} failed", EnergyTypeId);
|
||||||
|
_error = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The dialog renders through the dialog provider: say so when the list arrives, whoever awaited it.
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private string Name(int meterId)
|
||||||
|
{
|
||||||
|
if (_topology?.Find(meterId) is not { } meter)
|
||||||
|
{
|
||||||
|
return MeterMembership.FallbackName(meterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return meter.EnergyTypeId == EnergyTypeId ? meter.Name : Loc.F(S.EnergyView_OtherTypeMeter, meter.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The meter's name, marked when it is calculated or retired — the two things that change what a link means.</summary>
|
||||||
|
private static string Label(MeterLinkMeter meter)
|
||||||
|
{
|
||||||
|
var marks = new List<string>(2);
|
||||||
|
if (meter.IsVirtual)
|
||||||
|
{
|
||||||
|
marks.Add(meter.Mode.Display());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!meter.IsActive)
|
||||||
|
{
|
||||||
|
marks.Add(S.Meters_Retired);
|
||||||
|
}
|
||||||
|
|
||||||
|
return marks.Count == 0 ? meter.Name : meter.Name + " (" + string.Join(", ", marks) + ")";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnFromChanged(int id)
|
||||||
|
{
|
||||||
|
_from = id;
|
||||||
|
Check();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnToChanged(int id)
|
||||||
|
{
|
||||||
|
_to = id;
|
||||||
|
Check();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Check() =>
|
||||||
|
_verdict = _topology is not null && _from != 0 && _to != 0 ? _topology.CheckAdd(_from, _to) : null;
|
||||||
|
|
||||||
|
private async Task AddAsync()
|
||||||
|
{
|
||||||
|
await RunAsync(async () =>
|
||||||
|
{
|
||||||
|
var result = await Service.AddAsync(_from, _to);
|
||||||
|
if (result.IsAllowed)
|
||||||
|
{
|
||||||
|
Snackbar.Add(Loc.F(S.EnergyView_ConnectionAdded, Name(_from), Name(_to)), Severity.Success);
|
||||||
|
_from = 0;
|
||||||
|
_to = 0;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Snackbar.Add(FlowText.Refusal(result, Name, _to), Severity.Warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.IsAllowed;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RemoveAsync(MeterLinkEntry link)
|
||||||
|
{
|
||||||
|
var from = Name(link.FromMeterId);
|
||||||
|
var to = Name(link.ToMeterId);
|
||||||
|
await RunAsync(async () =>
|
||||||
|
{
|
||||||
|
var result = await Service.RemoveAsync(link.LinkId);
|
||||||
|
Snackbar.Add(
|
||||||
|
result.IsAllowed ? Loc.F(S.EnergyView_ConnectionRemoved, from, to) : FlowText.Refusal(result, Name, link.ToMeterId),
|
||||||
|
result.IsAllowed ? Severity.Success : Severity.Warning);
|
||||||
|
return result.IsAllowed;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One change at a time; the list is read again afterwards, so it always shows what is stored.</summary>
|
||||||
|
private async Task RunAsync(Func<Task<bool>> change)
|
||||||
|
{
|
||||||
|
if (_busy)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_busy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_changed |= await change();
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
Logger.LogError(ex, "Changing a connection of energy type {EnergyTypeId} failed", EnergyTypeId);
|
||||||
|
Snackbar.Add(S.EnergyView_ConnectionFailed, Severity.Error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_busy = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnVisibleChanged(bool visible)
|
||||||
|
{
|
||||||
|
if (!visible)
|
||||||
|
{
|
||||||
|
await CloseAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CloseAsync()
|
||||||
|
{
|
||||||
|
_open = false;
|
||||||
|
if (_changed)
|
||||||
|
{
|
||||||
|
_changed = false;
|
||||||
|
await Changed.InvokeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
.mv-connections {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-connections__item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
grid-template-areas: "names remove" "note remove";
|
||||||
|
align-items: center;
|
||||||
|
gap: 0 8px;
|
||||||
|
padding: 6px 0;
|
||||||
|
border-bottom: 1px solid var(--mud-palette-lines-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-connections__names {
|
||||||
|
grid-area: names;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-connections__meter {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-connections__note {
|
||||||
|
grid-area: note;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 2px 8px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--mud-palette-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-connections__note:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-connections__item ::deep .mv-connections__remove {
|
||||||
|
grid-area: remove;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-connections__add {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-connections__select {
|
||||||
|
flex: 1 1 200px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-connections__add ::deep .mv-connections__button {
|
||||||
|
align-self: center;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-connections__verdict {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px 8px;
|
||||||
|
min-height: 3em;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--mud-palette-text-secondary);
|
||||||
|
}
|
||||||
@@ -1,172 +1,267 @@
|
|||||||
@page "/energy/{Id:int}"
|
@page "/energy/{Id:int}"
|
||||||
@inject FlowService Flow
|
@using MeterVault.App.Energy
|
||||||
@inject MeterVault.Infrastructure.Costing.CostService Costs
|
@using MeterVault.App.Components.Pages.Energy
|
||||||
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
@using MeterVault.App.Components.Shared.MeterLists
|
||||||
|
@using MeterVault.Core.Analysis
|
||||||
|
@using MeterVault.Infrastructure.Analysis
|
||||||
@using Microsoft.EntityFrameworkCore
|
@using Microsoft.EntityFrameworkCore
|
||||||
@using MudBlazor
|
@inject NavigationManager Nav
|
||||||
|
@inject InstanceClock Clock
|
||||||
|
@inject AnalysisPeriods Periods
|
||||||
|
@inject AnalysisReader Reader
|
||||||
|
@inject CostReader Costs
|
||||||
|
@inject FlowService Flow
|
||||||
|
@inject IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
||||||
|
@inject ILogger<EnergyView> Logger
|
||||||
|
@implements IDisposable
|
||||||
|
|
||||||
<PageTitle>MeterVault — @(_graph?.EnergyType ?? "Energy")</PageTitle>
|
@* One energy type (brief §7.3): its user-defined name as the title, one period toolbar for every tab, and the tabs
|
||||||
|
Overview | History | Flow | Meters by key (tab=…, written with replace). The type is read once per period, bucket and
|
||||||
|
comparison — its measures and every meter's own series, its bill, the comparison bill and the flow of those same
|
||||||
|
totals — so a tab switch, the History view or the metric never reloads anything (D-46). *@
|
||||||
|
|
||||||
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
<PageHeader Title="@Title" Description="@S.EnergyView_Description">
|
||||||
<MudText Typo="Typo.h4">@(_graph?.EnergyType ?? "Energy") flow</MudText>
|
<Breadcrumbs>
|
||||||
<MudSelect T="int" Value="_months" ValueChanged="OnRangeChanged" Label="Range" Dense="true" Style="max-width:200px">
|
<AnalysisBreadcrumbs Query="_query" EnergyTypeId="Id" EnergyTypeName="@Title" />
|
||||||
<MudSelectItem T="int" Value="12">Last 12 months</MudSelectItem>
|
</Breadcrumbs>
|
||||||
<MudSelectItem T="int" Value="24">Last 24 months</MudSelectItem>
|
<Actions>
|
||||||
<MudSelectItem T="int" Value="60">Last 5 years</MudSelectItem>
|
@if (_state.Value is { Type: not null } header)
|
||||||
<MudSelectItem T="int" Value="1200">All time</MudSelectItem>
|
|
||||||
</MudSelect>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (_graph is null)
|
|
||||||
{
|
|
||||||
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
|
|
||||||
}
|
|
||||||
else if (!_graph.HasData)
|
|
||||||
{
|
|
||||||
<MudAlert Severity="Severity.Info">
|
|
||||||
No meters for this energy type yet. Add meters in <MudLink Href="/meters">Meters</MudLink>, or load the
|
|
||||||
reference data from <MudLink Href="/import">Import</MudLink>.
|
|
||||||
</MudAlert>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<MudGrid Class="mb-2">
|
|
||||||
<MudItem xs="12" sm="4">
|
|
||||||
<MudPaper Class="pa-4" Elevation="2">
|
|
||||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Top-level throughput</MudText>
|
|
||||||
<MudText Typo="Typo.h5">@Format.Number(_graph.Total, 0) @_graph.Unit</MudText>
|
|
||||||
</MudPaper>
|
|
||||||
</MudItem>
|
|
||||||
<MudItem xs="12" sm="4">
|
|
||||||
<MudPaper Class="pa-4" Elevation="2">
|
|
||||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Cost (range)</MudText>
|
|
||||||
<MudText Typo="Typo.h5">@Format.Euro(_cost)</MudText>
|
|
||||||
</MudPaper>
|
|
||||||
</MudItem>
|
|
||||||
<MudItem xs="12" sm="4">
|
|
||||||
<MudPaper Class="pa-4" Elevation="2">
|
|
||||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Meters</MudText>
|
|
||||||
<MudText Typo="Typo.h5">@_meters.Count</MudText>
|
|
||||||
</MudPaper>
|
|
||||||
</MudItem>
|
|
||||||
</MudGrid>
|
|
||||||
|
|
||||||
<MudPaper Class="pa-4 mb-4" Elevation="2">
|
|
||||||
<MudText Typo="Typo.h6" Class="mb-1">Flow</MudText>
|
|
||||||
@if (_graph.HasChain)
|
|
||||||
{
|
{
|
||||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2">
|
@if (header.HasGeneration)
|
||||||
Where the top-level flow goes. Arrow thickness ∝ amount; "Other" is the unmetered remainder.
|
{
|
||||||
</MudText>
|
<MudButton Variant="Variant.Outlined" Color="Color.Primary" Size="Size.Small" StartIcon="@Icons.Material.Filled.WbSunny"
|
||||||
<SankeyChart Nodes="_graph.Nodes" Links="_graph.Links" Unit="@_graph.Unit" />
|
Href="@AnalysisLinks.Solar(_query)">@S.Nav_Solar</MudButton>
|
||||||
|
}
|
||||||
|
@if (header.HasTank)
|
||||||
|
{
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Primary" Size="Size.Small" StartIcon="@Icons.Material.Filled.PropaneTank"
|
||||||
|
Href="@AnalysisLinks.Consumables(_query)">@S.Nav_Consumables</MudButton>
|
||||||
|
}
|
||||||
|
<MudButton Variant="Variant.Text" Size="Size.Small" StartIcon="@Icons.Material.Filled.Settings"
|
||||||
|
Href="/admin/energy-types">@S.EnergyView_EditDefinition</MudButton>
|
||||||
|
}
|
||||||
|
</Actions>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
@if (_query is not null)
|
||||||
|
{
|
||||||
|
<PeriodToolbar Query="_query" Period="_state.Value?.Period" Plan="ToolbarPlan" Defaults="Defaults" QueryChanged="OnQueryChanged"
|
||||||
|
ShowBucket="ShowsBuckets" ShowComparison="ShowsBuckets"
|
||||||
|
Metrics="@(_tab == AnalysisLinks.EnergyTabHistory ? _state.Value?.Metrics : null)" NaturalMetric="NaturalMetric"
|
||||||
|
ExportHref="@ExportHref" Class="mb-3" />
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="mv-energy-page">
|
||||||
|
<LoadPanel State="_state" OnRetry="Retry" Context="analysis" PlaceholderHeight="320">
|
||||||
|
@if (analysis.Type is null)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Warning">@S.EnergyView_NotFound</MudAlert>
|
||||||
|
}
|
||||||
|
else if (analysis.Meters.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="mv-empty" role="status">
|
||||||
|
<MudIcon Icon="@Icons.Material.Outlined.Speed" Class="mv-empty__icon" aria-hidden="true" />
|
||||||
|
<div class="mv-empty__body">
|
||||||
|
<MudText Typo="Typo.subtitle1">@S.EnergyView_NoMeters</MudText>
|
||||||
|
<MudText Typo="Typo.body2" Class="mv-muted">@S.EnergyView_NoMetersHelp</MudText>
|
||||||
|
<div class="d-flex flex-wrap gap-2 mt-2">
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" Size="Size.Small" Href="/meters"
|
||||||
|
StartIcon="@Icons.Material.Filled.Add">@S.Meters_AddMeter</MudButton>
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Primary" Size="Size.Small" Href="/import"
|
||||||
|
StartIcon="@Icons.Material.Filled.UploadFile">@S.Nav_Import</MudButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-3">
|
<MudTabs ActivePanelIndex="AnalysisLinks.EnergyTabIndex(_tab)" ActivePanelIndexChanged="OnTabChanged"
|
||||||
No meter chain configured yet. In <MudLink Href="/meters">Meters</MudLink> → edit a sub-meter and set its
|
Elevation="0" Rounded="true" Border="false" TabPanelsClass="pt-4" Class="mv-energy-tabs">
|
||||||
<b>upstream meter(s)</b> to show where the main meter's flow divides (e.g. main → car, pool, other).
|
<MudTabPanel Text="@S.EnergyView_TabOverview">
|
||||||
</MudAlert>
|
<EnergyOverviewTab Analysis="analysis" Query="_query!" Defaults="Defaults" OnRefresh="Retry" />
|
||||||
@if (_graph.Nodes.Count > 0)
|
</MudTabPanel>
|
||||||
{
|
<MudTabPanel Text="@S.EnergyView_TabHistory">
|
||||||
<MudSimpleTable Dense="true" Hover="true">
|
<EnergyHistoryTab Analysis="analysis" Query="_query!" Defaults="Defaults" Metric="EffectiveMetric(analysis)"
|
||||||
<thead><tr><th>Meter</th><th style="text-align:right">Consumption</th></tr></thead>
|
View="@_view" ViewChanged="OnViewChanged" OnRefresh="Retry" />
|
||||||
<tbody>
|
</MudTabPanel>
|
||||||
@foreach (var node in _graph.Nodes.OrderByDescending(n => n.Value))
|
<MudTabPanel Text="@S.EnergyView_TabFlow">
|
||||||
{
|
<EnergyFlowTab Analysis="analysis" Query="_query!" ManageConnections="OpenConnectionsAsync" />
|
||||||
<tr>
|
</MudTabPanel>
|
||||||
<td>@node.Label</td>
|
<MudTabPanel Text="@Loc.F(S.EnergyView_TabMeters, analysis.Meters.Count)">
|
||||||
<td style="text-align:right">@Format.Number(node.Value, 0) @_graph.Unit</td>
|
<div class="d-flex flex-wrap align-center justify-space-between gap-2 mb-2">
|
||||||
</tr>
|
<MudText Typo="Typo.body2" Class="mv-muted">@S.EnergyView_MetersHelp</MudText>
|
||||||
|
<MudButton Variant="Variant.Text" Color="Color.Primary" Size="Size.Small" StartIcon="@Icons.Material.Filled.Hub"
|
||||||
|
OnClick="OpenConnectionsAsync">@S.EnergyView_ManageConnections</MudButton>
|
||||||
|
</div>
|
||||||
|
<MeterList Rows="MeterRows(analysis)" Query="_query" />
|
||||||
|
</MudTabPanel>
|
||||||
|
</MudTabs>
|
||||||
}
|
}
|
||||||
</tbody>
|
</LoadPanel>
|
||||||
</MudSimpleTable>
|
</div>
|
||||||
}
|
|
||||||
}
|
|
||||||
</MudPaper>
|
|
||||||
|
|
||||||
<MudPaper Class="pa-4" Elevation="2">
|
<ManageConnectionsDialog @ref="_connections" EnergyTypeId="Id" EnergyTypeName="@Title" Changed="OnConnectionsChanged" />
|
||||||
<MudText Typo="Typo.h6" Class="mb-2">Meters</MudText>
|
|
||||||
<MudSimpleTable Dense="true" Hover="true">
|
|
||||||
<thead><tr><th>Name</th><th>Mode</th><th>Upstream of</th><th style="text-align:right">Consumption</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
@foreach (var meter in _meters)
|
|
||||||
{
|
|
||||||
<tr>
|
|
||||||
<td><MudLink Href="@($"/meters/{meter.Id}")">@meter.Name</MudLink></td>
|
|
||||||
<td>@meter.Mode</td>
|
|
||||||
<td>@UpstreamLabel(meter.Id)</td>
|
|
||||||
<td style="text-align:right">@Format.Number(NodeValue(meter.Id), 0) @_graph.Unit</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</MudSimpleTable>
|
|
||||||
</MudPaper>
|
|
||||||
}
|
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
|
|
||||||
private int _months = 60;
|
private readonly LoadSequencer _loads = new();
|
||||||
private bool _loading;
|
private readonly LoadState<EnergyAnalysis> _state = new();
|
||||||
private FlowGraph? _graph;
|
private AnalysisQuery? _query;
|
||||||
private double _cost;
|
private (int Id, AnalysisQuery Query)? _loaded;
|
||||||
private List<Meter> _meters = [];
|
private string _tab = AnalysisLinks.EnergyTabOverview;
|
||||||
private Dictionary<int, List<string>> _downstream = [];
|
private string _view = EnergyPageKeys.ViewTotal;
|
||||||
|
private ManageConnectionsDialog? _connections;
|
||||||
|
private EnergyAnalysis? _rowsFor;
|
||||||
|
private IReadOnlyList<MeterListRow> _rows = [];
|
||||||
|
|
||||||
protected override Task OnParametersSetAsync() => LoadAsync();
|
/// <summary>The History defaults (last 12 months, automatic, previous year) on a page whose route names the type.</summary>
|
||||||
|
private AnalysisDefaults Defaults => AnalysisDefaults.History.ForScope(QueryScope.ForEnergyType(Math.Max(1, Id)));
|
||||||
|
|
||||||
private async Task OnRangeChanged(int months)
|
private string Title => _state.Value is { EnergyTypeId: var shown, Type: { } type } && shown == Id ? type.Name : S.EnergyView_EnergyFallback;
|
||||||
|
|
||||||
|
/// <summary>The interval and the comparison change only the Overview's and the History's figures.</summary>
|
||||||
|
private bool ShowsBuckets => _tab is AnalysisLinks.EnergyTabOverview or AnalysisLinks.EnergyTabHistory;
|
||||||
|
|
||||||
|
private BucketPlan? ToolbarPlan => _state.Value is { } value ? value.RefusedPlan ?? value.Quantities?.Plan : null;
|
||||||
|
|
||||||
|
private AnalysisMetric? NaturalMetric => _state.Value is { Metrics.Count: > 0 } value ? value.Metrics[0] : null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The History's CSV (D-55): the type's measures of the metric, or — in the individual view — the meters it charts.
|
||||||
|
/// </summary>
|
||||||
|
private string? ExportHref
|
||||||
{
|
{
|
||||||
_months = months;
|
get
|
||||||
await LoadAsync();
|
{
|
||||||
|
if (_tab != AnalysisLinks.EnergyTabHistory || _query is null || Id <= 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task LoadAsync()
|
var metric = _state.Value is { } value ? EffectiveMetric(value) : _query.Metric;
|
||||||
|
var scope = QueryScope.ForEnergyType(Id);
|
||||||
|
if (_view == EnergyPageKeys.ViewMeters && metric is { } m && m.IsQuantity()
|
||||||
|
&& EnergyMetrics.MetersOf(_state.Value?.Quantities, m).Take(AnalysisLimits.MaxSeries).Select(s => s.MeterId!.Value).ToList() is { Count: > 0 } shown)
|
||||||
{
|
{
|
||||||
if (_loading)
|
scope = QueryScope.ForMeters(shown);
|
||||||
|
}
|
||||||
|
|
||||||
|
return AnalysisLinks.Export(_query.WithScope(scope).WithMetric(metric));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnInitialized() => Nav.LocationChanged += OnLocationChanged;
|
||||||
|
|
||||||
|
protected override Task OnParametersSetAsync() => SyncAsync();
|
||||||
|
|
||||||
|
private void OnLocationChanged(object? sender, LocationChangedEventArgs e) => _ = InvokeAsync(async () =>
|
||||||
|
{
|
||||||
|
// Only this page's own address: a link away fires this too, just before the page goes.
|
||||||
|
if (!IsThisPage(e.Location))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_loading = true;
|
await SyncAsync();
|
||||||
_graph = null;
|
StateHasChanged();
|
||||||
try
|
});
|
||||||
|
|
||||||
|
private bool IsThisPage(string uri)
|
||||||
{
|
{
|
||||||
var asOf = DateOnly.FromDateTime(DateTime.UtcNow);
|
var path = Nav.ToBaseRelativePath(uri);
|
||||||
var from = new DateOnly(asOf.AddMonths(-_months).Year, asOf.AddMonths(-_months).Month, 1);
|
var end = path.IndexOfAny(['?', '#']);
|
||||||
var to = asOf.AddMonths(1);
|
path = (end >= 0 ? path[..end] : path).TrimEnd('/');
|
||||||
var typeId = (short)Id;
|
return string.Equals(path, "energy/" + Id.ToString(System.Globalization.CultureInfo.InvariantCulture), StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
_graph = await Flow.GetFlowAsync(typeId, from, to);
|
/// <summary>Reads the address: the tab and view always, the analysis only when period, bucket or comparison changed.</summary>
|
||||||
|
private async Task SyncAsync()
|
||||||
await using var db = await DbFactory.CreateDbContextAsync();
|
|
||||||
_meters = await db.Meters.AsNoTracking().Where(m => m.EnergyTypeId == typeId).OrderBy(m => m.Name).ToListAsync();
|
|
||||||
var links = await db.MeterLinks.AsNoTracking()
|
|
||||||
.Where(l => _meters.Select(m => m.Id).Contains(l.FromMeterId))
|
|
||||||
.ToListAsync();
|
|
||||||
var names = _meters.ToDictionary(m => m.Id, m => m.Name);
|
|
||||||
_downstream = links
|
|
||||||
.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);
|
|
||||||
double cost = 0;
|
|
||||||
foreach (var meter in _meters)
|
|
||||||
{
|
{
|
||||||
cost += (await Costs.GetMeterCostsAsync(meter.Id, fromUtc, toUtc, CostBucket.Month)).Sum(c => c.Cost);
|
(_tab, _view) = EnergyPageKeys.Parse(Nav.Uri);
|
||||||
}
|
var query = AnalysisQuery.Parse(Nav.Uri, Defaults);
|
||||||
_cost = cost;
|
_query = query;
|
||||||
}
|
|
||||||
finally
|
var key = (Id, LoadKey(query));
|
||||||
|
if (_loaded == key)
|
||||||
{
|
{
|
||||||
_loading = false;
|
return;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private double NodeValue(int meterId) => _graph?.Nodes.FirstOrDefault(n => n.MeterId == meterId)?.Value ?? 0;
|
if (_loaded?.Id != Id)
|
||||||
|
{
|
||||||
|
// Another type: its figures must not show under this title, not even dimmed.
|
||||||
|
_state.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
private string UpstreamLabel(int meterId) =>
|
_loaded = key;
|
||||||
_downstream.TryGetValue(meterId, out var children) && children.Count > 0 ? string.Join(", ", children) : "—";
|
await LoadAsync(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>What the read depends on: the metric only picks what History charts, the scope is the route's.</summary>
|
||||||
|
private AnalysisQuery LoadKey(AnalysisQuery query) => EnergyAnalysisLoader.LoadKey(query, Id);
|
||||||
|
|
||||||
|
private Task Retry() => _query is null ? Task.CompletedTask : LoadAsync(_query);
|
||||||
|
|
||||||
|
private Task LoadAsync(AnalysisQuery query)
|
||||||
|
{
|
||||||
|
var id = Id;
|
||||||
|
return _loads.RunAsync(_state, token => ReadAsync(id, query, token), Logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task<EnergyAnalysis> ReadAsync(int id, AnalysisQuery query, CancellationToken token) =>
|
||||||
|
new EnergyAnalysisLoader(DbFactory, Periods, Reader, Costs, Flow).LoadAsync(id, query, Clock.Now, token);
|
||||||
|
|
||||||
|
/// <summary>The metric History charts: the address's when the type has it, else the type's first (consumption first).</summary>
|
||||||
|
private AnalysisMetric EffectiveMetric(EnergyAnalysis analysis) => EnergyMetrics.Effective(_query?.Metric, analysis.Metrics);
|
||||||
|
|
||||||
|
private IReadOnlyList<MeterListRow> MeterRows(EnergyAnalysis analysis)
|
||||||
|
{
|
||||||
|
// Built once per committed value: the list keeps its search and filter across renders.
|
||||||
|
if (!ReferenceEquals(_rowsFor, analysis))
|
||||||
|
{
|
||||||
|
_rowsFor = analysis;
|
||||||
|
_rows = MeterListRows.Build(analysis.Meters, analysis.Quantities);
|
||||||
|
}
|
||||||
|
|
||||||
|
return _rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnQueryChanged(AnalysisQuery query) => AnalysisNavigation.Replace(Nav, query, Defaults);
|
||||||
|
|
||||||
|
/// <summary>A tab click writes <c>tab=</c> (replace, D-46); the Overview is the default and is not written.</summary>
|
||||||
|
private void OnTabChanged(int index)
|
||||||
|
{
|
||||||
|
var tab = index >= 0 && index < AnalysisLinks.EnergyTabs.Count ? AnalysisLinks.EnergyTabs[index] : AnalysisLinks.EnergyTabOverview;
|
||||||
|
if (tab == _tab)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_tab = tab;
|
||||||
|
Nav.NavigateTo(Nav.GetUriWithQueryParameter(EnergyPageKeys.Tab, tab == AnalysisLinks.EnergyTabOverview ? null : tab), replace: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnViewChanged(string view)
|
||||||
|
{
|
||||||
|
if (view == _view)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_view = view;
|
||||||
|
Nav.NavigateTo(Nav.GetUriWithQueryParameter(EnergyPageKeys.View, view == EnergyPageKeys.ViewTotal ? null : view), replace: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task OpenConnectionsAsync() => _connections?.OpenAsync() ?? Task.CompletedTask;
|
||||||
|
|
||||||
|
/// <summary>The topology changed: the measures, the bill and the flow may classify differently, so read again.</summary>
|
||||||
|
private Task OnConnectionsChanged() => Retry();
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Nav.LocationChanged -= OnLocationChanged;
|
||||||
|
_loads.Dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/* Four tabs fit a phone: plain case and tighter padding below 600px, so none of them is cut off or scrolled away. */
|
||||||
|
@media (max-width: 599.98px) {
|
||||||
|
.mv-energy-page ::deep .mv-energy-tabs .mud-tab {
|
||||||
|
/* MudTabs sets the tab's minimum width inline. */
|
||||||
|
min-width: 0 !important;
|
||||||
|
padding: 6px 8px;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,27 +1,27 @@
|
|||||||
@page "/Error"
|
@page "/Error"
|
||||||
@using System.Diagnostics
|
@using System.Diagnostics
|
||||||
|
|
||||||
<PageTitle>Error</PageTitle>
|
<PageTitle>@S.Error_PageTitle</PageTitle>
|
||||||
|
|
||||||
<h1 class="text-danger">Error.</h1>
|
<h1 class="text-danger">@S.Error_Heading</h1>
|
||||||
<h2 class="text-danger">An error occurred while processing your request.</h2>
|
<h2 class="text-danger">@S.Error_Message</h2>
|
||||||
|
|
||||||
@if (ShowRequestId)
|
@if (ShowRequestId)
|
||||||
{
|
{
|
||||||
<p>
|
<p>
|
||||||
<strong>Request ID:</strong> <code>@RequestId</code>
|
<strong>@S.Error_RequestId</strong> <code>@RequestId</code>
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
|
|
||||||
<h3>Development Mode</h3>
|
<h3>@S.Error_DevelopmentMode</h3>
|
||||||
<p>
|
<p>
|
||||||
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
|
@* "Development" and ASPNETCORE_ENVIRONMENT are literal environment names — emphasised, never translated. *@
|
||||||
|
@((MarkupString)Loc.F(S.Error_DevelopmentSwap, "<strong>Development</strong>"))
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
|
<strong>@S.Error_DevelopmentWarning</strong>
|
||||||
It can result in displaying sensitive information from exceptions to end users.
|
@S.Error_DevelopmentWarningDetail
|
||||||
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
|
@((MarkupString)Loc.F(S.Error_DevelopmentEnableHint, "<strong>Development</strong>", "<strong>ASPNETCORE_ENVIRONMENT</strong>"))
|
||||||
and restarting the app.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@code{
|
@code{
|
||||||
|
|||||||
@@ -9,21 +9,20 @@
|
|||||||
@using MeterVault.Infrastructure.Persistence
|
@using MeterVault.Infrastructure.Persistence
|
||||||
@using Microsoft.EntityFrameworkCore
|
@using Microsoft.EntityFrameworkCore
|
||||||
|
|
||||||
<PageTitle>MeterVault — Import</PageTitle>
|
<PageTitle>MeterVault — @S.Nav_Import</PageTitle>
|
||||||
|
|
||||||
<MudText Typo="Typo.h4" Class="mb-4">Import</MudText>
|
<MudText Typo="Typo.h4" Class="mb-4">@S.Nav_Import</MudText>
|
||||||
|
|
||||||
<MudGrid>
|
<MudGrid>
|
||||||
<MudItem xs="12" md="6">
|
<MudItem xs="12" md="6">
|
||||||
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
|
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
|
||||||
<MudText Typo="Typo.h6">Reference dataset</MudText>
|
<MudText Typo="Typo.h6">@S.Import_ReferenceDataset</MudText>
|
||||||
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-3">
|
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-3">
|
||||||
Load the bundled Energiebilanz sheets (electricity, water, heating oil, costs) as a
|
@S.Import_ReferenceDatasetHelp
|
||||||
starter dataset with meters, tariffs and categories.
|
|
||||||
</MudText>
|
</MudText>
|
||||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.CloudDownload"
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.CloudDownload"
|
||||||
OnClick="LoadReferenceAsync" Disabled="_loadingReference || _referenceLoaded">
|
OnClick="LoadReferenceAsync" Disabled="_loadingReference || _referenceLoaded">
|
||||||
@(_referenceLoaded ? "Loaded" : "Load reference data")
|
@(_referenceLoaded ? S.Import_ReferenceLoaded : S.Import_LoadReferenceData)
|
||||||
</MudButton>
|
</MudButton>
|
||||||
@if (_loadingReference)
|
@if (_loadingReference)
|
||||||
{
|
{
|
||||||
@@ -35,22 +34,21 @@
|
|||||||
<MudItem xs="12" md="6">
|
<MudItem xs="12" md="6">
|
||||||
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
|
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
|
||||||
<div class="d-flex align-center justify-space-between">
|
<div class="d-flex align-center justify-space-between">
|
||||||
<MudText Typo="Typo.h6">Your own CSV</MudText>
|
<MudText Typo="Typo.h6">@S.Import_YourOwnCsv</MudText>
|
||||||
<MudButton Variant="Variant.Filled" Color="Color.Secondary" Href="/import/wizard"
|
<MudButton Variant="Variant.Filled" Color="Color.Secondary" Href="/import/wizard"
|
||||||
StartIcon="@Icons.Material.Filled.AutoFixHigh">Mapping wizard</MudButton>
|
StartIcon="@Icons.Material.Filled.AutoFixHigh">@S.Import_MappingWizard</MudButton>
|
||||||
</div>
|
</div>
|
||||||
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-3 mt-1">
|
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-3 mt-1">
|
||||||
Map an arbitrary sheet's columns to your meters/categories, preview and commit it as a
|
@S.Import_YourOwnCsvHelp
|
||||||
revertible import. Or dry-run against one of the built-in reference profiles below.
|
|
||||||
</MudText>
|
</MudText>
|
||||||
<MudSelect T="string" @bind-Value="_profileName" Label="Reference profile" Dense="true" Class="mb-2">
|
<MudSelect T="string" @bind-Value="_profileName" Label="@S.Import_ReferenceProfile" Dense="true" Class="mb-2">
|
||||||
<MudSelectItem T="string" Value="@("Strom")">Electricity (Strom)</MudSelectItem>
|
<MudSelectItem T="string" Value="@("Strom")">@S.Import_ProfileElectricity</MudSelectItem>
|
||||||
<MudSelectItem T="string" Value="@("Wasser")">Water (Wasser)</MudSelectItem>
|
<MudSelectItem T="string" Value="@("Wasser")">@S.Import_ProfileWater</MudSelectItem>
|
||||||
<MudSelectItem T="string" Value="@("Heizöl")">Heating oil (Heizöl)</MudSelectItem>
|
<MudSelectItem T="string" Value="@("Heizöl")">@S.Import_ProfileHeatingOil</MudSelectItem>
|
||||||
<MudSelectItem T="string" Value="@("Kosten")">Costs (Kosten)</MudSelectItem>
|
<MudSelectItem T="string" Value="@("Kosten")">@S.Import_ProfileCosts</MudSelectItem>
|
||||||
</MudSelect>
|
</MudSelect>
|
||||||
<MudButton HtmlTag="label" Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.UploadFile" for="csvUpload">
|
<MudButton HtmlTag="label" Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.UploadFile" for="csvUpload">
|
||||||
Dry-run a reference sheet
|
@S.Import_DryRunReferenceSheet
|
||||||
</MudButton>
|
</MudButton>
|
||||||
<InputFile id="csvUpload" OnChange="PreviewAsync" accept=".csv" style="display:none" />
|
<InputFile id="csvUpload" OnChange="PreviewAsync" accept=".csv" style="display:none" />
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
@@ -60,20 +58,20 @@
|
|||||||
{
|
{
|
||||||
<MudItem xs="12">
|
<MudItem xs="12">
|
||||||
<MudPaper Class="pa-4" Elevation="2">
|
<MudPaper Class="pa-4" Elevation="2">
|
||||||
<MudText Typo="Typo.h6" Class="mb-2">Preview</MudText>
|
<MudText Typo="Typo.h6" Class="mb-2">@S.Common_Preview</MudText>
|
||||||
<div class="d-flex" style="gap:2rem; flex-wrap:wrap">
|
<div class="d-flex" style="gap:2rem; flex-wrap:wrap">
|
||||||
<MudText>Readings: <b>@_preview.Readings.Count</b></MudText>
|
<MudText>@S.Common_ReadingsLabel <b>@_preview.Readings.Count</b></MudText>
|
||||||
<MudText>Events: <b>@_preview.Events.Count</b></MudText>
|
<MudText>@S.Common_EventsLabel <b>@_preview.Events.Count</b></MudText>
|
||||||
<MudText>Manual costs: <b>@_preview.ManualCosts.Count</b></MudText>
|
<MudText>@S.Common_ManualCostsLabel <b>@_preview.ManualCosts.Count</b></MudText>
|
||||||
<MudText>Skipped rows: <b>@_preview.SkippedRows</b></MudText>
|
<MudText>@S.Common_SkippedRowsLabel <b>@_preview.SkippedRows</b></MudText>
|
||||||
</div>
|
</div>
|
||||||
@if (_preview.Warnings.Count > 0)
|
@if (_preview.Warnings.Count > 0)
|
||||||
{
|
{
|
||||||
<MudExpansionPanels Class="mt-3">
|
<MudExpansionPanels Class="mt-3">
|
||||||
<MudExpansionPanel Text="@($"{_preview.Warnings.Count} warnings")">
|
<MudExpansionPanel Text="@Loc.F(S.Import_WarningsCount, _preview.Warnings.Count)">
|
||||||
@foreach (var warning in _preview.Warnings.Take(50))
|
@foreach (var warning in _preview.Warnings.Take(50))
|
||||||
{
|
{
|
||||||
<MudText Typo="Typo.body2">@warning</MudText>
|
<MudText Typo="Typo.body2">@warning.Display()</MudText>
|
||||||
}
|
}
|
||||||
</MudExpansionPanel>
|
</MudExpansionPanel>
|
||||||
</MudExpansionPanels>
|
</MudExpansionPanels>
|
||||||
@@ -84,38 +82,57 @@
|
|||||||
|
|
||||||
<MudItem xs="12">
|
<MudItem xs="12">
|
||||||
<MudPaper Class="pa-4" Elevation="2">
|
<MudPaper Class="pa-4" Elevation="2">
|
||||||
<MudText Typo="Typo.h6" Class="mb-2">Recent imports</MudText>
|
<MudText Typo="Typo.h6" Class="mb-2">@S.Import_RecentImports</MudText>
|
||||||
@if (_batches.Count == 0)
|
@if (_batches.Count == 0)
|
||||||
{
|
{
|
||||||
<MudText Typo="Typo.body2" Color="Color.Secondary">No imports yet.</MudText>
|
<MudText Typo="Typo.body2" Color="Color.Secondary">@S.Import_NoImportsYet</MudText>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<MudSimpleTable Dense="true" Hover="true">
|
<MudSimpleTable Dense="true" Hover="true">
|
||||||
<thead><tr><th>#</th><th>Source</th><th style="text-align:right">Rows</th><th>Imported</th><th>Status</th><th></th></tr></thead>
|
<thead><tr><th>#</th><th>@S.Import_ColumnSource</th><th>@S.Import_ColumnWritesTo</th><th style="text-align:right">@S.Import_ColumnRows</th><th>@S.Import_ColumnImported</th><th>@S.Common_Status</th><th></th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@foreach (var batch in _batches)
|
@foreach (var batch in _batches)
|
||||||
{
|
{
|
||||||
<tr>
|
<tr>
|
||||||
<td>@batch.Id</td>
|
<td>@batch.Id</td>
|
||||||
<td>@(batch.SourceName ?? "—")</td>
|
<td>@(batch.SourceName ?? "—")</td>
|
||||||
|
<td>
|
||||||
|
@* What the batch wrote to, so an import can be checked where it landed — and a
|
||||||
|
revert weighed against what it will take away. *@
|
||||||
|
@if (_targets.TryGetValue(batch.Id, out var targets))
|
||||||
|
{
|
||||||
|
@foreach (var meter in targets.Meters)
|
||||||
|
{
|
||||||
|
<MudLink Href="@MeterLinks.Detail(meter.Id)" Class="mr-2">@meter.Name</MudLink>
|
||||||
|
}
|
||||||
|
@foreach (var category in targets.Categories)
|
||||||
|
{
|
||||||
|
<MudLink Href="/admin/categories" Class="mr-2" Color="Color.Secondary">@category</MudLink>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span>—</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
<td style="text-align:right">@batch.RowCount</td>
|
<td style="text-align:right">@batch.RowCount</td>
|
||||||
<td>@batch.CreatedAt.LocalDateTime.ToString("yyyy-MM-dd HH:mm")</td>
|
<td>@batch.CreatedAt.LocalDateTime.ToString("yyyy-MM-dd HH:mm")</td>
|
||||||
<td>
|
<td>
|
||||||
@if (batch.RevertedAt is not null)
|
@if (batch.RevertedAt is not null)
|
||||||
{
|
{
|
||||||
<MudChip T="string" Size="Size.Small" Color="Color.Default">reverted</MudChip>
|
<MudChip T="string" Size="Size.Small" Color="Color.Default">@S.Import_StatusReverted</MudChip>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<MudChip T="string" Size="Size.Small" Color="Color.Success">active</MudChip>
|
<MudChip T="string" Size="Size.Small" Color="Color.Success">@S.Import_StatusActive</MudChip>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td style="text-align:right">
|
<td style="text-align:right">
|
||||||
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Error"
|
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Error"
|
||||||
StartIcon="@Icons.Material.Filled.Undo"
|
StartIcon="@Icons.Material.Filled.Undo"
|
||||||
Disabled="@(batch.RevertedAt is not null || _reverting == batch.Id)"
|
Disabled="@(batch.RevertedAt is not null || _reverting == batch.Id)"
|
||||||
OnClick="@(() => RevertAsync(batch))">Revert</MudButton>
|
OnClick="@(() => RevertAsync(batch))">@S.Import_Revert</MudButton>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
@@ -132,6 +149,7 @@
|
|||||||
private string _profileName = "Strom";
|
private string _profileName = "Strom";
|
||||||
private StagedImport? _preview;
|
private StagedImport? _preview;
|
||||||
private List<ImportBatch> _batches = [];
|
private List<ImportBatch> _batches = [];
|
||||||
|
private Dictionary<int, BatchTargets> _targets = [];
|
||||||
private int? _reverting;
|
private int? _reverting;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
@@ -147,13 +165,74 @@
|
|||||||
.OrderByDescending(b => b.Id)
|
.OrderByDescending(b => b.Id)
|
||||||
.Take(25)
|
.Take(25)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
_targets = await LoadTargetsAsync(db, [.. _batches.Where(b => b.RevertedAt is null).Select(b => b.Id)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The meters and cost categories each batch wrote rows for. Read from the rows themselves rather
|
||||||
|
/// than the stored mapping, which the reference import does not have and which cannot say what a
|
||||||
|
/// partly failed or reverted batch actually left behind.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<Dictionary<int, BatchTargets>> LoadTargetsAsync(MeterVaultDbContext db, List<int> batchIds)
|
||||||
|
{
|
||||||
|
if (batchIds.Count == 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var readingMeters = await db.Readings.AsNoTracking()
|
||||||
|
.Where(r => r.ImportBatchId != null && batchIds.Contains(r.ImportBatchId.Value))
|
||||||
|
.Select(r => new { Batch = r.ImportBatchId!.Value, r.MeterId })
|
||||||
|
.Distinct()
|
||||||
|
.ToListAsync();
|
||||||
|
var eventMeters = await db.MeterEvents.AsNoTracking()
|
||||||
|
.Where(e => e.ImportBatchId != null && batchIds.Contains(e.ImportBatchId.Value))
|
||||||
|
.Select(e => new { Batch = e.ImportBatchId!.Value, e.MeterId })
|
||||||
|
.Distinct()
|
||||||
|
.ToListAsync();
|
||||||
|
var costs = await db.ManualCosts.AsNoTracking()
|
||||||
|
.Where(c => c.ImportBatchId != null && batchIds.Contains(c.ImportBatchId.Value))
|
||||||
|
.Select(c => new { Batch = c.ImportBatchId!.Value, c.MeterId, c.CategoryId })
|
||||||
|
.Distinct()
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var meterPairs = readingMeters.Select(r => (r.Batch, MeterId: (int?)r.MeterId))
|
||||||
|
.Concat(eventMeters.Select(e => (e.Batch, MeterId: (int?)e.MeterId)))
|
||||||
|
.Concat(costs.Select(c => (c.Batch, c.MeterId)))
|
||||||
|
.Where(p => p.MeterId is not null)
|
||||||
|
.Select(p => (p.Batch, MeterId: p.MeterId!.Value))
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
var categoryPairs = costs.Where(c => c.CategoryId is not null).Select(c => (c.Batch, CategoryId: c.CategoryId!.Value)).Distinct().ToList();
|
||||||
|
|
||||||
|
var meterIds = meterPairs.Select(p => p.MeterId).Distinct().ToList();
|
||||||
|
var categoryIds = categoryPairs.Select(p => p.CategoryId).Distinct().ToList();
|
||||||
|
var meterNames = await db.Meters.AsNoTracking().Where(m => meterIds.Contains(m.Id))
|
||||||
|
.ToDictionaryAsync(m => m.Id, m => m.Name);
|
||||||
|
var categoryNames = await db.CostCategories.AsNoTracking().Where(c => categoryIds.Contains(c.Id))
|
||||||
|
.ToDictionaryAsync(c => c.Id, c => c.Name);
|
||||||
|
|
||||||
|
return batchIds
|
||||||
|
.Select(id => (Id: id, Targets: new BatchTargets(
|
||||||
|
[.. meterPairs.Where(p => p.Batch == id && meterNames.ContainsKey(p.MeterId))
|
||||||
|
.Select(p => new MeterTarget(p.MeterId, meterNames[p.MeterId]))
|
||||||
|
.OrderBy(m => m.Name, StringComparer.CurrentCulture)],
|
||||||
|
[.. categoryPairs.Where(p => p.Batch == id && categoryNames.ContainsKey(p.CategoryId))
|
||||||
|
.Select(p => categoryNames[p.CategoryId])
|
||||||
|
.Order(StringComparer.CurrentCulture)])))
|
||||||
|
.Where(t => t.Targets.Meters.Count > 0 || t.Targets.Categories.Count > 0)
|
||||||
|
.ToDictionary(t => t.Id, t => t.Targets);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record MeterTarget(int Id, string Name);
|
||||||
|
|
||||||
|
private sealed record BatchTargets(IReadOnlyList<MeterTarget> Meters, IReadOnlyList<string> Categories);
|
||||||
|
|
||||||
private async Task RevertAsync(ImportBatch batch)
|
private async Task RevertAsync(ImportBatch batch)
|
||||||
{
|
{
|
||||||
if (!await Confirm.ConfirmAsync(Dialogs, "Revert import?",
|
if (!await Confirm.ConfirmAsync(Dialogs, S.Import_RevertConfirmTitle,
|
||||||
$"Delete all {batch.RowCount} rows from import #{batch.Id} ({batch.SourceName ?? "unnamed"}) and recompute the affected meters?",
|
Loc.F(S.Import_RevertConfirmBody, batch.RowCount, batch.Id, batch.SourceName ?? S.Import_UnnamedSource),
|
||||||
"Revert"))
|
S.Import_Revert))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -162,12 +241,12 @@
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
await ImportService.RevertAsync(batch.Id);
|
await ImportService.RevertAsync(batch.Id);
|
||||||
Snackbar.Add($"Import #{batch.Id} reverted.", Severity.Success);
|
Snackbar.Add(Loc.F(S.Import_BatchReverted, batch.Id), Severity.Success);
|
||||||
await LoadBatchesAsync();
|
await LoadBatchesAsync();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Snackbar.Add($"Revert failed: {ex.Message}", Severity.Error);
|
Snackbar.Add(Loc.F(S.Import_RevertFailed, ex.Message), Severity.Error);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -183,12 +262,12 @@
|
|||||||
var dir = Path.Combine(AppContext.BaseDirectory, "sampledata");
|
var dir = Path.Combine(AppContext.BaseDirectory, "sampledata");
|
||||||
await ReferenceImporter.LoadAsync(dir);
|
await ReferenceImporter.LoadAsync(dir);
|
||||||
_referenceLoaded = true;
|
_referenceLoaded = true;
|
||||||
Snackbar.Add("Reference data loaded.", Severity.Success);
|
Snackbar.Add(S.Import_ReferenceDataLoaded, Severity.Success);
|
||||||
await LoadBatchesAsync();
|
await LoadBatchesAsync();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Snackbar.Add($"Import failed: {ex.Message}", Severity.Error);
|
Snackbar.Add(Loc.F(S.Import_Failed, ex.Message), Severity.Error);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,29 +10,28 @@
|
|||||||
@using MeterVault.Infrastructure.Persistence
|
@using MeterVault.Infrastructure.Persistence
|
||||||
@using Microsoft.EntityFrameworkCore
|
@using Microsoft.EntityFrameworkCore
|
||||||
|
|
||||||
<PageTitle>MeterVault — Import wizard</PageTitle>
|
<PageTitle>MeterVault — @S.ImportWizard_Title</PageTitle>
|
||||||
|
|
||||||
<div class="d-flex align-center mb-4" style="gap:1rem">
|
<div class="d-flex align-center mb-4" style="gap:1rem">
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.ArrowBack" Href="/import" aria-label="Back to Import" />
|
<MudIconButton Icon="@Icons.Material.Filled.ArrowBack" Href="/import" aria-label="@S.ImportWizard_BackToImport" />
|
||||||
<MudText Typo="Typo.h4">Import wizard</MudText>
|
<MudText Typo="Typo.h4">@S.ImportWizard_Title</MudText>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-4">
|
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-4">
|
||||||
Upload any CSV, map its columns to your meters and categories, preview what would be staged, then
|
@S.ImportWizard_IntroLead
|
||||||
commit it as a revertible import. Values may use the German dialect (decimal comma, unit suffixes,
|
<code>Monat JJJJ</code> @S.ImportWizard_IntroOr <code>TT.MM.JJJJ</code> @S.ImportWizard_IntroTail
|
||||||
<code>Monat JJJJ</code> or <code>TT.MM.JJJJ</code> dates) — the same parser the reference sheets use.
|
|
||||||
</MudText>
|
</MudText>
|
||||||
|
|
||||||
<MudPaper Class="pa-4 mb-4" Elevation="2">
|
<MudPaper Class="pa-4 mb-4" Elevation="2">
|
||||||
<div class="d-flex align-center" style="gap:1rem; flex-wrap:wrap">
|
<div class="d-flex align-center" style="gap:1rem; flex-wrap:wrap">
|
||||||
<MudButton HtmlTag="label" Variant="Variant.Filled" Color="Color.Primary"
|
<MudButton HtmlTag="label" Variant="Variant.Filled" Color="Color.Primary"
|
||||||
StartIcon="@Icons.Material.Filled.UploadFile" for="wizardUpload">
|
StartIcon="@Icons.Material.Filled.UploadFile" for="wizardUpload">
|
||||||
Choose CSV
|
@S.ImportWizard_ChooseCsv
|
||||||
</MudButton>
|
</MudButton>
|
||||||
<InputFile id="wizardUpload" OnChange="OnFileAsync" accept=".csv" style="display:none" />
|
<InputFile id="wizardUpload" OnChange="OnFileAsync" accept=".csv" style="display:none" />
|
||||||
@if (_fileName is not null)
|
@if (_fileName is not null)
|
||||||
{
|
{
|
||||||
<MudText><b>@_fileName</b> — @_rows.Count rows, @_colCount columns</MudText>
|
<MudText><b>@_fileName</b> — @Loc.F(S.ImportWizard_FileSummary, _rows.Count, _colCount)</MudText>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
@@ -40,10 +39,10 @@
|
|||||||
@if (_colCount > 0)
|
@if (_colCount > 0)
|
||||||
{
|
{
|
||||||
<MudPaper Class="pa-4 mb-4" Elevation="2">
|
<MudPaper Class="pa-4 mb-4" Elevation="2">
|
||||||
<MudText Typo="Typo.h6" Class="mb-2">1. Parsing options</MudText>
|
<MudText Typo="Typo.h6" Class="mb-2">@S.ImportWizard_Step1Title</MudText>
|
||||||
<MudGrid>
|
<MudGrid>
|
||||||
<MudItem xs="12" sm="6" md="3">
|
<MudItem xs="12" sm="6" md="3">
|
||||||
<MudSelect T="int" @bind-Value="_dateColumn" Label="Date column" Dense="true">
|
<MudSelect T="int" @bind-Value="_dateColumn" Label="@S.ImportWizard_DateColumn" Dense="true">
|
||||||
@foreach (var i in Enumerable.Range(0, _colCount))
|
@foreach (var i in Enumerable.Range(0, _colCount))
|
||||||
{
|
{
|
||||||
<MudSelectItem T="int" Value="i">@ColLabel(i)</MudSelectItem>
|
<MudSelectItem T="int" Value="i">@ColLabel(i)</MudSelectItem>
|
||||||
@@ -51,27 +50,27 @@
|
|||||||
</MudSelect>
|
</MudSelect>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
<MudItem xs="12" sm="6" md="3">
|
<MudItem xs="12" sm="6" md="3">
|
||||||
<MudSelect T="DateKind" @bind-Value="_dateKind" Label="Date format" Dense="true">
|
<MudSelect T="DateKind" @bind-Value="_dateKind" Label="@S.ImportWizard_DateFormat" Dense="true">
|
||||||
<MudSelectItem T="DateKind" Value="DateKind.Auto">Auto-detect</MudSelectItem>
|
<MudSelectItem T="DateKind" Value="DateKind.Auto">@S.ImportWizard_DateAutoDetect</MudSelectItem>
|
||||||
<MudSelectItem T="DateKind" Value="DateKind.MonthName">Month name (Januar 2024)</MudSelectItem>
|
<MudSelectItem T="DateKind" Value="DateKind.MonthName">@S.ImportWizard_DateMonthName</MudSelectItem>
|
||||||
<MudSelectItem T="DateKind" Value="DateKind.DayDotMonthYear">Day (31.12.2024)</MudSelectItem>
|
<MudSelectItem T="DateKind" Value="DateKind.DayDotMonthYear">@S.ImportWizard_DateDay</MudSelectItem>
|
||||||
</MudSelect>
|
</MudSelect>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
<MudItem xs="6" sm="6" md="2">
|
<MudItem xs="6" sm="6" md="2">
|
||||||
<MudNumericField T="int" @bind-Value="_headerRow" Label="Header row" Min="0" Margin="Margin.Dense" />
|
<MudNumericField T="int" @bind-Value="_headerRow" Label="@S.ImportWizard_HeaderRow" Min="0" Margin="Margin.Dense" />
|
||||||
</MudItem>
|
</MudItem>
|
||||||
<MudItem xs="6" sm="6" md="2">
|
<MudItem xs="6" sm="6" md="2">
|
||||||
<MudNumericField T="int" @bind-Value="_firstDataRow" Label="First data row" Min="0" Margin="Margin.Dense" />
|
<MudNumericField T="int" @bind-Value="_firstDataRow" Label="@S.ImportWizard_FirstDataRow" Min="0" Margin="Margin.Dense" />
|
||||||
</MudItem>
|
</MudItem>
|
||||||
<MudItem xs="12" md="2" Class="d-flex flex-column">
|
<MudItem xs="12" md="2" Class="d-flex flex-column">
|
||||||
<MudSwitch T="bool" @bind-Value="_skipAllZero" Label="Skip zero rows" Color="Color.Primary" />
|
<MudSwitch T="bool" @bind-Value="_skipAllZero" Label="@S.ImportWizard_SkipZeroRows" Color="Color.Primary" />
|
||||||
<MudSwitch T="bool" @bind-Value="_detectSwaps" Label="Detect swaps" Color="Color.Primary" />
|
<MudSwitch T="bool" @bind-Value="_detectSwaps" Label="@S.ImportWizard_DetectSwaps" Color="Color.Primary" />
|
||||||
</MudItem>
|
</MudItem>
|
||||||
</MudGrid>
|
</MudGrid>
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
|
|
||||||
<MudPaper Class="pa-4 mb-4" Elevation="2">
|
<MudPaper Class="pa-4 mb-4" Elevation="2">
|
||||||
<MudText Typo="Typo.h6" Class="mb-2">2. Column preview</MudText>
|
<MudText Typo="Typo.h6" Class="mb-2">@S.ImportWizard_Step2Title</MudText>
|
||||||
<div style="overflow-x:auto">
|
<div style="overflow-x:auto">
|
||||||
<MudSimpleTable Dense="true" Bordered="true" Style="min-width:100%">
|
<MudSimpleTable Dense="true" Bordered="true" Style="min-width:100%">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -79,7 +78,7 @@
|
|||||||
@foreach (var i in Enumerable.Range(0, _colCount))
|
@foreach (var i in Enumerable.Range(0, _colCount))
|
||||||
{
|
{
|
||||||
<th style="@(i == _dateColumn ? "background:var(--mud-palette-primary-hover)" : "")">
|
<th style="@(i == _dateColumn ? "background:var(--mud-palette-primary-hover)" : "")">
|
||||||
Col @i@(i == _dateColumn ? " 📅" : "")
|
@Loc.F(S.ImportWizard_ColumnN, i)@(i == _dateColumn ? " 📅" : "")
|
||||||
</th>
|
</th>
|
||||||
}
|
}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -98,23 +97,23 @@
|
|||||||
</MudSimpleTable>
|
</MudSimpleTable>
|
||||||
</div>
|
</div>
|
||||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||||
Faded rows are before the first data row. The 📅 column supplies the date.
|
@S.ImportWizard_PreviewCaption
|
||||||
</MudText>
|
</MudText>
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
|
|
||||||
<MudPaper Class="pa-4 mb-4" Elevation="2">
|
<MudPaper Class="pa-4 mb-4" Elevation="2">
|
||||||
<MudText Typo="Typo.h6" Class="mb-2">3. Map columns</MudText>
|
<MudText Typo="Typo.h6" Class="mb-2">@S.ImportWizard_Step3Title</MudText>
|
||||||
<div style="overflow-x:auto">
|
<div style="overflow-x:auto">
|
||||||
<MudSimpleTable Dense="true" Style="min-width:100%">
|
<MudSimpleTable Dense="true" Style="min-width:100%">
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>Column</th><th>Sample</th><th style="min-width:160px">Role</th><th style="min-width:220px">Target</th><th style="min-width:120px">Unit</th></tr>
|
<tr><th>@S.ImportWizard_HeaderColumn</th><th>@S.ImportWizard_HeaderSample</th><th style="min-width:160px">@S.ImportWizard_HeaderRole</th><th style="min-width:220px">@S.Common_Target</th><th style="min-width:120px">@S.Common_Unit</th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@foreach (var i in Enumerable.Range(0, _colCount))
|
@foreach (var i in Enumerable.Range(0, _colCount))
|
||||||
{
|
{
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<b>Col @i</b>
|
<b>@Loc.F(S.ImportWizard_ColumnN, i)</b>
|
||||||
@if (!string.IsNullOrWhiteSpace(Header(i)))
|
@if (!string.IsNullOrWhiteSpace(Header(i)))
|
||||||
{
|
{
|
||||||
<br /><span style="font-size:0.72rem; opacity:0.7">@Header(i)</span>
|
<br /><span style="font-size:0.72rem; opacity:0.7">@Header(i)</span>
|
||||||
@@ -125,7 +124,7 @@
|
|||||||
<MudSelect T="MappingRole" @bind-Value="_columns[i].Role" Dense="true" Margin="Margin.Dense">
|
<MudSelect T="MappingRole" @bind-Value="_columns[i].Role" Dense="true" Margin="Margin.Dense">
|
||||||
@foreach (var role in Enum.GetValues<MappingRole>())
|
@foreach (var role in Enum.GetValues<MappingRole>())
|
||||||
{
|
{
|
||||||
<MudSelectItem T="MappingRole" Value="role">@role</MudSelectItem>
|
<MudSelectItem T="MappingRole" Value="role">@role.Display()</MudSelectItem>
|
||||||
}
|
}
|
||||||
</MudSelect>
|
</MudSelect>
|
||||||
</td>
|
</td>
|
||||||
@@ -133,7 +132,7 @@
|
|||||||
@if (_columns[i].Role is MappingRole.Reading or MappingRole.Delivery or MappingRole.TankLevel)
|
@if (_columns[i].Role is MappingRole.Reading or MappingRole.Delivery or MappingRole.TankLevel)
|
||||||
{
|
{
|
||||||
<MudSelect T="int?" @bind-Value="_columns[i].MeterId" Dense="true" Margin="Margin.Dense"
|
<MudSelect T="int?" @bind-Value="_columns[i].MeterId" Dense="true" Margin="Margin.Dense"
|
||||||
Placeholder="Select meter" Clearable="true">
|
Placeholder="@S.ImportWizard_SelectMeter" Clearable="true">
|
||||||
@foreach (var m in _meters)
|
@foreach (var m in _meters)
|
||||||
{
|
{
|
||||||
<MudSelectItem T="int?" Value="m.Id">@m.Name (@m.Unit)</MudSelectItem>
|
<MudSelectItem T="int?" Value="m.Id">@m.Name (@m.Unit)</MudSelectItem>
|
||||||
@@ -143,7 +142,7 @@
|
|||||||
else if (_columns[i].Role == MappingRole.ManualCost)
|
else if (_columns[i].Role == MappingRole.ManualCost)
|
||||||
{
|
{
|
||||||
<MudSelect T="int?" @bind-Value="_columns[i].CategoryId" Dense="true" Margin="Margin.Dense"
|
<MudSelect T="int?" @bind-Value="_columns[i].CategoryId" Dense="true" Margin="Margin.Dense"
|
||||||
Placeholder="Select category" Clearable="true">
|
Placeholder="@S.ImportWizard_SelectCategory" Clearable="true">
|
||||||
@foreach (var c in _categories)
|
@foreach (var c in _categories)
|
||||||
{
|
{
|
||||||
<MudSelectItem T="int?" Value="c.Id">@c.Name</MudSelectItem>
|
<MudSelectItem T="int?" Value="c.Id">@c.Name</MudSelectItem>
|
||||||
@@ -154,7 +153,7 @@
|
|||||||
<td>
|
<td>
|
||||||
@if (_columns[i].Role is MappingRole.Reading or MappingRole.Delivery or MappingRole.TankLevel)
|
@if (_columns[i].Role is MappingRole.Reading or MappingRole.Delivery or MappingRole.TankLevel)
|
||||||
{
|
{
|
||||||
<MudTextField @bind-Value="_columns[i].Unit" Placeholder="e.g. kWh" Margin="Margin.Dense" />
|
<MudTextField @bind-Value="_columns[i].Unit" Placeholder="@S.ImportWizard_UnitPlaceholder" Margin="Margin.Dense" />
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -176,12 +175,12 @@
|
|||||||
|
|
||||||
<MudPaper Class="pa-4 mb-4" Elevation="2">
|
<MudPaper Class="pa-4 mb-4" Elevation="2">
|
||||||
<div class="d-flex align-center mb-2" style="gap:1rem; flex-wrap:wrap">
|
<div class="d-flex align-center mb-2" style="gap:1rem; flex-wrap:wrap">
|
||||||
<MudText Typo="Typo.h6">4. Preview & commit</MudText>
|
<MudText Typo="Typo.h6">@S.ImportWizard_Step4Title</MudText>
|
||||||
<MudButton Variant="Variant.Outlined" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Visibility"
|
<MudButton Variant="Variant.Outlined" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Visibility"
|
||||||
OnClick="Preview">Dry-run preview</MudButton>
|
OnClick="Preview">@S.ImportWizard_DryRunButton</MudButton>
|
||||||
<MudButton Variant="Variant.Filled" Color="Color.Success" StartIcon="@Icons.Material.Filled.Save"
|
<MudButton Variant="Variant.Filled" Color="Color.Success" StartIcon="@Icons.Material.Filled.Save"
|
||||||
OnClick="CommitAsync" Disabled="_staged is null || _staged.TotalRows == 0 || _committing">
|
OnClick="CommitAsync" Disabled="_staged is null || _staged.TotalRows == 0 || _committing">
|
||||||
Commit import
|
@S.ImportWizard_CommitButton
|
||||||
</MudButton>
|
</MudButton>
|
||||||
@if (_committing)
|
@if (_committing)
|
||||||
{
|
{
|
||||||
@@ -192,24 +191,24 @@
|
|||||||
@if (_staged is not null)
|
@if (_staged is not null)
|
||||||
{
|
{
|
||||||
<div class="d-flex mt-2" style="gap:2rem; flex-wrap:wrap">
|
<div class="d-flex mt-2" style="gap:2rem; flex-wrap:wrap">
|
||||||
<MudText>Readings: <b>@_staged.Readings.Count</b></MudText>
|
<MudText>@S.Common_ReadingsLabel <b>@_staged.Readings.Count</b></MudText>
|
||||||
<MudText>Events: <b>@_staged.Events.Count</b></MudText>
|
<MudText>@S.Common_EventsLabel <b>@_staged.Events.Count</b></MudText>
|
||||||
<MudText>Manual costs: <b>@_staged.ManualCosts.Count</b></MudText>
|
<MudText>@S.Common_ManualCostsLabel <b>@_staged.ManualCosts.Count</b></MudText>
|
||||||
<MudText>Skipped rows: <b>@_staged.SkippedRows</b></MudText>
|
<MudText>@S.Common_SkippedRowsLabel <b>@_staged.SkippedRows</b></MudText>
|
||||||
</div>
|
</div>
|
||||||
@if (_staged.TotalRows == 0)
|
@if (_staged.TotalRows == 0)
|
||||||
{
|
{
|
||||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-2">
|
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-2">
|
||||||
Nothing staged. Check the first-data-row, date column and column mappings above.
|
@S.ImportWizard_NothingStaged
|
||||||
</MudAlert>
|
</MudAlert>
|
||||||
}
|
}
|
||||||
@if (_staged.Warnings.Count > 0)
|
@if (_staged.Warnings.Count > 0)
|
||||||
{
|
{
|
||||||
<MudExpansionPanels Class="mt-3">
|
<MudExpansionPanels Class="mt-3">
|
||||||
<MudExpansionPanel Text="@($"{_staged.Warnings.Count} warnings")">
|
<MudExpansionPanel Text="@Loc.F(S.Import_WarningsCount, _staged.Warnings.Count)">
|
||||||
@foreach (var warning in _staged.Warnings.Take(100))
|
@foreach (var warning in _staged.Warnings.Take(100))
|
||||||
{
|
{
|
||||||
<MudText Typo="Typo.body2">@warning</MudText>
|
<MudText Typo="Typo.body2">@warning.Display()</MudText>
|
||||||
}
|
}
|
||||||
</MudExpansionPanel>
|
</MudExpansionPanel>
|
||||||
</MudExpansionPanels>
|
</MudExpansionPanels>
|
||||||
@@ -243,6 +242,7 @@
|
|||||||
private List<Meter> _meters = [];
|
private List<Meter> _meters = [];
|
||||||
private List<CostCategory> _categories = [];
|
private List<CostCategory> _categories = [];
|
||||||
private StagedImport? _staged;
|
private StagedImport? _staged;
|
||||||
|
private string? _stagedMapping;
|
||||||
private List<string> _validationErrors = [];
|
private List<string> _validationErrors = [];
|
||||||
private bool _committing;
|
private bool _committing;
|
||||||
|
|
||||||
@@ -265,6 +265,7 @@
|
|||||||
_columns = Enumerable.Range(0, _colCount).Select(_ => new ColumnState()).ToArray();
|
_columns = Enumerable.Range(0, _colCount).Select(_ => new ColumnState()).ToArray();
|
||||||
_dateColumn = 0;
|
_dateColumn = 0;
|
||||||
_staged = null;
|
_staged = null;
|
||||||
|
_stagedMapping = null;
|
||||||
_validationErrors = [];
|
_validationErrors = [];
|
||||||
|
|
||||||
await using var db = await DbFactory.CreateDbContextAsync();
|
await using var db = await DbFactory.CreateDbContextAsync();
|
||||||
@@ -283,8 +284,23 @@
|
|||||||
|
|
||||||
using var reader = new StringReader(_csvText ?? string.Empty);
|
using var reader = new StringReader(_csvText ?? string.Empty);
|
||||||
_staged = CsvImporter.Stage(BuildProfile(), reader);
|
_staged = CsvImporter.Stage(BuildProfile(), reader);
|
||||||
|
_stagedMapping = BuildMappingJson();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The mapping as persisted on the batch for provenance, and — compared against the mapping the
|
||||||
|
/// preview was staged under — the check that the two still agree.
|
||||||
|
/// </summary>
|
||||||
|
private string BuildMappingJson() => JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
dateColumn = _dateColumn,
|
||||||
|
dateKind = _dateKind.ToString(),
|
||||||
|
firstDataRow = _firstDataRow,
|
||||||
|
columns = _columns
|
||||||
|
.Select((c, i) => new { index = i, role = c.Role.ToString(), c.MeterId, c.CategoryId, c.Unit })
|
||||||
|
.Where(c => c.role != nameof(MappingRole.Ignore)),
|
||||||
|
});
|
||||||
|
|
||||||
private async Task CommitAsync()
|
private async Task CommitAsync()
|
||||||
{
|
{
|
||||||
if (_staged is null || _staged.TotalRows == 0)
|
if (_staged is null || _staged.TotalRows == 0)
|
||||||
@@ -300,26 +316,29 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The staged rows were built from the mapping as it stood at preview time. Editing a target
|
||||||
|
// afterwards leaves them pointing at the old meter while the batch would record the new
|
||||||
|
// mapping — wrong data, with a provenance record that contradicts it and no error to notice.
|
||||||
|
// Nothing here can re-derive the rows, so refuse rather than write either version.
|
||||||
|
var mappingJson = BuildMappingJson();
|
||||||
|
if (!string.Equals(mappingJson, _stagedMapping, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
_staged = null;
|
||||||
|
_stagedMapping = null;
|
||||||
|
_validationErrors = [S.ImportWizard_MappingChanged];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
_committing = true;
|
_committing = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var mappingJson = JsonSerializer.Serialize(new
|
|
||||||
{
|
|
||||||
dateColumn = _dateColumn,
|
|
||||||
dateKind = _dateKind.ToString(),
|
|
||||||
firstDataRow = _firstDataRow,
|
|
||||||
columns = _columns
|
|
||||||
.Select((c, i) => new { index = i, role = c.Role.ToString(), c.MeterId, c.CategoryId, c.Unit })
|
|
||||||
.Where(c => c.role != nameof(MappingRole.Ignore)),
|
|
||||||
});
|
|
||||||
|
|
||||||
var batchId = await ImportService.CommitAsync(_staged, _fileName, mappingJson);
|
var batchId = await ImportService.CommitAsync(_staged, _fileName, mappingJson);
|
||||||
Snackbar.Add($"Imported batch #{batchId}: {_staged.TotalRows} rows staged. Consumption recomputed.", Severity.Success);
|
Snackbar.Add(Loc.F(S.ImportWizard_CommitSuccess, batchId, _staged.TotalRows), Severity.Success);
|
||||||
Nav.NavigateTo("/import");
|
Nav.NavigateTo("/import");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Snackbar.Add($"Commit failed: {ex.Message}", Severity.Error);
|
Snackbar.Add(Loc.F(S.ImportWizard_CommitFailed, ex.Message), Severity.Error);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -332,7 +351,7 @@
|
|||||||
var errors = new List<string>();
|
var errors = new List<string>();
|
||||||
if (_columns.Count(c => c.Role != MappingRole.Ignore) == 0)
|
if (_columns.Count(c => c.Role != MappingRole.Ignore) == 0)
|
||||||
{
|
{
|
||||||
errors.Add("Map at least one column to a role other than Ignore.");
|
errors.Add(S.ImportWizard_ValidateNoMappedColumn);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (var i = 0; i < _columns.Length; i++)
|
for (var i = 0; i < _columns.Length; i++)
|
||||||
@@ -340,12 +359,12 @@
|
|||||||
var c = _columns[i];
|
var c = _columns[i];
|
||||||
if (c.Role is MappingRole.Reading or MappingRole.Delivery or MappingRole.TankLevel && c.MeterId is null)
|
if (c.Role is MappingRole.Reading or MappingRole.Delivery or MappingRole.TankLevel && c.MeterId is null)
|
||||||
{
|
{
|
||||||
errors.Add($"Col {i} ({c.Role}) needs a target meter.");
|
errors.Add(Loc.F(S.ImportWizard_ValidateNeedsMeter, i, c.Role.Display()));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (c.Role == MappingRole.ManualCost && c.CategoryId is null)
|
if (c.Role == MappingRole.ManualCost && c.CategoryId is null)
|
||||||
{
|
{
|
||||||
errors.Add($"Col {i} (ManualCost) needs a target category.");
|
errors.Add(Loc.F(S.ImportWizard_ValidateNeedsCategory, i));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,9 +378,10 @@
|
|||||||
|
|
||||||
foreach (var group in duplicateTargets)
|
foreach (var group in duplicateTargets)
|
||||||
{
|
{
|
||||||
var meterName = _meters.FirstOrDefault(m => m.Id == group.Key)?.Name ?? $"meter {group.Key}";
|
var meterName = _meters.FirstOrDefault(m => m.Id == group.Key)?.Name
|
||||||
var cols = string.Join(", ", group.Select(x => $"Col {x.Index}"));
|
?? Loc.F(S.ImportWizard_MeterFallback, group.Key);
|
||||||
errors.Add($"{cols} all read into '{meterName}'. Each Reading column needs its own meter.");
|
var cols = string.Join(", ", group.Select(x => Loc.F(S.ImportWizard_ColumnN, x.Index)));
|
||||||
|
errors.Add(Loc.F(S.ImportWizard_ValidateDuplicateMeter, cols, meterName));
|
||||||
}
|
}
|
||||||
|
|
||||||
return errors;
|
return errors;
|
||||||
@@ -411,6 +431,8 @@
|
|||||||
private string ColLabel(int col)
|
private string ColLabel(int col)
|
||||||
{
|
{
|
||||||
var header = Header(col);
|
var header = Header(col);
|
||||||
return string.IsNullOrWhiteSpace(header) ? $"Col {col}" : $"Col {col}: {header}";
|
return string.IsNullOrWhiteSpace(header)
|
||||||
|
? Loc.F(S.ImportWizard_ColumnN, col)
|
||||||
|
: Loc.F(S.ImportWizard_ColumnWithHeader, col, header);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,488 +1,548 @@
|
|||||||
@page "/meters/{Id:int}"
|
@page "/meters/{Id:int}"
|
||||||
|
@using MeterVault.App.Components.Pages.MeterPage
|
||||||
|
@using MeterVault.App.MeterDetails
|
||||||
|
@using MeterVault.Core.Analysis
|
||||||
@inject MeterDetailService Details
|
@inject MeterDetailService Details
|
||||||
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
@inject AnalysisPeriods Periods
|
||||||
@inject ISnackbar Snackbar
|
@inject MeterVault.Infrastructure.Analysis.AnalysisReader Reader
|
||||||
@inject IDialogService DialogService
|
@inject CostReader Costs
|
||||||
|
@inject InstanceClock Clock
|
||||||
@inject NavigationManager Nav
|
@inject NavigationManager Nav
|
||||||
@using Microsoft.EntityFrameworkCore
|
@inject ILogger<MeterDetail> Logger
|
||||||
@using MeterVault.Infrastructure.Ingestion
|
@implements IDisposable
|
||||||
@using MudBlazor
|
|
||||||
|
|
||||||
<PageTitle>MeterVault — Meter</PageTitle>
|
@* The per-meter hub (brief §7.2, SDD §8.6): the header with the meter's identity and its actions, the tab bar directly
|
||||||
|
below it, and the analysis in the default Analysis tab — so Sources and Events never sit under a wall of charts.
|
||||||
|
Tabs are addressed by stable keys (D-47) and written back to the address on a click (replace); the analysis reloads
|
||||||
|
only when its own keys change (D-46), and the one-shot `action` is consumed once, separately from both. *@
|
||||||
|
|
||||||
@if (_detail is null)
|
@if (_detail is null)
|
||||||
{
|
{
|
||||||
|
<PageTitle>MeterVault — @S.Common_Meter</PageTitle>
|
||||||
@if (_notFound)
|
@if (_notFound)
|
||||||
{
|
{
|
||||||
<MudAlert Severity="Severity.Warning">Meter #@Id not found. <MudLink Href="/meters">Back to meters</MudLink></MudAlert>
|
<MudAlert Severity="Severity.Warning">@Loc.F(S.MeterDetail_NotFound, Id) <MudLink Href="/meters">@S.MeterDetail_BackToMeters</MudLink></MudAlert>
|
||||||
|
}
|
||||||
|
else if (_detailFailed)
|
||||||
|
{
|
||||||
|
<PanelError HasStaleValue="false" OnRetry="RetryDetailAsync" />
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
|
<MudProgressLinear Indeterminate="true" Color="Color.Primary" aria-label="@S.Refresh_Updating" />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="d-flex align-center mb-4" style="gap:.75rem">
|
<MeterHeader Detail="_detail" Query="_query" Today="Clock.Today"
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.ArrowBack" Href="/meters" Size="Size.Small" />
|
OnAddReading="OpenReadingAsync" OnRecordEvent="@(type => OpenEventAsync(type))" OnEdit="OpenEditorAsync">
|
||||||
<MudText Typo="Typo.h4">@_detail.Name</MudText>
|
@if (_detail.Mode == MeterMode.ConsumableBalance && !_detail.HasTank)
|
||||||
<MudChip T="string" Size="Size.Small" Color="Color.Primary">@_detail.EnergyType</MudChip>
|
|
||||||
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined">@_detail.Mode</MudChip>
|
|
||||||
@if (!_detail.IsActive)
|
|
||||||
{
|
{
|
||||||
<MudChip T="string" Size="Size.Small" Color="Color.Warning">retired</MudChip>
|
<MudAlert Severity="Severity.Warning" Dense="true" Class="mt-2">
|
||||||
}
|
@S.MeterDetail_NoTankConfigured
|
||||||
</div>
|
<MudButton Size="Size.Small" Variant="Variant.Text" Color="Color.Warning" Class="ml-2"
|
||||||
|
OnClick="OpenEditorAsync">@S.MeterDetail_SetUpTank</MudButton>
|
||||||
<MudGrid Class="mb-2">
|
|
||||||
<MudItem xs="6" sm="3">
|
|
||||||
<MudPaper Class="pa-3" Elevation="2">
|
|
||||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Consumption</MudText>
|
|
||||||
<MudText Typo="Typo.h6">@Format.Number(_detail.TotalConsumption, 0) @_detail.Unit</MudText>
|
|
||||||
</MudPaper>
|
|
||||||
</MudItem>
|
|
||||||
@if (_detail.TotalGeneration != 0)
|
|
||||||
{
|
|
||||||
<MudItem xs="6" sm="3">
|
|
||||||
<MudPaper Class="pa-3" Elevation="2">
|
|
||||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Generation</MudText>
|
|
||||||
<MudText Typo="Typo.h6">@Format.Number(_detail.TotalGeneration, 0) @_detail.Unit</MudText>
|
|
||||||
</MudPaper>
|
|
||||||
</MudItem>
|
|
||||||
}
|
|
||||||
<MudItem xs="6" sm="3">
|
|
||||||
<MudPaper Class="pa-3" Elevation="2">
|
|
||||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Readings</MudText>
|
|
||||||
<MudText Typo="Typo.h6">@_detail.ReadingCount</MudText>
|
|
||||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
|
||||||
@(_detail.FirstReadingTime?.ToString("yyyy-MM") ?? "—") … @(_detail.LastReadingTime?.ToString("yyyy-MM") ?? "—")
|
|
||||||
</MudText>
|
|
||||||
</MudPaper>
|
|
||||||
</MudItem>
|
|
||||||
<MudItem xs="6" sm="3">
|
|
||||||
<MudPaper Class="pa-3" Elevation="2">
|
|
||||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Register span</MudText>
|
|
||||||
<MudText Typo="Typo.h6">
|
|
||||||
@(_detail.FirstReadingValue is { } f ? Format.Number(f, 0) : "—") →
|
|
||||||
@(_detail.LastReadingValue is { } l ? Format.Number(l, 0) : "—")
|
|
||||||
</MudText>
|
|
||||||
<MudText Typo="Typo.caption" Color="Color.Secondary">baseline @Format.Number(_detail.InitialBaseline, 0)</MudText>
|
|
||||||
</MudPaper>
|
|
||||||
</MudItem>
|
|
||||||
</MudGrid>
|
|
||||||
|
|
||||||
<MudTabs Elevation="2" Rounded="true" ApplyEffectsToContainer="true" Class="mt-2">
|
|
||||||
<MudTabPanel Text="@($"Readings ({_detail.ReadingCount})")">
|
|
||||||
@if (_detail.RecentReadings.Count == 0)
|
|
||||||
{
|
|
||||||
<MudText Typo="Typo.body2" Color="Color.Secondary">No raw readings.</MudText>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Most recent @_detail.RecentReadings.Count (raw, immutable audit truth).</MudText>
|
|
||||||
<MudSimpleTable Dense="true" Hover="true" Class="mt-2">
|
|
||||||
<thead><tr><th>Time</th><th style="text-align:right">Value</th><th>Quality</th><th>Flags</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
@foreach (var r in _detail.RecentReadings)
|
|
||||||
{
|
|
||||||
<tr>
|
|
||||||
<td>@r.Time.ToString("yyyy-MM-dd HH:mm")</td>
|
|
||||||
<td style="text-align:right">@Format.Number(r.Value, 2) @_detail.Unit</td>
|
|
||||||
<td>@QualityChip(r.Quality)</td>
|
|
||||||
<td>@(r.Flags == MeterVault.Core.Domain.ReadingFlags.None ? "" : r.Flags.ToString())</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</MudSimpleTable>
|
|
||||||
}
|
|
||||||
</MudTabPanel>
|
|
||||||
|
|
||||||
<MudTabPanel Text="@($"Consumption ({_detail.ConsumptionCount})")">
|
|
||||||
@if (_detail.RecentConsumption.Count == 0)
|
|
||||||
{
|
|
||||||
<MudText Typo="Typo.body2" Color="Color.Secondary">No normalized consumption yet.</MudText>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Most recent @_detail.RecentConsumption.Count normalized deltas.</MudText>
|
|
||||||
<MudSimpleTable Dense="true" Hover="true" Class="mt-2">
|
|
||||||
<thead><tr><th>Time</th><th style="text-align:right">Amount</th><th>Kind</th><th>Quality</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
@foreach (var c in _detail.RecentConsumption)
|
|
||||||
{
|
|
||||||
<tr>
|
|
||||||
<td>@c.Time.ToString("yyyy-MM-dd HH:mm")</td>
|
|
||||||
<td style="text-align:right">@Format.Number(c.Amount, 2) @_detail.Unit</td>
|
|
||||||
<td>@c.Kind</td>
|
|
||||||
<td>@QualityChip(c.Quality)</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</MudSimpleTable>
|
|
||||||
}
|
|
||||||
</MudTabPanel>
|
|
||||||
|
|
||||||
<MudTabPanel Text="@($"Events ({_detail.Events.Count})")">
|
|
||||||
@if (_detail.Events.Count == 0)
|
|
||||||
{
|
|
||||||
<MudText Typo="Typo.body2" Color="Color.Secondary">No events (swaps, deliveries, corrections).</MudText>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<MudSimpleTable Dense="true" Hover="true">
|
|
||||||
<thead><tr><th>Time</th><th>Type</th><th style="text-align:right">Amount</th><th style="text-align:right">Prev→New</th><th>Notes</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
@foreach (var e in _detail.Events)
|
|
||||||
{
|
|
||||||
<tr>
|
|
||||||
<td>@e.Time.ToString("yyyy-MM-dd")</td>
|
|
||||||
<td>@e.Type</td>
|
|
||||||
<td style="text-align:right">@(e.Amount is { } a ? $"{Format.Number(a, 2)} {e.Unit}" : "—")</td>
|
|
||||||
<td style="text-align:right">@(e.PrevValue is { } p ? $"{Format.Number(p, 0)}→{Format.Number(e.NewValue ?? 0, 0)}" : "—")</td>
|
|
||||||
<td>@e.Notes</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</MudSimpleTable>
|
|
||||||
}
|
|
||||||
</MudTabPanel>
|
|
||||||
|
|
||||||
<MudTabPanel Text="@($"Tariffs ({_detail.Tariffs.Count})")">
|
|
||||||
@if (_detail.Tariffs.Count == 0)
|
|
||||||
{
|
|
||||||
<MudText Typo="Typo.body2" Color="Color.Secondary">No applicable tariffs.</MudText>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<MudSimpleTable Dense="true" Hover="true">
|
|
||||||
<thead><tr><th>Scope</th><th>Component</th><th style="text-align:right">Value</th><th>Unit</th><th>From</th><th>To</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
@foreach (var t in _detail.Tariffs)
|
|
||||||
{
|
|
||||||
<tr>
|
|
||||||
<td>@t.Scope @(t.ScopeId is { } id ? $"#{id}" : "")</td>
|
|
||||||
<td>@t.Component</td>
|
|
||||||
<td style="text-align:right">@Format.Number(t.Value, 4)</td>
|
|
||||||
<td>@t.Unit</td>
|
|
||||||
<td>@t.ValidFrom.ToString("yyyy-MM-dd")</td>
|
|
||||||
<td>@(t.ValidTo?.ToString("yyyy-MM-dd") ?? "open")</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</MudSimpleTable>
|
|
||||||
}
|
|
||||||
</MudTabPanel>
|
|
||||||
|
|
||||||
<MudTabPanel Text="@($"Sources ({_sources.Count})")">
|
|
||||||
<div class="d-flex justify-end mb-2">
|
|
||||||
<MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenSource(null))">
|
|
||||||
Add source
|
|
||||||
</MudButton>
|
|
||||||
</div>
|
|
||||||
@if (_sources.Count == 0)
|
|
||||||
{
|
|
||||||
<MudText Typo="Typo.body2" Color="Color.Secondary">No ingest sources bound to this meter. Add one to pull from MQTT/Tasmota or Home Assistant.</MudText>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<MudSimpleTable Dense="true" Hover="true">
|
|
||||||
<thead><tr><th>Type</th><th>Target</th><th>Enabled</th><th>Last seen</th><th style="text-align:right">Last value</th><th>Status</th><th style="text-align:right">Actions</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
@foreach (var s in _sources)
|
|
||||||
{
|
|
||||||
<tr>
|
|
||||||
<td>@s.SourceType</td>
|
|
||||||
<td>@SourceTarget(s)</td>
|
|
||||||
<td>@(s.IsEnabled ? "yes" : "no")</td>
|
|
||||||
<td>@(s.LastSeenAt?.ToString("yyyy-MM-dd HH:mm") ?? "—")</td>
|
|
||||||
<td style="text-align:right">@(s.LastValue is { } v ? Format.Number(v, 2) : "—")</td>
|
|
||||||
<td>@(s.LastStatus ?? "—")</td>
|
|
||||||
<td style="text-align:right">
|
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => OpenSource(s))" />
|
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteSourceAsync(s))" />
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</MudSimpleTable>
|
|
||||||
}
|
|
||||||
</MudTabPanel>
|
|
||||||
</MudTabs>
|
|
||||||
|
|
||||||
<MudDialog @bind-Visible="_sourceOpen" Options="_dialogOptions">
|
|
||||||
<TitleContent>
|
|
||||||
<MudText Typo="Typo.h6">@(_sourceEdit.Id == 0 ? "New source" : "Edit source")</MudText>
|
|
||||||
</TitleContent>
|
|
||||||
<DialogContent>
|
|
||||||
<MudSelect T="SourceType" Value="_sourceEdit.SourceType" ValueChanged="OnSourceTypeChanged" Label="Source type" Class="mb-2">
|
|
||||||
@foreach (var type in Enum.GetValues<SourceType>())
|
|
||||||
{
|
|
||||||
<MudSelectItem T="SourceType" Value="type">@type</MudSelectItem>
|
|
||||||
}
|
|
||||||
</MudSelect>
|
|
||||||
@if (RequiredEndpointType(_sourceEdit.SourceType) is { } needed)
|
|
||||||
{
|
|
||||||
if (ConnectorsFor(needed).Count == 0)
|
|
||||||
{
|
|
||||||
<MudAlert Severity="Severity.Warning" Dense="true" Class="mb-2">
|
|
||||||
No @needed connector yet — <MudLink Href="/admin/connectors">create one</MudLink>
|
|
||||||
(set it up once; every source then just picks it).
|
|
||||||
</MudAlert>
|
</MudAlert>
|
||||||
}
|
}
|
||||||
|
else if (IsUnstarted)
|
||||||
|
{
|
||||||
|
@* A brand-new meter has nothing to show yet; say what makes it useful instead of a page of dashes. *@
|
||||||
|
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-2">
|
||||||
|
@S.MeterDetail_GetStarted
|
||||||
|
<div class="d-flex flex-wrap mt-2" style="gap:.5rem">
|
||||||
|
@if (TakesReadings)
|
||||||
|
{
|
||||||
|
<MudButton Size="Size.Small" Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.EditNote" OnClick="OpenReadingAsync">@S.MeterDetail_AddFirstReading</MudButton>
|
||||||
|
<MudButton Size="Size.Small" Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.SettingsInputComponent"
|
||||||
|
Href="@MeterLinks.Source(_detail.Id)">@S.MeterDetail_ConnectSource</MudButton>
|
||||||
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<MudSelect T="int?" @bind-Value="_sourceEdit.EndpointId" Label="Connector" Required="true" Class="mb-2">
|
<MudButton Size="Size.Small" Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Straighten"
|
||||||
@foreach (var e in ConnectorsFor(needed))
|
OnClick="@(() => OpenEventAsync(MeterEventType.TankLevel))">@S.MeterDetail_RecordTankLevel</MudButton>
|
||||||
{
|
|
||||||
<MudSelectItem T="int?" Value="@((int?)e.Id)">@e.Name</MudSelectItem>
|
|
||||||
}
|
}
|
||||||
</MudSelect>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@if (_sourceEdit.SourceType == SourceType.HomeAssistant)
|
|
||||||
{
|
|
||||||
<MudTextField @bind-Value="_sourceEdit.EntityId" Label="Entity id (e.g. sensor.house_power)" Class="mb-2" />
|
|
||||||
<MudTextField @bind-Value="_sourceEdit.Attribute" Label="Attribute (optional; blank = state)" Class="mb-2" />
|
|
||||||
<MudNumericField T="int?" @bind-Value="_sourceEdit.PollSeconds" Label="Poll interval (seconds)" Class="mb-2" />
|
|
||||||
}
|
|
||||||
else if (_sourceEdit.SourceType is SourceType.Mqtt or SourceType.Tasmota)
|
|
||||||
{
|
|
||||||
<MudTextField @bind-Value="_sourceEdit.Topic" Label="MQTT topic (e.g. tele/plug1/SENSOR)" Class="mb-2" />
|
|
||||||
<MudTextField @bind-Value="_sourceEdit.Path" Label="Value path (e.g. ENERGY.Total; blank = bare scalar)" Class="mb-2" />
|
|
||||||
<MudTextField @bind-Value="_sourceEdit.TimePath" Label="Time path (optional, e.g. Time)" Class="mb-2" />
|
|
||||||
}
|
|
||||||
<MudSelect T="SourceValueKind" @bind-Value="_sourceEdit.ValueKind" Label="Value kind" Class="mb-2">
|
|
||||||
@foreach (var kind in Enum.GetValues<SourceValueKind>())
|
|
||||||
{
|
|
||||||
<MudSelectItem T="SourceValueKind" Value="kind">@kind</MudSelectItem>
|
|
||||||
}
|
|
||||||
</MudSelect>
|
|
||||||
<div class="d-flex" style="gap:1rem">
|
|
||||||
<MudNumericField T="double" @bind-Value="_sourceEdit.Scale" Label="Scale" Class="mb-2" />
|
|
||||||
<MudNumericField T="double" @bind-Value="_sourceEdit.Offset" Label="Offset" Class="mb-2" />
|
|
||||||
<MudNumericField T="int" @bind-Value="_sourceEdit.Priority" Label="Priority" Class="mb-2" />
|
|
||||||
</div>
|
</div>
|
||||||
<MudSwitch T="bool" @bind-Value="_sourceEdit.IsEnabled" Label="Enabled" Color="Color.Primary" />
|
</MudAlert>
|
||||||
</DialogContent>
|
}
|
||||||
<DialogActions>
|
</MeterHeader>
|
||||||
<MudButton OnClick="@(() => _sourceOpen = false)">Cancel</MudButton>
|
|
||||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveSourceAsync">Save</MudButton>
|
<MudTabs @key="@($"{_detail.Id}:{_detail.Mode}")" ActivePanelIndex="ActiveIndex" ActivePanelIndexChanged="OnTabIndexChanged"
|
||||||
</DialogActions>
|
Elevation="0" Rounded="true" ApplyEffectsToContainer="true" Border="true" Class="mt-3">
|
||||||
</MudDialog>
|
@foreach (var key in MeterLinks.VisibleTabs(_detail.Mode))
|
||||||
|
{
|
||||||
|
<MudTabPanel Text="@TabLabel(key)" ID="@key">
|
||||||
|
<div class="pt-4">
|
||||||
|
@switch (key)
|
||||||
|
{
|
||||||
|
case MeterLinks.TabAnalysis:
|
||||||
|
<MeterAnalysisTab Detail="_detail" Query="_query" State="_analysis" OnQueryChanged="ReplaceQuery"
|
||||||
|
OnRetry="ReloadAnalysisAsync" OnAddReading="OpenReadingAsync"
|
||||||
|
OnRecordEvent="@(type => OpenEventAsync(type))" OnEdit="OpenEditorAsync" />
|
||||||
|
break;
|
||||||
|
case MeterLinks.TabReadings:
|
||||||
|
<MeterReadingsTab Detail="_detail" Query="_query" Version="_version" OnQueryChanged="ReplaceQuery"
|
||||||
|
OnChanged="RefreshAsync" OnAddReading="OpenReadingAsync" />
|
||||||
|
break;
|
||||||
|
case MeterLinks.TabNormalized:
|
||||||
|
<MeterNormalizedTab Detail="_detail" Query="_query" Version="_version" OnQueryChanged="ReplaceQuery" />
|
||||||
|
break;
|
||||||
|
case MeterLinks.TabEvents:
|
||||||
|
<MeterEventsTab Detail="_detail" Query="_query" Version="_version" OnQueryChanged="ReplaceQuery"
|
||||||
|
OnChanged="RefreshAsync" OnRecordEvent="@(type => OpenEventAsync(type))" />
|
||||||
|
break;
|
||||||
|
case MeterLinks.TabTariffs:
|
||||||
|
<MeterTariffsTab Detail="_detail" Version="_version" />
|
||||||
|
break;
|
||||||
|
case MeterLinks.TabSources:
|
||||||
|
<MeterSourcesTab Detail="_detail" Zone="Periods.Zone" Version="_version"
|
||||||
|
OnEdit="OpenSourceAsync" OnChanged="RefreshAsync" />
|
||||||
|
break;
|
||||||
|
case MeterLinks.TabCalculation:
|
||||||
|
<MeterCalculationTab Detail="_detail" Query="_query" Version="_version" OnEdit="OpenEditorAsync" />
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</MudTabPanel>
|
||||||
|
}
|
||||||
|
</MudTabs>
|
||||||
|
|
||||||
|
<ManualReadingDialog @ref="_reading" MeterId="_detail.Id" MeterName="@_detail.Name" Unit="@_detail.Unit" Zone="Periods.Zone"
|
||||||
|
Saved="RefreshAsync" SwitchToEvent="OnSwitchToEventAsync" />
|
||||||
|
<MeterSourceDialog @ref="_source" MeterId="_detail.Id" Saved="RefreshAsync" />
|
||||||
|
<MeterEventDialog @ref="_eventDialog" MeterId="_detail.Id" MeterName="@_detail.Name" Saved="OnEventSavedAsync" Cancelled="OnEventCancelledAsync" />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<MeterEditor @ref="_editor" Saved="OnMeterSavedAsync" SwapInsteadRequested="@(_ => OpenEventAsync(MeterEventType.MeterSwap))"
|
||||||
|
PeriodQuery="_query" />
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Which tab to open: a stable key (<see cref="MeterLinks.VisibleTabs"/>); old keys resolve (D-47).</summary>
|
||||||
|
[SupplyParameterFromQuery(Name = "tab")]
|
||||||
|
public string? Tab { get; set; }
|
||||||
|
|
||||||
|
/// <summary>A dialog to open once the page is interactive; see <see cref="MeterLinks"/>.</summary>
|
||||||
|
[SupplyParameterFromQuery(Name = "action")]
|
||||||
|
public string? Action { get; set; }
|
||||||
|
|
||||||
|
/// <summary>With the source action: the existing source to open instead of a new one.</summary>
|
||||||
|
[SupplyParameterFromQuery(Name = MeterLinks.ParamSource)]
|
||||||
|
public int? SourceParam { get; set; }
|
||||||
|
|
||||||
|
/// <summary>With the source action: the source type to preset.</summary>
|
||||||
|
[SupplyParameterFromQuery(Name = MeterLinks.ParamSourceType)]
|
||||||
|
public string? SourceTypeParam { get; set; }
|
||||||
|
|
||||||
|
/// <summary>With the source action: the connector to preselect, typically one just created for it.</summary>
|
||||||
|
[SupplyParameterFromQuery(Name = MeterLinks.ParamConnector)]
|
||||||
|
public int? ConnectorParam { get; set; }
|
||||||
|
|
||||||
|
// The analysis keys (D-46). Declared so a change to any of them re-runs OnParametersSet; the query itself is parsed
|
||||||
|
// from the whole address, which is what AnalysisQuery reads.
|
||||||
|
[SupplyParameterFromQuery(Name = AnalysisUrlKeys.Period)]
|
||||||
|
public string? PeriodKey { get; set; }
|
||||||
|
|
||||||
|
[SupplyParameterFromQuery(Name = AnalysisUrlKeys.From)]
|
||||||
|
public string? FromKey { get; set; }
|
||||||
|
|
||||||
|
[SupplyParameterFromQuery(Name = AnalysisUrlKeys.To)]
|
||||||
|
public string? ToKey { get; set; }
|
||||||
|
|
||||||
|
[SupplyParameterFromQuery(Name = AnalysisUrlKeys.Bucket)]
|
||||||
|
public string? BucketKey { get; set; }
|
||||||
|
|
||||||
|
[SupplyParameterFromQuery(Name = AnalysisUrlKeys.Compare)]
|
||||||
|
public string? CompareKey { get; set; }
|
||||||
|
|
||||||
|
[SupplyParameterFromQuery(Name = AnalysisUrlKeys.Metric)]
|
||||||
|
public string? MetricKey { get; set; }
|
||||||
|
|
||||||
|
private readonly LoadSequencer _detailLoads = new();
|
||||||
|
private readonly LoadSequencer _analysisLoads = new();
|
||||||
|
private readonly LoadState<MeterAnalysisView> _analysis = new();
|
||||||
|
|
||||||
private MeterDetailView? _detail;
|
private MeterDetailView? _detail;
|
||||||
private bool _notFound;
|
private bool _notFound;
|
||||||
private List<MeterSource> _sources = [];
|
private bool _detailFailed;
|
||||||
private List<IngestionEndpoint> _endpoints = [];
|
private int? _loadedId;
|
||||||
private bool _sourceOpen;
|
|
||||||
private SourceEdit _sourceEdit = new();
|
/// <summary>The page's analysis state (scope: this meter).</summary>
|
||||||
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
|
private AnalysisQuery _query = AnalysisQuery.Default(AnalysisDefaults.History);
|
||||||
|
|
||||||
|
/// <summary>The query the analysis was last requested for; a tab change or an action drop never reloads it (D-46).</summary>
|
||||||
|
private AnalysisQuery? _analysisRequested;
|
||||||
|
|
||||||
|
private string _activeTab = MeterLinks.TabAnalysis;
|
||||||
|
private string? _appliedTab;
|
||||||
|
private bool _tabApplied;
|
||||||
|
|
||||||
|
/// <summary>Bumped whenever the meter's data changed, so the open tab reloads.</summary>
|
||||||
|
private int _version;
|
||||||
|
|
||||||
|
private string? _pendingAction;
|
||||||
|
private SourcePreset? _pendingSource;
|
||||||
|
private bool _droppingAction;
|
||||||
|
private bool _refreshBeforeAction;
|
||||||
|
|
||||||
|
private ManualReadingDialog? _reading;
|
||||||
|
private MeterSourceDialog? _source;
|
||||||
|
private MeterEventDialog? _eventDialog;
|
||||||
|
private MeterEditor? _editor;
|
||||||
|
|
||||||
|
private AnalysisDefaults Defaults => MeterAnalysisLoader.DefaultsFor(Id);
|
||||||
|
|
||||||
|
private bool TakesReadings => _detail is not null && MeterEventRules.TakesReadings(_detail.Mode);
|
||||||
|
|
||||||
|
private bool IsUnstarted =>
|
||||||
|
_detail is { HasReadings: false, HasEvents: false, SourceCount: 0, IsVirtual: false };
|
||||||
|
|
||||||
|
private int ActiveIndex => _detail is null ? 0 : MeterLinks.PanelIndex(_activeTab, _detail.Mode);
|
||||||
|
|
||||||
protected override async Task OnParametersSetAsync()
|
protected override async Task OnParametersSetAsync()
|
||||||
{
|
{
|
||||||
|
var id = Id;
|
||||||
|
|
||||||
|
// Only a different meter reloads the page. The query string changes too — a deep link into a tab, the action
|
||||||
|
// being dropped once consumed, a period — and none of those should blank and refetch it.
|
||||||
|
var fresh = _loadedId != id;
|
||||||
|
if (fresh)
|
||||||
|
{
|
||||||
|
_loadedId = id;
|
||||||
_detail = null;
|
_detail = null;
|
||||||
_notFound = false;
|
_notFound = false;
|
||||||
_detail = await Details.GetAsync(Id);
|
_detailFailed = false;
|
||||||
_notFound = _detail is null;
|
|
||||||
if (_detail is not null)
|
// Per-meter view state: another meter opens on the tab its link names, shows none of the previous meter's
|
||||||
{
|
// figures, and an action meant for the previous meter must not fire on this one.
|
||||||
await LoadSourcesAsync();
|
_analysis.Clear();
|
||||||
}
|
_analysisRequested = null;
|
||||||
|
_activeTab = MeterLinks.TabAnalysis;
|
||||||
|
_appliedTab = null;
|
||||||
|
_tabApplied = false;
|
||||||
|
_pendingAction = null;
|
||||||
|
_pendingSource = null;
|
||||||
|
_droppingAction = false;
|
||||||
|
_refreshBeforeAction = false;
|
||||||
|
await LoadDetailAsync(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task LoadSourcesAsync()
|
if (id != Id || _detail is not { } detail || detail.Id != id)
|
||||||
{
|
|
||||||
await using var db = await DbFactory.CreateDbContextAsync();
|
|
||||||
_sources = await db.MeterSources.AsNoTracking().Where(s => s.MeterId == Id).OrderBy(s => s.Priority).ToListAsync();
|
|
||||||
_endpoints = await db.IngestionEndpoints.AsNoTracking().OrderBy(e => e.Name).ToListAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string SourceTarget(MeterSource s)
|
|
||||||
{
|
|
||||||
var config = SourceConfig.Parse(s.Config);
|
|
||||||
return s.SourceType == SourceType.HomeAssistant
|
|
||||||
? config.EntityId ?? "—"
|
|
||||||
: config.Topic ?? "—";
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OpenSource(MeterSource? source)
|
|
||||||
{
|
|
||||||
if (source is null)
|
|
||||||
{
|
|
||||||
_sourceEdit = new SourceEdit();
|
|
||||||
OnSourceTypeChanged(_sourceEdit.SourceType);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var config = SourceConfig.Parse(source.Config);
|
|
||||||
_sourceEdit = new SourceEdit
|
|
||||||
{
|
|
||||||
Id = source.Id,
|
|
||||||
SourceType = source.SourceType,
|
|
||||||
EndpointId = source.EndpointId,
|
|
||||||
ValueKind = source.ValueKind,
|
|
||||||
Scale = source.Scale,
|
|
||||||
Offset = source.Offset,
|
|
||||||
Priority = source.Priority,
|
|
||||||
IsEnabled = source.IsEnabled,
|
|
||||||
EntityId = config.EntityId,
|
|
||||||
Attribute = config.Attribute,
|
|
||||||
PollSeconds = config.PollSeconds,
|
|
||||||
Topic = config.Topic,
|
|
||||||
Path = config.Path,
|
|
||||||
TimePath = config.TimePath,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
_sourceOpen = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SaveSourceAsync()
|
|
||||||
{
|
|
||||||
// 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)
|
|
||||||
{
|
|
||||||
var selected = _endpoints.FirstOrDefault(e => e.Id == _sourceEdit.EndpointId);
|
|
||||||
if (selected is null)
|
|
||||||
{
|
|
||||||
Snackbar.Add($"Pick a {needed} connector for this {_sourceEdit.SourceType} source.", Severity.Error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selected.Type != needed)
|
|
||||||
{
|
|
||||||
Snackbar.Add($"'{selected.Name}' is a {selected.Type} connector; a {_sourceEdit.SourceType} source needs {needed}.", Severity.Error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_sourceEdit.EndpointId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var config = new SourceConfig
|
|
||||||
{
|
|
||||||
EntityId = Trim(_sourceEdit.EntityId),
|
|
||||||
Attribute = Trim(_sourceEdit.Attribute),
|
|
||||||
PollSeconds = _sourceEdit.PollSeconds,
|
|
||||||
Topic = Trim(_sourceEdit.Topic),
|
|
||||||
Path = Trim(_sourceEdit.Path),
|
|
||||||
TimePath = Trim(_sourceEdit.TimePath),
|
|
||||||
};
|
|
||||||
var configJson = System.Text.Json.JsonSerializer.Serialize(config,
|
|
||||||
new System.Text.Json.JsonSerializerOptions { DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull });
|
|
||||||
|
|
||||||
await using var db = await DbFactory.CreateDbContextAsync();
|
|
||||||
if (_sourceEdit.Id == 0)
|
|
||||||
{
|
|
||||||
db.MeterSources.Add(new MeterSource
|
|
||||||
{
|
|
||||||
MeterId = Id,
|
|
||||||
SourceType = _sourceEdit.SourceType,
|
|
||||||
EndpointId = _sourceEdit.EndpointId,
|
|
||||||
Config = configJson,
|
|
||||||
ValueKind = _sourceEdit.ValueKind,
|
|
||||||
Scale = _sourceEdit.Scale,
|
|
||||||
Offset = _sourceEdit.Offset,
|
|
||||||
Priority = _sourceEdit.Priority,
|
|
||||||
IsEnabled = _sourceEdit.IsEnabled,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var existing = await db.MeterSources.FirstAsync(s => s.Id == _sourceEdit.Id);
|
|
||||||
existing.SourceType = _sourceEdit.SourceType;
|
|
||||||
existing.EndpointId = _sourceEdit.EndpointId;
|
|
||||||
existing.Config = configJson;
|
|
||||||
existing.ValueKind = _sourceEdit.ValueKind;
|
|
||||||
existing.Scale = _sourceEdit.Scale;
|
|
||||||
existing.Offset = _sourceEdit.Offset;
|
|
||||||
existing.Priority = _sourceEdit.Priority;
|
|
||||||
existing.IsEnabled = _sourceEdit.IsEnabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
_sourceOpen = false;
|
|
||||||
Snackbar.Add("Source saved.", Severity.Success);
|
|
||||||
await LoadSourcesAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task DeleteSourceAsync(MeterSource source)
|
|
||||||
{
|
|
||||||
if (!await Confirm.DeleteAsync(DialogService, "Delete source", $"Delete this {source.SourceType} source?"))
|
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await using var db = await DbFactory.CreateDbContextAsync();
|
_query = MeterAnalysisLoader.ForMeter(AnalysisQuery.Parse(Nav.Uri, Defaults), id);
|
||||||
await db.MeterSources.Where(s => s.Id == source.Id).ExecuteDeleteAsync();
|
|
||||||
Snackbar.Add("Source deleted.", Severity.Success);
|
// A link's tab wins when it changes, or when the link also carries an action; otherwise the tab the user
|
||||||
await LoadSourcesAsync();
|
// clicked since is kept, including when the action is dropped from the address.
|
||||||
|
if (!_tabApplied || !string.Equals(Tab, _appliedTab, StringComparison.OrdinalIgnoreCase) || !string.IsNullOrEmpty(Action))
|
||||||
|
{
|
||||||
|
_activeTab = MeterLinks.ResolveTab(Tab, detail.Mode);
|
||||||
|
_tabApplied = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string? Trim(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
_appliedTab = Tab;
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(Action))
|
||||||
|
{
|
||||||
|
_pendingAction = Action;
|
||||||
|
_pendingSource = new SourcePreset(
|
||||||
|
SourceParam,
|
||||||
|
Enum.TryParse<SourceType>(SourceTypeParam, ignoreCase: true, out var sourceType) ? sourceType : null,
|
||||||
|
ConnectorParam);
|
||||||
|
|
||||||
|
// Arriving on a page already showing this meter: reload first, so the header is current when the dialog opens.
|
||||||
|
_refreshBeforeAction |= !fresh;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_droppingAction = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_activeTab == MeterLinks.TabAnalysis)
|
||||||
|
{
|
||||||
|
await EnsureAnalysisAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Which connector kind a source type needs, or null if it needs none (manual/import/virtual).
|
/// Opens a deep-linked dialog. After render, because only the interactive render can show one — a
|
||||||
/// Tasmota has no endpoint kind of its own — it is served by an MQTT broker connector.
|
/// prerendered page has no circuit to drive it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static EndpointType? RequiredEndpointType(SourceType sourceType) => sourceType switch
|
/// <remarks>
|
||||||
|
/// The action is dropped from the address <em>first</em> 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.
|
||||||
|
/// </remarks>
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
{
|
{
|
||||||
SourceType.HomeAssistant => EndpointType.HomeAssistant,
|
if (_pendingAction is not { } action || _detail is null)
|
||||||
SourceType.Mqtt or SourceType.Tasmota => EndpointType.MqttBroker,
|
{
|
||||||
_ => null,
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(Action))
|
||||||
|
{
|
||||||
|
if (!_droppingAction)
|
||||||
|
{
|
||||||
|
_droppingAction = true;
|
||||||
|
Nav.NavigateTo(Nav.GetUriWithQueryParameters(new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["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 ReloadDetailAsync();
|
||||||
|
if (_detail is null)
|
||||||
|
{
|
||||||
|
StateHasChanged();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (action.ToLowerInvariant())
|
||||||
|
{
|
||||||
|
case MeterLinks.ActionReading when TakesReadings:
|
||||||
|
await OpenReadingAsync();
|
||||||
|
break;
|
||||||
|
case MeterLinks.ActionEdit:
|
||||||
|
await OpenEditorAsync();
|
||||||
|
break;
|
||||||
|
case MeterLinks.ActionSource when !_detail.IsVirtual:
|
||||||
|
await _source!.OpenFromLinkAsync(sourcePreset?.SourceId, sourcePreset?.Type, sourcePreset?.ConnectorId);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
if (MeterLinks.EventFor(action) is { } type && MeterEventRules.CanRecord(_detail.Mode, type))
|
||||||
|
{
|
||||||
|
await OpenEventAsync(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads the meter's identity. Sequenced: fast meter-to-meter navigation cancels the previous load, and an answer
|
||||||
|
/// for a meter the page has left is dropped (brief §8).
|
||||||
|
/// </summary>
|
||||||
|
private async Task LoadDetailAsync(int id)
|
||||||
|
{
|
||||||
|
var ticket = _detailLoads.Next();
|
||||||
|
MeterDetailView? detail;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
detail = await Details.GetAsync(id, ticket.Token);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (ticket.Token.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
if (_detailLoads.IsCurrent(ticket) && id == Id)
|
||||||
|
{
|
||||||
|
Logger.LogError(ex, "Loading meter {MeterId} failed", id);
|
||||||
|
_detailFailed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_detailLoads.IsCurrent(ticket) || id != Id)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_detail = detail;
|
||||||
|
_notFound = detail is null;
|
||||||
|
_detailFailed = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RetryDetailAsync()
|
||||||
|
{
|
||||||
|
_detailFailed = false;
|
||||||
|
_loadedId = null;
|
||||||
|
await OnParametersSetAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reloads the identity in place (the tabs stay; a meter that vanished shows "not found").</summary>
|
||||||
|
private async Task ReloadDetailAsync()
|
||||||
|
{
|
||||||
|
await LoadDetailAsync(Id);
|
||||||
|
if (_detail is { } detail)
|
||||||
|
{
|
||||||
|
// A mode change (edited into a virtual meter, say) can take the open tab away.
|
||||||
|
_activeTab = MeterLinks.ResolveTab(_activeTab, detail.Mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Loads the analysis for the current query unless it was already requested for it (D-46).</summary>
|
||||||
|
private async Task EnsureAnalysisAsync()
|
||||||
|
{
|
||||||
|
if (_detail is not { } detail || _query == _analysisRequested)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var id = detail.Id;
|
||||||
|
var query = _query;
|
||||||
|
_analysisRequested = query;
|
||||||
|
var loader = new MeterAnalysisLoader(Periods, Reader, Costs, Details);
|
||||||
|
await _analysisLoads.RunAsync(_analysis, token => loader.LoadAsync(id, query, Clock.Now, token), Logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ReloadAnalysisAsync()
|
||||||
|
{
|
||||||
|
_analysisRequested = null;
|
||||||
|
await EnsureAnalysisAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>After any change to the meter's data: the identity, the open tab and — when shown — the analysis.</summary>
|
||||||
|
private async Task RefreshAsync()
|
||||||
|
{
|
||||||
|
_version++;
|
||||||
|
await ReloadDetailAsync();
|
||||||
|
_analysisRequested = null;
|
||||||
|
if (_activeTab == MeterLinks.TabAnalysis)
|
||||||
|
{
|
||||||
|
await EnsureAnalysisAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Stale for its next showing: the analysis tab reloads when it is opened again.
|
||||||
|
_analysis.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A tab click: remembered in the address (replace, D-46) — never a reload of the analysis or an action.</summary>
|
||||||
|
private async Task OnTabIndexChanged(int index)
|
||||||
|
{
|
||||||
|
if (_detail is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var tabs = MeterLinks.VisibleTabs(_detail.Mode);
|
||||||
|
if (index < 0 || index >= tabs.Count || tabs[index] == _activeTab)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var key = tabs[index];
|
||||||
|
_activeTab = key;
|
||||||
|
_appliedTab = key;
|
||||||
|
Nav.NavigateTo(Nav.GetUriWithQueryParameter("tab", key), replace: true);
|
||||||
|
if (key == MeterLinks.TabAnalysis)
|
||||||
|
{
|
||||||
|
await EnsureAnalysisAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A toolbar choice: written into the address, replacing the history entry (D-46).</summary>
|
||||||
|
private void ReplaceQuery(AnalysisQuery query) => AnalysisNavigation.Replace(Nav, query, Defaults);
|
||||||
|
|
||||||
|
private static string TabLabel(string key) => key switch
|
||||||
|
{
|
||||||
|
MeterLinks.TabAnalysis => S.MeterDetail_TabAnalysis,
|
||||||
|
MeterLinks.TabReadings => S.MeterDetail_TabReadings,
|
||||||
|
MeterLinks.TabNormalized => S.MeterDetail_TabNormalized,
|
||||||
|
MeterLinks.TabEvents => S.MeterDetail_TabEvents,
|
||||||
|
MeterLinks.TabTariffs => S.MeterDetail_TabTariffs,
|
||||||
|
MeterLinks.TabSources => S.MeterDetail_TabSources,
|
||||||
|
_ => S.MeterDetail_TabCalculation,
|
||||||
};
|
};
|
||||||
|
|
||||||
private List<IngestionEndpoint> ConnectorsFor(EndpointType type) =>
|
private async Task OpenReadingAsync()
|
||||||
_endpoints.Where(e => e.Type == type).ToList();
|
|
||||||
|
|
||||||
// Changing the source type can invalidate the chosen connector (an HA connector cannot serve an
|
|
||||||
// MQTT source), so drop a selection that no longer fits rather than saving a mismatched pair.
|
|
||||||
private void OnSourceTypeChanged(SourceType sourceType)
|
|
||||||
{
|
{
|
||||||
_sourceEdit.SourceType = sourceType;
|
if (TakesReadings && _reading is not null)
|
||||||
|
|
||||||
var needed = RequiredEndpointType(sourceType);
|
|
||||||
var selected = _endpoints.FirstOrDefault(e => e.Id == _sourceEdit.EndpointId);
|
|
||||||
if (needed is null || (selected is not null && selected.Type != needed))
|
|
||||||
{
|
{
|
||||||
_sourceEdit.EndpointId = null;
|
await _reading.OpenAsync();
|
||||||
}
|
|
||||||
|
|
||||||
// Sole candidate: preselect it, so the common single-broker / single-HA setup is one click.
|
|
||||||
if (needed is not null && _sourceEdit.EndpointId is null)
|
|
||||||
{
|
|
||||||
var candidates = _endpoints.Where(e => e.Type == needed).ToList();
|
|
||||||
if (candidates.Count == 1)
|
|
||||||
{
|
|
||||||
_sourceEdit.EndpointId = candidates[0].Id;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class SourceEdit
|
private async Task OpenEditorAsync()
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
if (_editor is not null)
|
||||||
public SourceType SourceType { get; set; } = SourceType.HomeAssistant;
|
{
|
||||||
public int? EndpointId { get; set; }
|
await _editor.OpenAsync(Id);
|
||||||
public SourceValueKind ValueKind { get; set; } = SourceValueKind.Register;
|
}
|
||||||
public double Scale { get; set; } = 1;
|
|
||||||
public double Offset { get; set; }
|
|
||||||
public int Priority { get; set; }
|
|
||||||
public bool IsEnabled { get; set; } = true;
|
|
||||||
public string? EntityId { get; set; }
|
|
||||||
public string? Attribute { get; set; }
|
|
||||||
public int? PollSeconds { get; set; } = 60;
|
|
||||||
public string? Topic { get; set; }
|
|
||||||
public string? Path { get; set; }
|
|
||||||
public string? TimePath { get; set; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static RenderFragment QualityChip(ReadingQuality quality) =>@<MudChip T="string" Size="Size.Small" Variant="Variant.Text"
|
private async Task OpenSourceAsync(MeterSource? source)
|
||||||
Color="@(quality == ReadingQuality.Measured ? Color.Success : quality == ReadingQuality.Estimated ? Color.Warning : Color.Default)">@quality</MudChip>;
|
{
|
||||||
|
if (_source is not null)
|
||||||
|
{
|
||||||
|
await _source.OpenAsync(source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OpenEventAsync(MeterEventType type, DateTimeOffset? at = null, bool keepResume = false)
|
||||||
|
{
|
||||||
|
if (_eventDialog is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opened on its own, the event dialog replaces a reading still waiting for a swap it was detoured to.
|
||||||
|
if (!keepResume)
|
||||||
|
{
|
||||||
|
_reading?.DropResume();
|
||||||
|
}
|
||||||
|
|
||||||
|
await _eventDialog.OpenAsync(type, at);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>From the reading dialog into the swap/reset dialog, at the time the reading was being entered.</summary>
|
||||||
|
private Task OnSwitchToEventAsync((MeterEventType Type, DateTimeOffset? At) request) =>
|
||||||
|
OpenEventAsync(request.Type, request.At, keepResume: true);
|
||||||
|
|
||||||
|
private async Task OnEventSavedAsync(MeterEventType type)
|
||||||
|
{
|
||||||
|
await RefreshAsync();
|
||||||
|
|
||||||
|
// Back to the reading that prompted the swap, with the typed digits still there.
|
||||||
|
if (_reading is { HasPendingResume: true } reading && MeterEventRules.IsRegisterBoundary(type) && _detail is not null)
|
||||||
|
{
|
||||||
|
await reading.ResumeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnEventCancelledAsync()
|
||||||
|
{
|
||||||
|
// Abandoning the swap returns to the reading as it was, rather than silently losing it.
|
||||||
|
if (_reading is { HasPendingResume: true } reading)
|
||||||
|
{
|
||||||
|
await reading.ResumeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnMeterSavedAsync((int MeterId, bool Created) saved) => await RefreshAsync();
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_detailLoads.Dispose();
|
||||||
|
_analysisLoads.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>What a link presets in the source dialog; see <see cref="MeterLinks.Source"/>.</summary>
|
||||||
|
private sealed record SourcePreset(int? SourceId, SourceType? Type, int? ConnectorId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
@* A record dated after now (D-04): the record tabs list the whole named range, so a row stamped later this month — a
|
||||||
|
current-month label, a device clock ahead — is shown, and marked in words, since it is not counted in any actual yet. *@
|
||||||
|
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined" Color="Color.Warning" Class="ml-1 mv-after-now"
|
||||||
|
title="@S.MeterDetail_AfterNowHint">@S.MeterDetail_AfterNow</MudChip>
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
@using Microsoft.Extensions.DependencyInjection
|
||||||
|
@using MeterVault.Infrastructure.Ingestion
|
||||||
|
@inject MeterDetailService Details
|
||||||
|
@inject IServiceScopeFactory Scopes
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
@inject InstanceClock Clock
|
||||||
|
@inject ILogger<ManualReadingDialog> Logger
|
||||||
|
|
||||||
|
@* Big touch targets and tabular digits for the manual-reading dialog: it is used standing at a
|
||||||
|
meter on a phone, where the default input sizes are fiddly. *@
|
||||||
|
<style>
|
||||||
|
.mv-reading-value input { font-size: 1.9rem; text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
|
/* nowrap pins it to exactly two lines, so a long unit or a big delta cannot spill over and
|
||||||
|
move the keypad; the full wording is repeated in the alert below the fold. */
|
||||||
|
.mv-reading-verdict { display: flex; flex-direction: column; min-height: 2.6rem; }
|
||||||
|
.mv-reading-verdict > * { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.mv-keypad { display: grid; grid-template-columns: repeat(3, 1fr); gap: .5rem; }
|
||||||
|
.mv-keypad .mud-button { height: 56px; font-size: 1.35rem; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<MudDialog Visible="_open" VisibleChanged="OnVisibleChanged" Options="_dialogOptions">
|
||||||
|
<TitleContent>
|
||||||
|
<MudText Typo="Typo.h6">@Loc.F(S.MeterDetail_AddReadingTitle, MeterName)</MudText>
|
||||||
|
</TitleContent>
|
||||||
|
<DialogContent>
|
||||||
|
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="d-block mb-2">@LastReadingCaption()</MudText>
|
||||||
|
|
||||||
|
<MudTextField T="string" Value="_entry.Text" ValueChanged="OnReadingTyped" Immediate="true"
|
||||||
|
Label="@Loc.F(S.MeterDetail_ReadingLabel, Unit)" Variant="Variant.Outlined"
|
||||||
|
InputMode="DecimalKeyboard" Class="mv-reading-value" Clearable="true" />
|
||||||
|
|
||||||
|
@* Fixed height, and above the keypad on purpose. The verdict on a value has to be visible
|
||||||
|
while it is being typed — the keypad pushes anything below it off a phone screen — but
|
||||||
|
anything that grows or shrinks here would move the keys out from under the user's
|
||||||
|
thumb mid-entry. So the slot is always the same size whether or not it says anything. *@
|
||||||
|
<div class="mv-reading-verdict mt-1 mb-3">
|
||||||
|
<MudText Typo="Typo.caption" Color="@(_entry.Value is null ? Color.Error : Color.Secondary)">
|
||||||
|
@(_entry.Value is { } parsed ? $"= {Format.Number(parsed, 3)} {Unit}" : S.MeterDetail_EnterValue)
|
||||||
|
</MudText>
|
||||||
|
@if (Verdict.WouldBeRejected)
|
||||||
|
{
|
||||||
|
@* 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. *@
|
||||||
|
<MudText Typo="Typo.caption" Color="Color.Warning">@S.MeterDetail_SwappedOrResetHint</MudText>
|
||||||
|
}
|
||||||
|
else if (Verdict.ChangeSincePrevious is { } change)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.caption" Color="Color.Secondary">@ChangeSinceText(change)</MudText>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mv-keypad mb-3">
|
||||||
|
@foreach (var key in Keypad)
|
||||||
|
{
|
||||||
|
var pressed = key;
|
||||||
|
<MudButton Variant="Variant.Outlined" OnClick="@(() => PressKey(pressed))">@pressed</MudButton>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex align-center flex-wrap" style="gap:.75rem">
|
||||||
|
<MudDatePicker Date="_when.Date" DateChanged="OnDateChangedAsync" Label="@S.Common_Date" Variant="Variant.Outlined"
|
||||||
|
Class="flex-grow-1" Style="min-width:150px" />
|
||||||
|
<MudTimePicker Time="_when.TimeOfDay" TimeChanged="OnTimeChangedAsync" Label="@S.MeterDetail_TimeOfDay" Variant="Variant.Outlined"
|
||||||
|
Class="flex-grow-1" Style="min-width:130px" />
|
||||||
|
<MudButton Size="Size.Small" Variant="Variant.Text"
|
||||||
|
StartIcon="@Icons.Material.Filled.Schedule" OnClick="SetNowAsync">@S.Common_Now</MudButton>
|
||||||
|
</div>
|
||||||
|
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="d-block mt-1">@Loc.F(S.MeterDetail_LocalTimeIn, Zone.Id)</MudText>
|
||||||
|
|
||||||
|
@* Everything below here can reflow freely: the dialog's buttons sit outside this scroll
|
||||||
|
area, so nothing the user is aiming at moves. *@
|
||||||
|
@if (_when.IsSkipped)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Error" Dense="true" Class="mt-3">
|
||||||
|
@Loc.F(S.MeterDetail_SkippedTime, Zone.Id)
|
||||||
|
</MudAlert>
|
||||||
|
}
|
||||||
|
@if (Verdict is { WouldBeRejected: true, Previous: { } previous })
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Warning" Dense="true" Class="mt-3">
|
||||||
|
@Loc.F(S.MeterDetail_DecreaseWarning, Format.Number(previous.Value, 2), Unit)
|
||||||
|
<div class="d-flex flex-wrap mt-2" style="gap:.5rem">
|
||||||
|
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Warning" StartIcon="@Icons.Material.Filled.SwapHoriz"
|
||||||
|
OnClick="@(() => SwitchToEventAsync(MeterEventType.MeterSwap))">@S.MeterDetail_RecordSwap</MudButton>
|
||||||
|
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Warning" StartIcon="@Icons.Material.Filled.RestartAlt"
|
||||||
|
OnClick="@(() => SwitchToEventAsync(MeterEventType.CounterReset))">@S.MeterDetail_RecordReset</MudButton>
|
||||||
|
</div>
|
||||||
|
</MudAlert>
|
||||||
|
}
|
||||||
|
@if (Verdict.ReplacesRegisterStart)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3">@S.MeterDetail_ReplaceSwapStartNotice</MudAlert>
|
||||||
|
}
|
||||||
|
else if (Verdict.ReplacesReading)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3">
|
||||||
|
@S.MeterDetail_ReplaceNotice
|
||||||
|
</MudAlert>
|
||||||
|
}
|
||||||
|
@if (Verdict.IsFuture)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3">@S.MeterDetail_FutureTime</MudAlert>
|
||||||
|
}
|
||||||
|
else if (Verdict.IsBackdated)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3">
|
||||||
|
@S.MeterDetail_BackdatedNotice
|
||||||
|
</MudAlert>
|
||||||
|
}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<MudButton OnClick="Close" Disabled="_saving">@S.Common_Cancel</MudButton>
|
||||||
|
<MudButton Color="Color.Primary" Variant="Variant.Filled" Size="Size.Large"
|
||||||
|
OnClick="SaveAsync" Disabled="@(!CanSave)">
|
||||||
|
@(_saving ? S.MeterDetail_Saving : S.MeterDetail_SaveReading)
|
||||||
|
</MudButton>
|
||||||
|
</DialogActions>
|
||||||
|
</MudDialog>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>The meter the reading is for.</summary>
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public int MeterId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Its name, for the title (user data).</summary>
|
||||||
|
[Parameter]
|
||||||
|
public string MeterName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>The raw unit of the register (readings are raw values).</summary>
|
||||||
|
[Parameter]
|
||||||
|
public string Unit { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>The instance zone the date and time are typed in.</summary>
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public TimeZoneInfo Zone { get; set; } = TimeZoneInfo.Utc;
|
||||||
|
|
||||||
|
/// <summary>Raised after a reading was stored (the meter is renormalized already).</summary>
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback Saved { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raised when the user leaves for a swap or reset at the entered time; the typed value is kept for
|
||||||
|
/// <see cref="ResumeAsync"/>.
|
||||||
|
/// </summary>
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback<(MeterEventType Type, DateTimeOffset? At)> SwitchToEvent { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Phone-dialpad order, ending in the row the thumb reaches last: separator, zero, backspace.</summary>
|
||||||
|
private static readonly string[] Keypad = ["7", "8", "9", "4", "5", "6", "1", "2", "3", ",", "0", "⌫"];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A decimal keyboard, so a phone offers the separator. Hoisted out of the markup because
|
||||||
|
/// <c>decimal</c> is a C# keyword and Razor would read the required <c>@</c> escape in an
|
||||||
|
/// attribute as a transition.
|
||||||
|
/// </summary>
|
||||||
|
private const InputMode DecimalKeyboard = InputMode.@decimal;
|
||||||
|
|
||||||
|
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
|
||||||
|
private readonly ReadingEntry _entry = new();
|
||||||
|
private LocalTimeEntry _when = new(TimeZoneInfo.Utc);
|
||||||
|
private bool _open;
|
||||||
|
private bool _saving;
|
||||||
|
|
||||||
|
/// <summary>The context for the instant the pickers show (D-50); versioned, as the pickers move faster than queries return.</summary>
|
||||||
|
private ReadingEntryContext? _context;
|
||||||
|
private int _contextVersion;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A reading typed before the user detoured into recording a swap. It is handed back to this dialog once the swap
|
||||||
|
/// is saved or abandoned, so the detour costs no retyping.
|
||||||
|
/// </summary>
|
||||||
|
private (string Text, DateTimeOffset? At)? _resume;
|
||||||
|
|
||||||
|
/// <summary>True while a typed reading waits for the swap or reset the user left to record.</summary>
|
||||||
|
public bool HasPendingResume => _resume is not null;
|
||||||
|
|
||||||
|
private ReadingEntryVerdict Verdict => ReadingEntryVerdict.Of(_context, _entry.Value, _when.Utc, Clock.Now);
|
||||||
|
|
||||||
|
private bool CanSave => !_saving && _entry.Value is not null && _when.WallClock is not null && !_when.IsSkipped;
|
||||||
|
|
||||||
|
protected override void OnParametersSet()
|
||||||
|
{
|
||||||
|
if (!ReferenceEquals(_when.Zone, Zone))
|
||||||
|
{
|
||||||
|
_when = new LocalTimeEntry(Zone);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens the dialog at now, prefilled with the meter's latest reading (or its baseline while it has none) — a register
|
||||||
|
/// only moves in its final digits, so backspace-and-retype beats keying six digits from scratch.
|
||||||
|
/// </summary>
|
||||||
|
public async Task OpenAsync()
|
||||||
|
{
|
||||||
|
_resume = null;
|
||||||
|
_entry.Clear();
|
||||||
|
_when.Set(Clock.Now);
|
||||||
|
await LoadContextAsync();
|
||||||
|
_entry.Prefill(_context?.Latest?.Value ?? _context?.InitialBaseline ?? 0);
|
||||||
|
_open = true;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reopens the dialog with the reading typed before the swap/reset detour, at its time.</summary>
|
||||||
|
public async Task ResumeAsync()
|
||||||
|
{
|
||||||
|
if (_resume is not { } resume)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_resume = null;
|
||||||
|
_entry.SetText(resume.Text);
|
||||||
|
if (resume.At is { } at)
|
||||||
|
{
|
||||||
|
_when.Set(at);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_when.Set(Clock.Now);
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadContextAsync();
|
||||||
|
_open = true;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Forgets a typed reading waiting for a detour (the user went on to something else).</summary>
|
||||||
|
public void DropResume() => _resume = null;
|
||||||
|
|
||||||
|
/// <summary>Closes the dialog without saving.</summary>
|
||||||
|
public void Close()
|
||||||
|
{
|
||||||
|
_open = false;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnVisibleChanged(bool visible)
|
||||||
|
{
|
||||||
|
if (!visible)
|
||||||
|
{
|
||||||
|
_open = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnReadingTyped(string? value) => _entry.SetText(value);
|
||||||
|
|
||||||
|
private void PressKey(string key)
|
||||||
|
{
|
||||||
|
switch (key)
|
||||||
|
{
|
||||||
|
case "⌫":
|
||||||
|
_entry.Backspace();
|
||||||
|
break;
|
||||||
|
case ",":
|
||||||
|
_entry.AppendSeparator();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
_entry.AppendDigit(key[0]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.Set(Clock.Now);
|
||||||
|
await LoadContextAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads what the entry at the chosen instant is judged against (D-50). Versioned: a late answer for an earlier time
|
||||||
|
/// must not overwrite a newer one. A failure leaves no verdict, and the save still goes through the guard.
|
||||||
|
/// </summary>
|
||||||
|
private async Task LoadContextAsync()
|
||||||
|
{
|
||||||
|
if (_when.Utc is not { } utc)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var version = ++_contextVersion;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var context = await Details.GetReadingEntryContextAsync(MeterId, utc);
|
||||||
|
if (version == _contextVersion)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
Logger.LogWarning(ex, "Reading the entry context of meter {MeterId} failed", MeterId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string LastReadingCaption() => _context switch
|
||||||
|
{
|
||||||
|
{ Latest: { } latest } => Loc.F(S.MeterDetail_LastReadingCaption,
|
||||||
|
Format.Number(latest.Value, 2), Unit, _when.Local(latest.Time).ToString("yyyy-MM-dd HH:mm", System.Globalization.CultureInfo.InvariantCulture)),
|
||||||
|
{ } context => Loc.F(S.MeterDetail_NoReadingsPrefill, Format.Number(context.InitialBaseline, 2), Unit),
|
||||||
|
_ => string.Empty,
|
||||||
|
};
|
||||||
|
|
||||||
|
private string ChangeSinceText(double change) =>
|
||||||
|
Math.Abs(change) < 1e-9
|
||||||
|
? S.MeterDetail_NoChangeSinceLast
|
||||||
|
: Loc.F(S.MeterDetail_ChangeSinceLast, $"{(change > 0 ? "+" : "−")}{Format.Number(Math.Abs(change), 2)}", Unit);
|
||||||
|
|
||||||
|
private async Task SaveAsync()
|
||||||
|
{
|
||||||
|
if (_entry.Value is not { } value || _when.Utc is not { } utc)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_saving = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// A scope per operation: IngestionService holds a scoped DbContext, and a Blazor circuit
|
||||||
|
// long outlives the unit of work a single save should share one with.
|
||||||
|
await using var scope = Scopes.CreateAsyncScope();
|
||||||
|
var ingestion = scope.ServiceProvider.GetRequiredService<IngestionService>();
|
||||||
|
IngestionOutcome outcome;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
outcome = await ingestion.IngestByMeterAsync(MeterId, utc, value, renormalize: true, quality: ReadingQuality.Manual);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
Logger.LogError(ex, "Saving a manual reading on meter {MeterId} failed", MeterId);
|
||||||
|
Snackbar.Add(S.MeterDetail_ReadingFailed, Severity.Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (outcome)
|
||||||
|
{
|
||||||
|
case IngestionOutcome.Written:
|
||||||
|
Snackbar.Add(Loc.F(S.MeterDetail_ReadingSaved, Format.Number(value, 2), Unit), Severity.Success);
|
||||||
|
break;
|
||||||
|
case IngestionOutcome.Updated:
|
||||||
|
Snackbar.Add(Loc.F(S.MeterDetail_ReadingReplaced, Format.Number(value, 2), Unit), Severity.Success);
|
||||||
|
break;
|
||||||
|
case IngestionOutcome.RejectedDecrease:
|
||||||
|
// 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));
|
||||||
|
});
|
||||||
|
await LoadContextAsync();
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
Snackbar.Add(S.MeterDetail_MeterGone, Severity.Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_open = false;
|
||||||
|
_resume = null;
|
||||||
|
await Saved.InvokeAsync();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// From this 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 (<see cref="ResumeAsync"/>).
|
||||||
|
/// </summary>
|
||||||
|
private async Task SwitchToEventAsync(MeterEventType type)
|
||||||
|
{
|
||||||
|
_resume = (_entry.Text, _when.Utc);
|
||||||
|
_open = false;
|
||||||
|
await SwitchToEvent.InvokeAsync((type, _resume.Value.At));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
@inject NavigationManager Nav
|
||||||
|
|
||||||
|
@* The meter's Analysis tab (brief §7.2): the shared period toolbar with CSV export, the period's quantity (normalized
|
||||||
|
unit, D-20) and cost (with its rule named, or why there is none), the projection kept apart from the actual (D-09),
|
||||||
|
the full-size chart with the comparison overlay, the accessible table, a click on a bucket drilling down (D-51), and
|
||||||
|
what qualifies the figures: coverage, resolution, opening balance, rows after now, freshness, and the events and
|
||||||
|
tariff changes inside the range. A virtual meter gets the same from its formula, with its sources' contributions. *@
|
||||||
|
|
||||||
|
<PeriodToolbar Query="Query" Period="Current?.Period" Plan="Current?.Quantities.Plan" Defaults="Defaults"
|
||||||
|
QueryChanged="OnQueryChanged" ExportHref="@ExportHref"
|
||||||
|
Metrics="Current?.Metrics" NaturalMetric="Current?.QuantityMetric" Class="mb-3" />
|
||||||
|
|
||||||
|
<LoadPanel State="State" OnRetry="OnRetry" Context="r">
|
||||||
|
@if (r.MeterId == Detail.Id)
|
||||||
|
{
|
||||||
|
var s = r.Series;
|
||||||
|
@if (s is null)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Warning" Dense="true">@Loc.F(S.MeterDetail_NotFound, Detail.Id)</MudAlert>
|
||||||
|
}
|
||||||
|
else if (r.Quantities.Refusal != AnalysisRefusal.None)
|
||||||
|
{
|
||||||
|
@* The toolbar above says why (too many points) and offers the coarser bucket. *@
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<AttentionList Problems="ProblemsOf(r)" CostAttention="r.Cost?.Attention" Names="r.AttentionNames"
|
||||||
|
Query="Query" MaxItems="4" Class="mb-3" />
|
||||||
|
|
||||||
|
@if (s.IsPending)
|
||||||
|
{
|
||||||
|
<PendingState OnRefresh="OnRetry" />
|
||||||
|
}
|
||||||
|
else if (s.Total.Status == BucketStatus.Invalid)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Error" Class="mb-3">
|
||||||
|
@Loc.F(S.MeterDetail_CalculationNotEvaluable, (s.Virtual?.Status ?? VirtualMeterStatus.Invalid).Display())
|
||||||
|
<div class="d-flex flex-wrap mt-2" style="gap:.5rem">
|
||||||
|
<MudButton Size="Size.Small" Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Functions"
|
||||||
|
OnClick="OnEdit">@S.MeterDetail_EditCalculation</MudButton>
|
||||||
|
<MudButton Size="Size.Small" Variant="Variant.Text"
|
||||||
|
Href="@MeterLinks.Detail(Detail.Id, MeterLinks.TabCalculation, null, Query)">@S.MeterDetail_ShowCalculation</MudButton>
|
||||||
|
</div>
|
||||||
|
</MudAlert>
|
||||||
|
}
|
||||||
|
else if (r.Quantities.NotYetOccurred || s.Total.Status == BucketStatus.Missing)
|
||||||
|
{
|
||||||
|
<EmptyPeriodState NotYetOccurred="r.Quantities.NotYetOccurred" Availability="s.Availability" LatestHref="@LatestHref(s)" Class="mb-3">
|
||||||
|
@if (s.Availability is null)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2" Class="mv-muted">@NextStepText</MudText>
|
||||||
|
<div class="d-flex flex-wrap mt-2" style="gap:.5rem">
|
||||||
|
@if (MeterEventRules.TakesReadings(Detail.Mode))
|
||||||
|
{
|
||||||
|
<MudButton Size="Size.Small" Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.EditNote"
|
||||||
|
OnClick="OnAddReading">@S.MeterDetail_AddFirstReading</MudButton>
|
||||||
|
<MudButton Size="Size.Small" Variant="Variant.Text" StartIcon="@Icons.Material.Filled.SettingsInputComponent"
|
||||||
|
Href="@MeterLinks.Source(Detail.Id)">@S.MeterDetail_ConnectSource</MudButton>
|
||||||
|
}
|
||||||
|
else if (Detail.Mode == MeterMode.ConsumableBalance)
|
||||||
|
{
|
||||||
|
<MudButton Size="Size.Small" Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Straighten"
|
||||||
|
OnClick="@(() => OnRecordEvent.InvokeAsync(MeterEventType.TankLevel))">@S.MeterDetail_RecordTankLevel</MudButton>
|
||||||
|
}
|
||||||
|
else if (Detail.IsVirtual)
|
||||||
|
{
|
||||||
|
<MudButton Size="Size.Small" Variant="Variant.Text" StartIcon="@Icons.Material.Filled.Functions"
|
||||||
|
Href="@MeterLinks.Detail(Detail.Id, MeterLinks.TabCalculation, null, Query)">@S.MeterDetail_ShowCalculation</MudButton>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</EmptyPeriodState>
|
||||||
|
@if (Detail.IsVirtual)
|
||||||
|
{
|
||||||
|
<SeriesContributions Series="s" Query="Query" Class="mt-3" />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudGrid Spacing="2" Class="mb-1">
|
||||||
|
<MudItem xs="12" sm="6" md="4">
|
||||||
|
<MetricCard Title="@s.Kind.Display()" Value="s.Total" Unit="@s.Unit" Caption="@Format.PeriodRange(r.Period)"
|
||||||
|
Change="s.Comparison?.Change" Polarity="ChangePolarities.For(s.Kind)"
|
||||||
|
ChangeCaption="@ComparisonCaption">
|
||||||
|
@if (r.Projection is { } projection)
|
||||||
|
{
|
||||||
|
<ProjectionNote Days="projection.Days" ValueText="@Format.Quantity(projection.Value, projection.Unit)" Class="mt-2" />
|
||||||
|
}
|
||||||
|
</MetricCard>
|
||||||
|
</MudItem>
|
||||||
|
@if (r.Cost is { } cost)
|
||||||
|
{
|
||||||
|
<MudItem xs="12" sm="6" md="4">
|
||||||
|
@if (r.IsCosted)
|
||||||
|
{
|
||||||
|
<MetricCard Title="@S.MeterDetail_CostTitle" Cost="cost.Total" Currency="@cost.Currency"
|
||||||
|
Caption="@CostRuleText(cost)"
|
||||||
|
Change="CostChanges.ForCard(r.CostChange)" Polarity="CostChanges.Polarity(r.CostChange)"
|
||||||
|
ChangeCaption="@CostChanges.Caption(Query, r.CostChange)" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudPaper Outlined="true" Elevation="0" Class="pa-4 mv-metric">
|
||||||
|
<MudText Typo="Typo.overline" Class="mv-muted mv-metric__title">@S.MeterDetail_CostTitle</MudText>
|
||||||
|
<div class="mv-metric__value-row">
|
||||||
|
<span class="mv-metric__value mv-metric__value--words">@MeterCostRule.None.Display()</span>
|
||||||
|
</div>
|
||||||
|
<MudText Typo="Typo.caption" Class="mv-muted">@((cost.Meter?.NotCosted ?? MeterNotCostedReason.None).Display())</MudText>
|
||||||
|
</MudPaper>
|
||||||
|
}
|
||||||
|
</MudItem>
|
||||||
|
}
|
||||||
|
</MudGrid>
|
||||||
|
|
||||||
|
<ComparisonSummary Period="r.Period" Resolution="r.Quantities.Comparison?.Resolution" Matched="s.Comparison?.Matched" Class="mb-2" />
|
||||||
|
|
||||||
|
@* A click leads somewhere or is not offered (D-51): the chart is clickable, the table has its drill column and
|
||||||
|
the hint shows only when some bucket opens something. *@
|
||||||
|
var drills = r.Quantities.Plan.Buckets.Any(b => DrillHref(b, r, s) is not null);
|
||||||
|
<MudPaper Outlined="true" Elevation="0" Class="pa-3 mb-3">
|
||||||
|
@if (drills)
|
||||||
|
{
|
||||||
|
<AnalysisChart Buckets="r.Quantities.Plan.Buckets" Series="r.Chart" ComparisonPairs="r.Quantities.Comparison?.Buckets"
|
||||||
|
Title="@ChartTitle(r, s)" Height="340" OnBucketClick="b => Drill(b, r, s)"
|
||||||
|
Resolution="s.Resolution" OnUseBucket="UseBucketAsync" />
|
||||||
|
<MudText Typo="Typo.caption" Class="mv-muted">@DrillHint(s)</MudText>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<AnalysisChart Buckets="r.Quantities.Plan.Buckets" Series="r.Chart" ComparisonPairs="r.Quantities.Comparison?.Buckets"
|
||||||
|
Title="@ChartTitle(r, s)" Height="340" Resolution="s.Resolution" OnUseBucket="UseBucketAsync" />
|
||||||
|
}
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
<AnalysisTable Buckets="r.Quantities.Plan.Buckets" Series="r.Table" ComparisonPairs="r.Quantities.Comparison?.Buckets"
|
||||||
|
DrillHref="@(drills ? b => DrillHref(b, r, s) : null)" Caption="@ChartTitle(r, s)" />
|
||||||
|
|
||||||
|
@if (Detail.IsVirtual)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.h6" Class="mt-5 mb-1">@S.MeterDetail_SourcesOfCalculation</MudText>
|
||||||
|
<SeriesContributions Series="s" Query="Query" />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<MeterCoverageNote View="r" Detail="Detail" Query="Query" OnEdit="OnEdit" Class="mt-5" />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</LoadPanel>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>The meter.</summary>
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public MeterDetailView Detail { get; set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>The page's analysis state.</summary>
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public AnalysisQuery Query { get; set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>The page's analysis load (owned by the page, so switching tabs never reloads it, D-46).</summary>
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public LoadState<MeterAnalysisView> State { get; set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>A toolbar choice: the page writes it into its address (replace).</summary>
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback<AnalysisQuery> OnQueryChanged { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback OnRetry { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback OnAddReading { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback<MeterEventType> OnRecordEvent { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Opens the shared meter editor (install date, calculation).</summary>
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback OnEdit { get; set; }
|
||||||
|
|
||||||
|
private AnalysisDefaults Defaults => MeterAnalysisLoader.DefaultsFor(Detail.Id);
|
||||||
|
|
||||||
|
/// <summary>The committed value when it is this meter's.</summary>
|
||||||
|
private MeterAnalysisView? Current => State.Value is { } value && value.MeterId == Detail.Id ? value : null;
|
||||||
|
|
||||||
|
private string ExportHref => AnalysisLinks.Export(MeterAnalysisLoader.ForMeter(Query, Detail.Id));
|
||||||
|
|
||||||
|
private string? ComparisonCaption => Query.Comparison.Kind == ComparisonKind.None ? null : Query.Comparison.Display();
|
||||||
|
|
||||||
|
private string NextStepText => Detail.Mode switch
|
||||||
|
{
|
||||||
|
MeterMode.ConsumableBalance => S.MeterDetail_NextStepTank,
|
||||||
|
MeterMode.Virtual => S.MeterDetail_NextStepVirtual,
|
||||||
|
_ => S.MeterDetail_NextStepCounter,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The readers' problems for the attention list. Rows recorded after now are explained with their dates and amount in
|
||||||
|
/// the coverage section below, so they are not listed twice.
|
||||||
|
/// </summary>
|
||||||
|
private static IEnumerable<AnalysisProblem> ProblemsOf(MeterAnalysisView view) =>
|
||||||
|
view.Quantities.Problems.Concat(view.Cost?.QuantityProblems ?? [])
|
||||||
|
.Where(p => p.Kind != AnalysisProblemKind.RecordedAfterNow);
|
||||||
|
|
||||||
|
private string CostRuleText(Infrastructure.Costing.CostAnalysis cost) =>
|
||||||
|
Loc.F(S.MeterDetail_CostRule, (cost.Meter?.Rule ?? MeterCostRule.None).Display());
|
||||||
|
|
||||||
|
private static string ChartTitle(MeterAnalysisView view, AnalysisSeries series) =>
|
||||||
|
view.ShowsCost
|
||||||
|
? Loc.F(S.MeterDetail_CostOf, AnalysisChartSeries.NameOf(series))
|
||||||
|
: Loc.F(S.MeterDetail_ChartTitle, series.Kind.Display(), AnalysisChartSeries.NameOf(series));
|
||||||
|
|
||||||
|
private string DrillHint(AnalysisSeries series) =>
|
||||||
|
Detail.IsVirtual ? S.MeterDetail_DrillHintVirtual : S.MeterDetail_DrillHint;
|
||||||
|
|
||||||
|
private string? LatestHref(AnalysisSeries series) =>
|
||||||
|
AnalysisNavigation.LatestData(Query, series.Availability) is { } latest ? MeterLinks.Analysis(Detail.Id, latest) : null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Where a bucket leads (D-51, <see cref="MeterDrill"/>): the next finer size the data resolves; otherwise a physical
|
||||||
|
/// meter's records of the bucket, or a virtual meter's own analysis over just that bucket, whose source contributions
|
||||||
|
/// link on to each source's records — never a dead end.
|
||||||
|
/// </summary>
|
||||||
|
private string? DrillHref(AnalysisBucket bucket, MeterAnalysisView view, AnalysisSeries series) =>
|
||||||
|
MeterDrill.Href(Detail.Id, Detail.IsVirtual, Query, view.Period, bucket, series.Resolution);
|
||||||
|
|
||||||
|
/// <summary>A chart click drills down, pushing a history entry so Back returns to the range it came from (D-46).</summary>
|
||||||
|
private void Drill(AnalysisBucket bucket, MeterAnalysisView view, AnalysisSeries series)
|
||||||
|
{
|
||||||
|
if (DrillHref(bucket, view, series) is { } href)
|
||||||
|
{
|
||||||
|
Nav.NavigateTo(href);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The buckets are finer than the data: apply the interval that shows it (the page replaces its address).</summary>
|
||||||
|
private Task UseBucketAsync(BucketSize size) => OnQueryChanged.InvokeAsync(Query.WithBucket(size));
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
@implements IDisposable
|
||||||
|
@inject MeterDetailService Details
|
||||||
|
@inject ILogger<MeterCalculationTab> Logger
|
||||||
|
|
||||||
|
@* A virtual meter's calculation (brief §5.1, D-25 – D-31): its status (valid, legacy — confirm, needs configuration,
|
||||||
|
invalid), the formula with each m<id> token beside the meter's name, what it yields and in which unit, its cost rule,
|
||||||
|
the meters it reads (linked to their own analysis), and every validation problem with the meters involved — plus the
|
||||||
|
one action that fixes them: Edit calculation, in the shared meter editor. It replaces Sources (a calculation has no
|
||||||
|
ingest), and register details never show here. *@
|
||||||
|
|
||||||
|
<LoadPanel State="_state" OnRetry="LoadAsync" Context="calc" PlaceholderHeight="160">
|
||||||
|
@if (calc.MeterId == Detail.Id)
|
||||||
|
{
|
||||||
|
<div class="d-flex align-center flex-wrap mb-3" style="gap:.5rem 1rem">
|
||||||
|
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined"
|
||||||
|
Color="@(calc.Status == VirtualMeterStatus.Valid ? Color.Success : calc.Status is VirtualMeterStatus.Legacy ? Color.Info : Color.Error)"
|
||||||
|
Icon="@(calc.Status == VirtualMeterStatus.Valid ? Icons.Material.Outlined.CheckCircle : Icons.Material.Outlined.Info)">
|
||||||
|
@calc.Status.Display()
|
||||||
|
</MudChip>
|
||||||
|
<MudText Typo="Typo.body2" Class="flex-grow-1" Style="min-width:12rem">@StatusText(calc.Status)</MudText>
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Functions" OnClick="OnEdit">
|
||||||
|
@S.MeterDetail_EditCalculation
|
||||||
|
</MudButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MudPaper Outlined="true" Elevation="0" Class="pa-3 mb-4">
|
||||||
|
<MudText Typo="Typo.subtitle2" Class="mb-1">@S.Contributions_Formula</MudText>
|
||||||
|
@if (FormulaText.Split(calc.Expression) is { Count: > 0 } segments)
|
||||||
|
{
|
||||||
|
<div class="mv-formula mb-3" style="flex-wrap:wrap">
|
||||||
|
@foreach (var segment in segments)
|
||||||
|
{
|
||||||
|
@if (segment.MeterId is { } id)
|
||||||
|
{
|
||||||
|
<span class="mv-formula__ref">
|
||||||
|
@if (calc.NameOf(id) is { } name)
|
||||||
|
{
|
||||||
|
<MudLink Href="@MeterLinks.Analysis(id, Query)" Typo="Typo.body2">@name</MudLink>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="mv-unknown">@S.MeterDetail_UnknownMeter</span>
|
||||||
|
}
|
||||||
|
<code>@segment.Text</code>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<code>@segment.Text</code>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2" Class="mv-muted mb-3">@S.MeterDetail_NoFormula</MudText>
|
||||||
|
}
|
||||||
|
|
||||||
|
<dl class="mv-calc-facts">
|
||||||
|
<dt>@S.MeterDetail_Result</dt>
|
||||||
|
<dd>@Loc.F(S.MeterDetail_QuantityIn, calc.Kind.Display(), calc.Unit)</dd>
|
||||||
|
<dt>@S.MeterDetail_CostRuleLabel</dt>
|
||||||
|
<dd>
|
||||||
|
@calc.CostRule.Display()
|
||||||
|
@if (calc.CostRuleProblem is not null && calc.DeclaredCostRule is { } declared && declared != calc.CostRule)
|
||||||
|
{
|
||||||
|
<div class="mv-cell-secondary">@Loc.F(S.MeterDetail_CostRuleIgnored, declared.Display())</div>
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
@if (calc.Problems.Count > 0 || calc.CostRuleProblem is not null || calc.Legacy is { NeedsConfiguration: true })
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-1">@S.MeterDetail_CalcProblems</MudText>
|
||||||
|
<ul class="mv-calc-problems mb-4">
|
||||||
|
@if (calc.Legacy is { NeedsConfiguration: true } legacy)
|
||||||
|
{
|
||||||
|
<li>
|
||||||
|
<MudIcon Icon="@Icons.Material.Outlined.ErrorOutline" Size="Size.Small" aria-hidden="true" />
|
||||||
|
<span>@LegacyText(legacy)</span>
|
||||||
|
<MeterPath Ids="legacy.MeterIds" Calculation="calc" Query="Query" />
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
@foreach (var problem in calc.Problems.Concat(calc.CostRuleProblem is { } p ? [p] : []))
|
||||||
|
{
|
||||||
|
<li>
|
||||||
|
<MudIcon Icon="@Icons.Material.Outlined.ErrorOutline" Size="Size.Small" aria-hidden="true" />
|
||||||
|
<span>@ProblemText(problem)</span>
|
||||||
|
<MeterPath Ids="problem.MeterIds" Calculation="calc" Query="Query"
|
||||||
|
Separator="@(problem.Kind == VirtualProblemKind.DependencyCycle ? " → " : ", ")" />
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-1">@S.MeterDetail_ReferencedMeters</MudText>
|
||||||
|
@if (calc.Sources.Count == 0)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2" Class="mv-muted">@S.MeterDetail_NoReferencedMeters</MudText>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="mv-table-scroll" role="region" aria-label="@S.MeterDetail_ReferencedMeters" tabindex="0">
|
||||||
|
<MudSimpleTable Dense="true" Hover="true">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">@S.Common_Name</th>
|
||||||
|
<th scope="col">@S.Common_Mode</th>
|
||||||
|
<th scope="col">@S.MeterDetail_Kind</th>
|
||||||
|
<th scope="col">@S.Common_Unit</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var source in calc.Sources)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<th scope="row" class="mv-row-label">
|
||||||
|
@if (source.Exists)
|
||||||
|
{
|
||||||
|
<MudLink Href="@MeterLinks.Analysis(source.MeterId, Query)" Typo="Typo.body2">@source.Name</MudLink>
|
||||||
|
<code class="ml-1 mv-muted">@($"m{source.MeterId}")</code>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="mv-unknown">@S.MeterDetail_UnknownMeter</span>
|
||||||
|
<code class="ml-1 mv-muted">@($"m{source.MeterId}")</code>
|
||||||
|
}
|
||||||
|
</th>
|
||||||
|
<td>@(source.Exists ? source.Mode.Display() : Format.Unknown)</td>
|
||||||
|
<td>@(source.Exists ? source.Kind.Display() : Format.Unknown)</td>
|
||||||
|
<td>@(source.Exists ? source.Unit : Format.Unknown)</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</MudSimpleTable>
|
||||||
|
</div>
|
||||||
|
@if (calc.Sources.Any(s => s.IsVirtual) && calc.PhysicalLeaves.Count > 0)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2" Class="mt-2">
|
||||||
|
@S.MeterDetail_ReadsPhysical
|
||||||
|
@for (var i = 0; i < calc.PhysicalLeaves.Count; i++)
|
||||||
|
{
|
||||||
|
var leaf = calc.PhysicalLeaves[i];
|
||||||
|
@(i > 0 ? ", " : " ")<MudLink Href="@MeterLinks.Analysis(leaf.MeterId, Query)" Typo="Typo.body2">@leaf.Name</MudLink>
|
||||||
|
}
|
||||||
|
</MudText>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<MudText Typo="Typo.caption" Class="mv-muted d-block mt-4">@S.MeterDetail_CalculationVsFlow</MudText>
|
||||||
|
}
|
||||||
|
</LoadPanel>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public MeterDetailView Detail { get; set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>The page's analysis state: links to the source meters carry its period.</summary>
|
||||||
|
[Parameter]
|
||||||
|
public AnalysisQuery? Query { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Bumped by the page whenever the meter (or its definition) changed.</summary>
|
||||||
|
[Parameter]
|
||||||
|
public int Version { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Opens the shared meter editor on this meter.</summary>
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback OnEdit { get; set; }
|
||||||
|
|
||||||
|
private readonly LoadSequencer _loads = new();
|
||||||
|
private readonly LoadState<MeterCalculationView> _state = new();
|
||||||
|
private (int MeterId, int Version)? _loadedFor;
|
||||||
|
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
var key = (Detail.Id, Version);
|
||||||
|
if (_loadedFor == key)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_loadedFor?.MeterId != Detail.Id)
|
||||||
|
{
|
||||||
|
_state.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
_loadedFor = key;
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task LoadAsync()
|
||||||
|
{
|
||||||
|
var meterId = Detail.Id;
|
||||||
|
return _loads.RunAsync(_state, async token =>
|
||||||
|
await Details.GetCalculationAsync(meterId, token)
|
||||||
|
?? throw new InvalidOperationException($"Meter {meterId} is not a virtual meter."), Logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string StatusText(VirtualMeterStatus status) => status switch
|
||||||
|
{
|
||||||
|
VirtualMeterStatus.Valid => S.MeterDetail_CalcStatusValid,
|
||||||
|
VirtualMeterStatus.Legacy => S.MeterDetail_CalcStatusLegacy,
|
||||||
|
VirtualMeterStatus.NeedsConfiguration => S.MeterDetail_CalcStatusNeedsConfiguration,
|
||||||
|
VirtualMeterStatus.Malformed => S.MeterDetail_CalcStatusMalformed,
|
||||||
|
_ => S.MeterDetail_CalcStatusInvalid,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string LegacyText(LegacyDerivation legacy) => legacy.Outcome switch
|
||||||
|
{
|
||||||
|
LegacyDerivationOutcome.NoSources => S.MeterDetail_LegacyNoSources,
|
||||||
|
LegacyDerivationOutcome.UnknownSource => S.MeterDetail_LegacyUnknownSource,
|
||||||
|
LegacyDerivationOutcome.SourceNeedsConfiguration => S.MeterDetail_LegacySourceNeedsConfiguration,
|
||||||
|
LegacyDerivationOutcome.NotAdditive => S.MeterDetail_LegacyNotAdditive,
|
||||||
|
LegacyDerivationOutcome.MixedUnits => S.MeterDetail_LegacyMixed,
|
||||||
|
LegacyDerivationOutcome.MixedKinds => S.MeterDetail_LegacyMixed,
|
||||||
|
_ => S.MeterDetail_CalcStatusNeedsConfiguration,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One validation finding in words — the same sentence the attention list and <see cref="DisplayNames"/> give it, with
|
||||||
|
/// the units or kinds involved; a syntax error in the editor's words, with its position. The meters involved follow
|
||||||
|
/// it as links (<see cref="MeterPath"/>).
|
||||||
|
/// </summary>
|
||||||
|
private static string ProblemText(VirtualProblem problem) =>
|
||||||
|
problem is { Kind: VirtualProblemKind.Syntax, SyntaxError: { } error }
|
||||||
|
? MeterVault.App.MeterEditing.MeterEditorText.FormulaError(error)
|
||||||
|
: AttentionItems.VirtualReasonWithoutMeters(problem);
|
||||||
|
|
||||||
|
public void Dispose() => _loads.Dispose();
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
.mv-calc-facts {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(7rem, max-content) 1fr;
|
||||||
|
gap: .35rem 1.5rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-calc-facts dt {
|
||||||
|
color: var(--mud-palette-text-secondary);
|
||||||
|
font-size: .875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-calc-facts dd {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-calc-problems {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-calc-problems li {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: .25rem .5rem;
|
||||||
|
padding: .25rem 0;
|
||||||
|
color: var(--mud-palette-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 599.98px) {
|
||||||
|
.mv-calc-facts {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: .1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-calc-facts dd {
|
||||||
|
margin-bottom: .4rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
@* What qualifies the meter's figures (brief §4.3, §7.2, D-13 – D-19): the resolution its data has, the dates it covers,
|
||||||
|
an opening balance of unknown start (with the offer to set an install date), rows dated after now that are not
|
||||||
|
counted yet, how current the data is, what the normalized quantity assumes, and — as context for the chart — the
|
||||||
|
events and tariff changes inside the range. Worded, never a bare percentage the metadata cannot support. *@
|
||||||
|
|
||||||
|
<section class="@Class" aria-labelledby="@_headingId">
|
||||||
|
<MudText Typo="Typo.h6" id="@_headingId" Class="mb-2">@S.MeterDetail_QualityTitle</MudText>
|
||||||
|
<MudPaper Outlined="true" Elevation="0" Class="pa-3">
|
||||||
|
<dl class="mv-facts">
|
||||||
|
<div class="mv-facts__row">
|
||||||
|
<dt>@S.MeterDetail_Resolution</dt>
|
||||||
|
<dd>
|
||||||
|
@if (Series?.Resolution is { } resolution)
|
||||||
|
{
|
||||||
|
@resolution.Display()
|
||||||
|
@if (resolution >= ResolutionClass.Month)
|
||||||
|
{
|
||||||
|
<div class="mv-cell-secondary">@(Detail.IsVirtual ? S.MeterDetail_ResolutionMonthlyHintVirtual : S.MeterDetail_ResolutionMonthlyHint)</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@Format.Unknown
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="mv-facts__row">
|
||||||
|
<dt>@S.MeterDetail_DataRange</dt>
|
||||||
|
<dd>
|
||||||
|
@if (Series?.Availability is { } available)
|
||||||
|
{
|
||||||
|
@Format.DateRange(available.FirstDay, available.LastDay)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@S.MeterDetail_NoDataAtAll
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="mv-facts__row">
|
||||||
|
<dt>@S.MeterDetail_Freshness</dt>
|
||||||
|
<dd>
|
||||||
|
@if (Series?.Freshness is { State: not FreshnessState.NoData } freshness)
|
||||||
|
{
|
||||||
|
@freshness.State.Display()
|
||||||
|
@if (freshness.LastActivity is { } last)
|
||||||
|
{
|
||||||
|
<div class="mv-cell-secondary">@Loc.F(S.MeterDetail_LastActivity, Format.Date(PeriodResolver.LocalDate(last, View.Period.Zone)))</div>
|
||||||
|
}
|
||||||
|
@if (freshness.State == FreshnessState.Stale && !Detail.IsVirtual)
|
||||||
|
{
|
||||||
|
<MudLink Href="@MeterLinks.Detail(Detail.Id, MeterLinks.TabSources, null, Query)" Typo="Typo.body2">@S.Attention_CheckSource</MudLink>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@FreshnessState.NoData.Display()
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="mv-facts__row">
|
||||||
|
<dt>@S.MeterDetail_Quantity</dt>
|
||||||
|
<dd>
|
||||||
|
@QuantityText
|
||||||
|
@foreach (var note in NoteTexts)
|
||||||
|
{
|
||||||
|
<div class="mv-cell-secondary">@note</div>
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
@if (HasOpeningBalance)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3">
|
||||||
|
@Loc.F(S.MeterDetail_OpeningBalanceNote, Format.Number(Detail.InitialBaseline, 2), Detail.Unit)
|
||||||
|
@if (Detail.InstalledAt is null && !Detail.IsVirtual)
|
||||||
|
{
|
||||||
|
<div class="mt-2">
|
||||||
|
<MudButton Size="Size.Small" Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Event"
|
||||||
|
OnClick="OnEdit">@S.MeterDetail_SetInstallDate</MudButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</MudAlert>
|
||||||
|
}
|
||||||
|
|
||||||
|
@foreach (var block in Series?.RecordedAfterNow ?? [])
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3">
|
||||||
|
@Loc.F(S.MeterDetail_RecordedAfterNow,
|
||||||
|
NameOf(block.MeterId),
|
||||||
|
block.Rows,
|
||||||
|
Format.Quantity(block.Amount, Series!.Unit),
|
||||||
|
Format.DateRange(block.FirstDay, block.LastDay))
|
||||||
|
<div class="mt-1">
|
||||||
|
<MudLink Href="@RecordsLink(block)" Typo="Typo.body2">@S.MeterDetail_ShowRecords</MudLink>
|
||||||
|
</div>
|
||||||
|
</MudAlert>
|
||||||
|
}
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
@if (!View.Markers.IsEmpty)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.subtitle1" Class="mt-4 mb-1">@S.MeterDetail_InThisPeriod</MudText>
|
||||||
|
@* One dated list, newest first (A-42): events and price changes interleave, so the dates never jump back up. *@
|
||||||
|
<ul class="mv-markers">
|
||||||
|
@foreach (var marker in Markers)
|
||||||
|
{
|
||||||
|
<li>
|
||||||
|
<MudIcon Icon="@marker.Icon" Size="Size.Small" aria-hidden="true" />
|
||||||
|
<span class="mv-markers__date">@Format.Date(marker.Day)</span>
|
||||||
|
<span>@marker.Text</span>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
<div class="d-flex flex-wrap" style="gap:1rem">
|
||||||
|
@if (View.Markers.Events.Count > 0)
|
||||||
|
{
|
||||||
|
<MudLink Href="@MeterLinks.Detail(Detail.Id, MeterLinks.TabEvents, null, Query)" Typo="Typo.body2">
|
||||||
|
@(View.Markers.MoreEvents ? S.MeterDetail_AllEventsInPeriod : S.MeterDetail_GoToEvents)
|
||||||
|
</MudLink>
|
||||||
|
}
|
||||||
|
@if (View.Markers.TariffChanges.Count > 0)
|
||||||
|
{
|
||||||
|
<MudLink Href="@MeterLinks.Detail(Detail.Id, MeterLinks.TabTariffs, null, Query)" Typo="Typo.body2">@S.MeterDetail_OpenTariffs</MudLink>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>The committed analysis.</summary>
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public MeterAnalysisView View { get; set; } = null!;
|
||||||
|
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public MeterDetailView Detail { get; set; } = null!;
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public AnalysisQuery? Query { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Opens the meter editor (to set an install date).</summary>
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback OnEdit { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public string? Class { get; set; }
|
||||||
|
|
||||||
|
private readonly string _headingId = "mv-quality-" + Guid.NewGuid().ToString("N")[..8];
|
||||||
|
|
||||||
|
private AnalysisSeries? Series => View.Series;
|
||||||
|
|
||||||
|
/// <summary>The events and tariff changes of the range as one dated list, newest first (A-42).</summary>
|
||||||
|
private IReadOnlyList<MeterMarker> Markers => MeterMarkerList.Of(View.Markers, View.Period.Zone, Detail.EnergyType);
|
||||||
|
|
||||||
|
/// <summary>A first reading booked against the baseline with an unknown start (D-14) sits in the range.</summary>
|
||||||
|
private bool HasOpeningBalance =>
|
||||||
|
Series is { } s && (s.Total.Provenance.HasFlag(Provenance.OpeningBalance) || s.Values.Any(v => v.Provenance.HasFlag(Provenance.OpeningBalance)));
|
||||||
|
|
||||||
|
private string QuantityText => Series is { } s
|
||||||
|
? Loc.F(S.MeterDetail_QuantityIn, s.Kind.Display(), s.Unit)
|
||||||
|
: Format.Unknown;
|
||||||
|
|
||||||
|
private IEnumerable<string> NoteTexts
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var notes = Series?.Notes ?? default;
|
||||||
|
if (notes.HasFlag(QuantityNotes.FixedRateEstimate))
|
||||||
|
{
|
||||||
|
yield return S.MeterDetail_NoteFixedRate;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notes.HasFlag(QuantityNotes.RateAssumedPerHour))
|
||||||
|
{
|
||||||
|
yield return S.MeterDetail_NoteRatePerHour;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notes.HasFlag(QuantityNotes.RateNotPerHour))
|
||||||
|
{
|
||||||
|
yield return S.MeterDetail_NoteRateNotPerHour;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notes.HasFlag(QuantityNotes.RegisterNotInHours))
|
||||||
|
{
|
||||||
|
yield return S.MeterDetail_NoteRegisterNotInHours;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notes.HasFlag(QuantityNotes.UndeclaredResult))
|
||||||
|
{
|
||||||
|
yield return S.MeterDetail_NoteUndeclaredResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string NameOf(int meterId) =>
|
||||||
|
meterId == Detail.Id ? Detail.Name
|
||||||
|
: Series?.Contributions.SelectMany(Flatten).FirstOrDefault(c => c.MeterId == meterId)?.Name is { Length: > 0 } name ? name
|
||||||
|
: Loc.F(S.Attention_MeterFallback, meterId);
|
||||||
|
|
||||||
|
private static IEnumerable<SeriesContribution> Flatten(SeriesContribution contribution) =>
|
||||||
|
contribution.Nested.SelectMany(Flatten).Prepend(contribution);
|
||||||
|
|
||||||
|
/// <summary>The normalized records of the days holding rows after now, on the meter that holds them.</summary>
|
||||||
|
private string RecordsLink(RecordedAfterNow block)
|
||||||
|
{
|
||||||
|
var target = Query is null || !PeriodResolver.IsValidCustomRange(block.FirstDay, block.LastDay)
|
||||||
|
? Query
|
||||||
|
: Query.WithCustomRange(block.FirstDay, block.LastDay);
|
||||||
|
return MeterLinks.Detail(block.MeterId, MeterLinks.TabNormalized, null, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/* The quality facts as a two-column list on wide screens, stacked on a phone. Palette variables only. */
|
||||||
|
.mv-facts {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(8rem, max-content) 1fr;
|
||||||
|
gap: .5rem 1.5rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-facts__row {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-facts dt {
|
||||||
|
color: var(--mud-palette-text-secondary);
|
||||||
|
font-size: .875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-facts dd {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 599.98px) {
|
||||||
|
.mv-facts {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: .15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-facts dd {
|
||||||
|
margin-bottom: .5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-markers {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0 0 .5rem;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-markers li {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: .25rem .5rem;
|
||||||
|
padding: .2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-markers__date {
|
||||||
|
color: var(--mud-palette-text-secondary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
min-width: 8.5rem;
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
@inherits RecordTabBase<EventRow>
|
||||||
|
@using Microsoft.Extensions.DependencyInjection
|
||||||
|
@using MeterVault.Infrastructure.Ingestion
|
||||||
|
@inject IServiceScopeFactory Scopes
|
||||||
|
@inject IDialogService DialogService
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
|
||||||
|
@* A meter's events (brief §7.2, D-50): swaps and resets that keep a register's history continuous, a tank's levels and
|
||||||
|
deliveries, notes. Recorded through MeterEventService (the dialog), deleted there too — an imported one only by
|
||||||
|
reverting its import. Paged and filtered by the page period. *@
|
||||||
|
|
||||||
|
<div class="d-flex align-center justify-space-between flex-wrap mb-3" style="gap:.5rem">
|
||||||
|
<MudText Typo="Typo.body2" Class="mv-muted">@EventsHint</MudText>
|
||||||
|
<MudMenu Label="@S.MeterDetail_RecordEvent" Variant="Variant.Filled" Color="Color.Primary" Size="Size.Small"
|
||||||
|
StartIcon="@Icons.Material.Filled.Add" EndIcon="@Icons.Material.Filled.KeyboardArrowDown" Dense="true">
|
||||||
|
@foreach (var type in MeterEventRules.RecordableFor(Detail.Mode))
|
||||||
|
{
|
||||||
|
var chosen = type;
|
||||||
|
<MudMenuItem Icon="@MeterEventText.Icon(chosen)" OnClick="@(() => OnRecordEvent.InvokeAsync(chosen))">@($"{chosen.Display()}…")</MudMenuItem>
|
||||||
|
}
|
||||||
|
</MudMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PeriodToolbar Query="Query" Period="Current?.Period" Defaults="Defaults" QueryChanged="OnQueryChanged"
|
||||||
|
ShowBucket="false" ShowComparison="false" ShowsRecords="true" Class="mb-2" />
|
||||||
|
|
||||||
|
<LoadPanel State="State" OnRetry="LoadAsync" Context="view" PlaceholderHeight="160">
|
||||||
|
@if (view.MeterId == Detail.Id)
|
||||||
|
{
|
||||||
|
@if (view.Page.Rows.Count == 0)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2" Class="mv-muted">@(Detail.HasEvents ? S.MeterDetail_NoEventsInPeriod : S.MeterDetail_NoEvents)</MudText>
|
||||||
|
@if (Detail.HasEvents && view.Range.IsBounded)
|
||||||
|
{
|
||||||
|
<MudButton Size="Size.Small" Variant="Variant.Text" Class="mt-1" OnClick="ShowAllAsync">@S.MeterDetail_ShowAllDates</MudButton>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.caption" Class="mv-muted">@Loc.F(S.MeterDetail_TimesIn, Periods.Zone.Id)</MudText>
|
||||||
|
<div class="mv-table-scroll mt-1" role="region" aria-label="@S.MeterDetail_TabEvents" tabindex="0">
|
||||||
|
<MudSimpleTable Dense="true" Hover="true">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">@S.MeterDetail_Time</th>
|
||||||
|
<th scope="col">@S.Common_Type</th>
|
||||||
|
<th scope="col" class="mv-num">@S.Common_Amount</th>
|
||||||
|
<th scope="col" class="mv-num">@S.MeterDetail_PrevNew</th>
|
||||||
|
<th scope="col">@S.MeterDetail_Notes</th>
|
||||||
|
<th scope="col"><span class="mv-sr-only">@S.Common_Actions</span></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var e in view.Page.Rows)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td style="white-space:nowrap">
|
||||||
|
@Local(e.Time)
|
||||||
|
@if (IsAfterNow(e.Time))
|
||||||
|
{
|
||||||
|
<AfterNowChip />
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td style="white-space:nowrap">
|
||||||
|
<MudIcon Icon="@MeterEventText.Icon(e.Type)" Size="Size.Small" Class="mr-1" Style="vertical-align:middle" aria-hidden="true" />@e.Type.Display()
|
||||||
|
</td>
|
||||||
|
<td class="mv-num">@(e.Amount is { } a ? $"{Format.Number(a, 2)} {e.Unit}" : Format.Unknown)</td>
|
||||||
|
<td class="mv-num" style="white-space:nowrap">@PrevNewText(e)</td>
|
||||||
|
<td>@e.Notes</td>
|
||||||
|
<td style="text-align:right">
|
||||||
|
@if (e.ImportBatchId is not null)
|
||||||
|
{
|
||||||
|
<MudTooltip Text="@S.MeterDetail_ImportedEventHint">
|
||||||
|
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined" Href="/import">@S.MeterDetail_Imported</MudChip>
|
||||||
|
</MudTooltip>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudTooltip Text="@S.MeterDetail_DeleteEvent">
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error"
|
||||||
|
OnClick="@(() => DeleteAsync(e))" aria-label="@S.MeterDetail_DeleteEvent" />
|
||||||
|
</MudTooltip>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</MudSimpleTable>
|
||||||
|
</div>
|
||||||
|
<RecordPagerBar Pager="Pager" Count="view.Page.Rows.Count" Total="view.Page.Total" TotalIsCapped="view.Page.TotalIsCapped"
|
||||||
|
HasOlder="view.Page.Next is not null" OnNewest="NewestAsync" OnNewer="NewerAsync" OnOlder="OlderAsync" />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</LoadPanel>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>Opens the event dialog for a type (the page owns it).</summary>
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback<MeterEventType> OnRecordEvent { get; set; }
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
protected override Task<RecordPage<EventRow>> FetchAsync(int meterId, RecordRange range, RecordCursor? cursor, CancellationToken cancellationToken) =>
|
||||||
|
Details.GetEventsAsync(meterId, range, cursor, cancellationToken);
|
||||||
|
|
||||||
|
private static string PrevNewText(EventRow e) => (e.PrevValue, e.NewValue) switch
|
||||||
|
{
|
||||||
|
(null, null) => Format.Unknown,
|
||||||
|
({ } prev, var next) => $"{Format.Number(prev, 2)} → {(next is { } n ? Format.Number(n, 2) : Format.Unknown)}",
|
||||||
|
(null, { } next) => $"→ {Format.Number(next, 2)}",
|
||||||
|
};
|
||||||
|
|
||||||
|
private async Task DeleteAsync(EventRow meterEvent)
|
||||||
|
{
|
||||||
|
var message = MeterEventRules.IsRegisterBoundary(meterEvent.Type)
|
||||||
|
? Loc.F(S.MeterDetail_DeleteBoundaryConfirm, meterEvent.Type.Display(), Local(meterEvent.Time))
|
||||||
|
: Loc.F(S.MeterDetail_DeleteEventConfirm, meterEvent.Type.Display(), Local(meterEvent.Time));
|
||||||
|
if (!await Confirm.DeleteAsync(DialogService, S.MeterDetail_DeleteEvent, message))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var scope = Scopes.CreateAsyncScope();
|
||||||
|
var result = await scope.ServiceProvider.GetRequiredService<MeterEventService>().DeleteEventAsync(Detail.Id, meterEvent.Id);
|
||||||
|
Snackbar.Add(result.Succeeded ? S.MeterDetail_EventDeleted : MeterEventText.Problem(result.Problem),
|
||||||
|
result.Succeeded ? Severity.Success : Severity.Error);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
// The service's transaction has rolled back; report rather than end the circuit.
|
||||||
|
LoggerFactory.CreateLogger<MeterEventsTab>().LogError(ex, "Deleting an event on meter {MeterId} failed", Detail.Id);
|
||||||
|
Snackbar.Add(S.MeterEvent_ActionFailed, Severity.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
await OnChanged.InvokeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
@* The meter page's header (brief §7.2, D-48): Overview → energy type → meter, carrying the period; the name; chips for
|
||||||
|
the energy type (its analysis), the mode and "retired"; and the meter's own actions, so entering a reading, recording
|
||||||
|
a swap or fixing a setting never depends on finding the right tab first. The tab bar follows directly below. *@
|
||||||
|
|
||||||
|
<PageHeader Title="@Detail.Name" Description="@IdentityLine()">
|
||||||
|
<Breadcrumbs>
|
||||||
|
<AnalysisBreadcrumbs Query="Query" EnergyTypeId="Detail.EnergyTypeId" EnergyTypeName="@Detail.EnergyType"
|
||||||
|
MeterId="Detail.Id" MeterName="@Detail.Name" />
|
||||||
|
</Breadcrumbs>
|
||||||
|
<Chips>
|
||||||
|
<MudTooltip Text="@Loc.F(S.MeterDetail_EnergyTypeAnalysis, Detail.EnergyType)">
|
||||||
|
<MudChip T="string" Size="Size.Small" Color="Color.Primary"
|
||||||
|
Href="@AnalysisLinks.EnergyType(Detail.EnergyTypeId, AnalysisLinks.EnergyTabHistory, Query)">@Detail.EnergyType</MudChip>
|
||||||
|
</MudTooltip>
|
||||||
|
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined">@Detail.Mode.Display()</MudChip>
|
||||||
|
@if (IsRetired)
|
||||||
|
{
|
||||||
|
<MudChip T="string" Size="Size.Small" Color="Color.Warning">@RetiredText</MudChip>
|
||||||
|
}
|
||||||
|
</Chips>
|
||||||
|
<Actions>
|
||||||
|
@if (MeterEventRules.TakesReadings(Detail.Mode))
|
||||||
|
{
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.EditNote" OnClick="OnAddReading">
|
||||||
|
@S.MeterDetail_AddReading
|
||||||
|
</MudButton>
|
||||||
|
}
|
||||||
|
else if (Detail.Mode == MeterMode.ConsumableBalance)
|
||||||
|
{
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Straighten"
|
||||||
|
OnClick="@(() => OnRecordEvent.InvokeAsync(MeterEventType.TankLevel))">
|
||||||
|
@S.MeterDetail_RecordTankLevel
|
||||||
|
</MudButton>
|
||||||
|
}
|
||||||
|
<MudMenu Label="@S.MeterDetail_RecordEvent" Variant="Variant.Outlined" Color="Color.Primary"
|
||||||
|
EndIcon="@Icons.Material.Filled.KeyboardArrowDown" Dense="true">
|
||||||
|
@foreach (var type in MeterEventRules.RecordableFor(Detail.Mode))
|
||||||
|
{
|
||||||
|
var chosen = type;
|
||||||
|
<MudMenuItem Icon="@MeterEventText.Icon(chosen)" OnClick="@(() => OnRecordEvent.InvokeAsync(chosen))">@($"{chosen.Display()}…")</MudMenuItem>
|
||||||
|
}
|
||||||
|
</MudMenu>
|
||||||
|
<MudTooltip Text="@S.MeterDetail_EditMeter">
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Edit" Variant="Variant.Outlined" Size="Size.Medium"
|
||||||
|
OnClick="OnEdit" aria-label="@S.MeterDetail_EditMeter" />
|
||||||
|
</MudTooltip>
|
||||||
|
</Actions>
|
||||||
|
<ChildContent>
|
||||||
|
@ChildContent
|
||||||
|
</ChildContent>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>The meter.</summary>
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public MeterDetailView Detail { get; set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>The page's analysis state: breadcrumbs and the type chip carry its period.</summary>
|
||||||
|
[Parameter]
|
||||||
|
public AnalysisQuery? Query { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Today in the instance zone: a retire date on or before it makes the meter retired.</summary>
|
||||||
|
[Parameter]
|
||||||
|
public DateOnly Today { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback OnAddReading { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback<MeterEventType> OnRecordEvent { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback OnEdit { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Notices that belong to the header (a tank to set up, a first step).</summary>
|
||||||
|
[Parameter]
|
||||||
|
public RenderFragment? ChildContent { get; set; }
|
||||||
|
|
||||||
|
private bool IsRetired => !Detail.IsActive || Detail.RetiredAt is { } retired && retired <= Today;
|
||||||
|
|
||||||
|
private string RetiredText => Detail.RetiredAt is { } retired
|
||||||
|
? Loc.F(S.MeterDetail_RetiredOn, Format.Date(retired))
|
||||||
|
: S.MeterDetail_Retired;
|
||||||
|
|
||||||
|
private string? IdentityLine()
|
||||||
|
{
|
||||||
|
var parts = new List<string>(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 parts.Count == 0 ? null : string.Join(" · ", parts);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
@inherits RecordTabBase<ConsumptionDetailRow>
|
||||||
|
|
||||||
|
@* Normalized data (brief §7.2, D-50): the consumption or generation deltas derived from the readings and events, in the
|
||||||
|
meter's normalized unit (D-20) — what every chart, total and cost is built from. Derived and reproducible: they are
|
||||||
|
rebuilt whenever a reading or event changes. A drill-down from a chart bucket the data cannot resolve lands here,
|
||||||
|
filtered to that bucket. *@
|
||||||
|
|
||||||
|
<MudText Typo="Typo.body2" Class="mv-muted mb-3">@Loc.F(S.MeterDetail_NormalizedIntro, Detail.NormalizedUnit)</MudText>
|
||||||
|
|
||||||
|
<PeriodToolbar Query="Query" Period="Current?.Period" Defaults="Defaults" QueryChanged="OnQueryChanged"
|
||||||
|
ShowBucket="false" ShowComparison="false" ShowsRecords="true" Class="mb-2" />
|
||||||
|
|
||||||
|
<LoadPanel State="State" OnRetry="LoadAsync" Context="view" PlaceholderHeight="160">
|
||||||
|
@if (view.MeterId == Detail.Id)
|
||||||
|
{
|
||||||
|
@if (view.Page.Rows.Count == 0)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2" Class="mv-muted">@(view.Range.IsBounded ? S.MeterDetail_NoNormalizedInPeriod : S.MeterDetail_NoConsumption)</MudText>
|
||||||
|
@if (view.Range.IsBounded)
|
||||||
|
{
|
||||||
|
<MudButton Size="Size.Small" Variant="Variant.Text" Class="mt-1" OnClick="ShowAllAsync">@S.MeterDetail_ShowAllDates</MudButton>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.caption" Class="mv-muted">@Loc.F(S.MeterDetail_TimesIn, Periods.Zone.Id)</MudText>
|
||||||
|
<div class="mv-table-scroll mt-1" role="region" aria-label="@S.MeterDetail_TabNormalized" tabindex="0">
|
||||||
|
<MudSimpleTable Dense="true" Hover="true">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">@S.MeterDetail_Time</th>
|
||||||
|
<th scope="col" class="mv-num">@Loc.F(S.MeterDetail_AmountIn, Detail.NormalizedUnit)</th>
|
||||||
|
<th scope="col">@S.MeterDetail_Kind</th>
|
||||||
|
<th scope="col">@S.MeterDetail_Quality</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var c in view.Page.Rows)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td style="white-space:nowrap">
|
||||||
|
@Local(c.Time)
|
||||||
|
@if (IsAfterNow(c.Time))
|
||||||
|
{
|
||||||
|
<AfterNowChip />
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="mv-num">@Format.Number(c.Amount, 3)</td>
|
||||||
|
<td>@c.Kind.Display()</td>
|
||||||
|
<td><QualityChip Quality="c.Quality" /></td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</MudSimpleTable>
|
||||||
|
</div>
|
||||||
|
<RecordPagerBar Pager="Pager" Count="view.Page.Rows.Count" Total="view.Page.Total" TotalIsCapped="view.Page.TotalIsCapped"
|
||||||
|
HasOlder="view.Page.Next is not null" OnNewest="NewestAsync" OnNewer="NewerAsync" OnOlder="OlderAsync" />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</LoadPanel>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
protected override Task<RecordPage<ConsumptionDetailRow>> FetchAsync(int meterId, RecordRange range, RecordCursor? cursor, CancellationToken cancellationToken) =>
|
||||||
|
Details.GetConsumptionAsync(meterId, range, cursor, cancellationToken);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
@* The meters a calculation finding involves — the operands of a mismatch, the path of a loop — by name, each linked to
|
||||||
|
its own page over the same period; an id that names no meter says so. *@
|
||||||
|
|
||||||
|
@if (Ids.Count > 0)
|
||||||
|
{
|
||||||
|
<span class="mv-cell-secondary">
|
||||||
|
—
|
||||||
|
@for (var i = 0; i < Ids.Count; i++)
|
||||||
|
{
|
||||||
|
var id = Ids[i];
|
||||||
|
@(i > 0 ? Separator : string.Empty)
|
||||||
|
@if (Calculation.NameOf(id) is { } name)
|
||||||
|
{
|
||||||
|
<MudLink Href="@MeterLinks.Analysis(id, Query)" Typo="Typo.body2">@name</MudLink>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@Loc.F(S.Attention_MeterFallback, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public IReadOnlyList<int> Ids { get; set; } = [];
|
||||||
|
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public MeterCalculationView Calculation { get; set; } = null!;
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public AnalysisQuery? Query { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public string Separator { get; set; } = ", ";
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
@inherits RecordTabBase<ReadingRow>
|
||||||
|
@using Microsoft.Extensions.DependencyInjection
|
||||||
|
@using MeterVault.Infrastructure.Ingestion
|
||||||
|
@inject IServiceScopeFactory Scopes
|
||||||
|
@inject IDialogService DialogService
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
|
||||||
|
@* Raw readings (brief §7.2, D-50, D-57): every value exactly as it arrived, in the register's raw unit — the audit record
|
||||||
|
everything else is derived from, never changed to fix a figure. Paged and filtered by the page period; hand-entered
|
||||||
|
ones can be deleted (the meter is recomputed). *@
|
||||||
|
|
||||||
|
<MudText Typo="Typo.body2" Class="mv-muted mb-3">@S.MeterDetail_ReadingsIntro</MudText>
|
||||||
|
|
||||||
|
@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. *@
|
||||||
|
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-3">
|
||||||
|
@S.MeterDetail_TankUsesEvents
|
||||||
|
<MudButton Size="Size.Small" Variant="Variant.Text" Color="Color.Primary" Class="ml-2"
|
||||||
|
Href="@MeterLinks.Detail(Detail.Id, MeterLinks.TabEvents, null, Query)">@S.MeterDetail_GoToEvents</MudButton>
|
||||||
|
</MudAlert>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="d-flex align-center flex-wrap justify-space-between mb-2" style="gap:.5rem 1rem">
|
||||||
|
<MudText Typo="Typo.body2">
|
||||||
|
@if (Detail.FirstReading is { } first && Detail.LastReading is { } last)
|
||||||
|
{
|
||||||
|
@Loc.F(S.MeterDetail_RegisterSummary,
|
||||||
|
Format.Number(first.Value, 2), Local(first.Time), Format.Number(last.Value, 2), Local(last.Time), Detail.Unit)
|
||||||
|
}
|
||||||
|
<span class="mv-muted">@(" " + Loc.F(S.MeterDetail_BaselineValue, Format.Number(Detail.InitialBaseline, 2)))</span>
|
||||||
|
</MudText>
|
||||||
|
<MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Primary"
|
||||||
|
StartIcon="@Icons.Material.Filled.Add" OnClick="OnAddReading">
|
||||||
|
@S.MeterDetail_AddReading
|
||||||
|
</MudButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<PeriodToolbar Query="Query" Period="Current?.Period" Defaults="Defaults" QueryChanged="OnQueryChanged"
|
||||||
|
ShowBucket="false" ShowComparison="false" ShowsRecords="true" Class="mb-2" />
|
||||||
|
|
||||||
|
<LoadPanel State="State" OnRetry="LoadAsync" Context="view" PlaceholderHeight="160">
|
||||||
|
@if (view.MeterId == Detail.Id)
|
||||||
|
{
|
||||||
|
@if (view.Page.Rows.Count == 0)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2" Class="mv-muted">@(Detail.HasReadings ? S.MeterDetail_NoReadingsInPeriod : S.MeterDetail_NoRawReadings)</MudText>
|
||||||
|
@if (Detail.HasReadings && view.Range.IsBounded)
|
||||||
|
{
|
||||||
|
<MudButton Size="Size.Small" Variant="Variant.Text" Class="mt-1" OnClick="ShowAllAsync">@S.MeterDetail_ShowAllDates</MudButton>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.caption" Class="mv-muted">@Loc.F(S.MeterDetail_TimesIn, Periods.Zone.Id)</MudText>
|
||||||
|
<div class="mv-table-scroll mt-1" role="region" aria-label="@S.MeterDetail_TabReadings" tabindex="0">
|
||||||
|
<MudSimpleTable Dense="true" Hover="true">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">@S.MeterDetail_Time</th>
|
||||||
|
<th scope="col" class="mv-num">@Loc.F(S.MeterDetail_ValueIn, Detail.Unit)</th>
|
||||||
|
<th scope="col">@S.MeterDetail_Quality</th>
|
||||||
|
<th scope="col">@S.MeterDetail_Flags</th>
|
||||||
|
<th scope="col"><span class="mv-sr-only">@S.Common_Actions</span></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var r in view.Page.Rows)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td style="white-space:nowrap">
|
||||||
|
@Local(r.Time)
|
||||||
|
@if (IsAfterNow(r.Time))
|
||||||
|
{
|
||||||
|
<AfterNowChip />
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="mv-num">@Format.Number(r.Value, 2)</td>
|
||||||
|
<td><QualityChip Quality="r.Quality" /></td>
|
||||||
|
<td>@r.Flags.Display()</td>
|
||||||
|
<td style="text-align:right">
|
||||||
|
@if (r.Quality == ReadingQuality.Manual)
|
||||||
|
{
|
||||||
|
<MudTooltip Text="@S.MeterDetail_DeleteReading">
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error"
|
||||||
|
OnClick="@(() => DeleteAsync(r))" aria-label="@S.MeterDetail_DeleteReading" />
|
||||||
|
</MudTooltip>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</MudSimpleTable>
|
||||||
|
</div>
|
||||||
|
<RecordPagerBar Pager="Pager" Count="view.Page.Rows.Count" Total="view.Page.Total" TotalIsCapped="view.Page.TotalIsCapped"
|
||||||
|
HasOlder="view.Page.Next is not null" OnNewest="NewestAsync" OnNewer="NewerAsync" OnOlder="OlderAsync" />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</LoadPanel>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>Opens the manual-reading dialog (the page owns it).</summary>
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback OnAddReading { get; set; }
|
||||||
|
|
||||||
|
protected override Task<RecordPage<ReadingRow>> FetchAsync(int meterId, RecordRange range, RecordCursor? cursor, CancellationToken cancellationToken) =>
|
||||||
|
Details.GetReadingsAsync(meterId, range, cursor, cancellationToken);
|
||||||
|
|
||||||
|
private async Task DeleteAsync(ReadingRow reading)
|
||||||
|
{
|
||||||
|
if (!await Confirm.DeleteAsync(DialogService, S.MeterDetail_DeleteReadingTitle,
|
||||||
|
Loc.F(S.MeterDetail_DeleteReadingConfirm, Format.Number(reading.Value, 2), Detail.Unit, Local(reading.Time))))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var scope = Scopes.CreateAsyncScope();
|
||||||
|
var result = await scope.ServiceProvider.GetRequiredService<MeterEventService>().DeleteManualReadingAsync(Detail.Id, reading.Time);
|
||||||
|
Snackbar.Add(result.Succeeded ? S.MeterDetail_ReadingDeleted : MeterEventText.Problem(result.Problem),
|
||||||
|
result.Succeeded ? Severity.Success : Severity.Error);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
// The service's transaction has rolled back; report rather than end the circuit.
|
||||||
|
LoggerFactory.CreateLogger<MeterReadingsTab>().LogError(ex, "Deleting a manual reading on meter {MeterId} failed", Detail.Id);
|
||||||
|
Snackbar.Add(S.MeterEvent_ActionFailed, Severity.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
await OnChanged.InvokeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
@using System.Globalization
|
||||||
|
@using Microsoft.EntityFrameworkCore
|
||||||
|
@using MeterVault.Infrastructure.Ingestion
|
||||||
|
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
@inject DraftStore Drafts
|
||||||
|
@implements IDisposable
|
||||||
|
|
||||||
|
@* A meter's ingest source: its type, the connector that serves it and where the value sits in the payload. Every way to
|
||||||
|
a missing connector is a detour that comes back here with the connector picked and everything typed restored: the
|
||||||
|
dialog saves itself to the circuit's DraftStore when the page is left while it is open. *@
|
||||||
|
<MudDialog Visible="_open" VisibleChanged="OnVisibleChanged" Options="_dialogOptions">
|
||||||
|
<TitleContent>
|
||||||
|
<MudText Typo="Typo.h6">@(_edit.Id == 0 ? S.MeterDetail_NewSource : S.MeterDetail_EditSource)</MudText>
|
||||||
|
</TitleContent>
|
||||||
|
<DialogContent>
|
||||||
|
<MudSelect T="SourceType" Value="_edit.SourceType" ValueChanged="OnSourceTypeChanged" Label="@S.MeterDetail_SourceType" Class="mb-2">
|
||||||
|
@foreach (var type in Enum.GetValues<SourceType>())
|
||||||
|
{
|
||||||
|
<MudSelectItem T="SourceType" Value="type">@type.Display()</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
@if (SourceRouting.RequiredEndpoint(_edit.SourceType) is { } needed)
|
||||||
|
{
|
||||||
|
@* 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)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Warning" Dense="true" Class="mb-2">
|
||||||
|
@if (_endpoints.FirstOrDefault(e => e.Type == needed && !e.IsEnabled) is { } disabled)
|
||||||
|
{
|
||||||
|
<span>@Loc.F(S.MeterDetail_ConnectorOnlyDisabled, disabled.Name) <MudLink Href="@MeterLinks.EditConnector(MeterId, SourceIdOrNull, _edit.SourceType, disabled.Id)">@S.MeterDetail_EnableConnectorLink</MudLink></span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span>@Loc.F(S.MeterDetail_NoConnectorYet, needed.Display()) <MudLink Href="@MeterLinks.NewConnector(MeterId, SourceIdOrNull, _edit.SourceType, needed)">@S.MeterDetail_CreateConnectorLink</MudLink> @S.MeterDetail_CreateConnectorHint</span>
|
||||||
|
}
|
||||||
|
</MudAlert>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudSelect T="int?" @bind-Value="_edit.EndpointId" Label="@S.MeterDetail_Connector" Required="true" Class="mb-1">
|
||||||
|
@foreach (var e in usable)
|
||||||
|
{
|
||||||
|
<MudSelectItem T="int?" Value="@((int?)e.Id)">@e.Name</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
<div class="mb-2 d-flex flex-wrap" style="gap:.25rem 1rem">
|
||||||
|
@if (_edit.EndpointId is { } chosen && usable.Any(e => e.Id == chosen))
|
||||||
|
{
|
||||||
|
@* Change the connection itself (URL, token, broker) without losing what is typed here: the dialog's
|
||||||
|
draft is kept and the connector page leads back to it. *@
|
||||||
|
<MudLink Typo="Typo.caption" Href="@MeterLinks.EditConnector(MeterId, SourceIdOrNull, _edit.SourceType, chosen)">
|
||||||
|
@S.MeterDetail_EditConnectorLink
|
||||||
|
</MudLink>
|
||||||
|
}
|
||||||
|
<MudLink Typo="Typo.caption" Href="@MeterLinks.NewConnector(MeterId, SourceIdOrNull, _edit.SourceType, needed)">
|
||||||
|
@S.MeterDetail_AnotherConnector
|
||||||
|
</MudLink>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@if (_edit.SourceType == SourceType.HomeAssistant)
|
||||||
|
{
|
||||||
|
<MudTextField @bind-Value="_edit.EntityId" Label="@S.MeterDetail_EntityIdLabel" Class="mb-2" />
|
||||||
|
<MudTextField @bind-Value="_edit.Attribute" Label="@S.MeterDetail_AttributeLabel" Class="mb-2" />
|
||||||
|
<MudNumericField T="int?" @bind-Value="_edit.PollMinutes" Label="@S.MeterDetail_PollIntervalLabel" Class="mb-1" />
|
||||||
|
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2 d-block">
|
||||||
|
@S.MeterDetail_PollHint
|
||||||
|
</MudText>
|
||||||
|
}
|
||||||
|
else if (_edit.SourceType is SourceType.Mqtt or SourceType.Tasmota)
|
||||||
|
{
|
||||||
|
<MudTextField @bind-Value="_edit.Topic" Label="@S.MeterDetail_TopicLabel" Class="mb-2" />
|
||||||
|
<MudTextField @bind-Value="_edit.Path" Label="@S.MeterDetail_ValuePathLabel" Class="mb-2" />
|
||||||
|
<MudTextField @bind-Value="_edit.TimePath" Label="@S.MeterDetail_TimePathLabel" Class="mb-2" />
|
||||||
|
}
|
||||||
|
<MudSelect T="SourceValueKind" @bind-Value="_edit.ValueKind" Label="@S.MeterDetail_ValueKind" Class="mb-2">
|
||||||
|
@foreach (var kind in Enum.GetValues<SourceValueKind>())
|
||||||
|
{
|
||||||
|
<MudSelectItem T="SourceValueKind" Value="kind">@kind.Display()</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
<div class="d-flex flex-wrap" style="column-gap:1rem">
|
||||||
|
<MudNumericField T="double" @bind-Value="_edit.Scale" Label="@S.MeterDetail_Scale" Class="mb-2" Style="min-width:6rem" />
|
||||||
|
<MudNumericField T="double" @bind-Value="_edit.Offset" Label="@S.MeterDetail_Offset" Class="mb-2" Style="min-width:6rem" />
|
||||||
|
<MudNumericField T="int" @bind-Value="_edit.Priority" Label="@S.MeterDetail_Priority" Class="mb-2" Style="min-width:6rem" />
|
||||||
|
</div>
|
||||||
|
<MudSwitch T="bool" @bind-Value="_edit.IsEnabled" Label="@S.Common_Enabled" Color="Color.Primary" />
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<MudButton OnClick="Cancel">@S.Common_Cancel</MudButton>
|
||||||
|
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">@S.Common_Save</MudButton>
|
||||||
|
</DialogActions>
|
||||||
|
</MudDialog>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>The meter the source feeds.</summary>
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public int MeterId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Raised after a source was saved.</summary>
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback Saved { get; set; }
|
||||||
|
|
||||||
|
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
|
||||||
|
private List<IngestionEndpoint> _endpoints = [];
|
||||||
|
private SourceEdit _edit = new();
|
||||||
|
private bool _open;
|
||||||
|
|
||||||
|
/// <summary>The meter the dialog was opened for: a draft is only ever saved under the meter it belongs to.</summary>
|
||||||
|
private int? _openFor;
|
||||||
|
|
||||||
|
/// <summary>Opens the dialog for a new source, or for <paramref name="source"/>.</summary>
|
||||||
|
public async Task OpenAsync(MeterSource? source)
|
||||||
|
{
|
||||||
|
await LoadEndpointsAsync();
|
||||||
|
Fill(source);
|
||||||
|
Show();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens the 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 (<see cref="MeterLinks.Source"/>).
|
||||||
|
/// </summary>
|
||||||
|
public async Task OpenFromLinkAsync(int? sourceId, SourceType? type, int? connectorId)
|
||||||
|
{
|
||||||
|
await LoadEndpointsAsync();
|
||||||
|
MeterSource? source = null;
|
||||||
|
if (sourceId is { } id)
|
||||||
|
{
|
||||||
|
await using var db = await DbFactory.CreateDbContextAsync();
|
||||||
|
source = await db.MeterSources.AsNoTracking().FirstOrDefaultAsync(s => s.Id == id && s.MeterId == MeterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Fill(source);
|
||||||
|
|
||||||
|
// 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 (type is not null && Drafts.TryTake<SourceEdit>(DraftKey(MeterId, sourceId), out var draft))
|
||||||
|
{
|
||||||
|
draft.Id = _edit.Id;
|
||||||
|
_edit = draft;
|
||||||
|
}
|
||||||
|
|
||||||
|
var connector = connectorId is { } cid ? _endpoints.FirstOrDefault(e => e.Id == cid) : null;
|
||||||
|
if ((type ?? (connector is null ? null : SourceRouting.DefaultSourceFor(connector.Type))) is { } wanted
|
||||||
|
&& wanted != _edit.SourceType)
|
||||||
|
{
|
||||||
|
OnSourceTypeChanged(wanted);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, _edit.SourceType))
|
||||||
|
{
|
||||||
|
_edit.EndpointId = connector.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
Show();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Leaving the page with the 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.
|
||||||
|
/// </summary>
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_open && _openFor is { } meterId)
|
||||||
|
{
|
||||||
|
Drafts.Save(DraftKey(meterId, SourceIdOrNull), _edit.Clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The draft key of a meter's source dialog (a new source when <paramref name="sourceId"/> is null).</summary>
|
||||||
|
public static string DraftKey(int meterId, int? sourceId) =>
|
||||||
|
$"meter:{meterId.ToString(CultureInfo.InvariantCulture)}:source:{sourceId?.ToString(CultureInfo.InvariantCulture) ?? "new"}";
|
||||||
|
|
||||||
|
private void Show()
|
||||||
|
{
|
||||||
|
_openFor = MeterId;
|
||||||
|
_open = true;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The dialog closed on its own (backdrop, Escape, or the dialog provider dismissing it on navigation). Only the
|
||||||
|
/// Cancel button throws the draft away: when the page is left through a connector link, the provider dismisses the
|
||||||
|
/// dialog after <see cref="Dispose"/> has saved what was typed, and that draft is the way back.
|
||||||
|
/// </summary>
|
||||||
|
private void OnVisibleChanged(bool visible)
|
||||||
|
{
|
||||||
|
if (!visible)
|
||||||
|
{
|
||||||
|
_open = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The source being edited, or null for a new one — what a detour to the connector page returns to.</summary>
|
||||||
|
private int? SourceIdOrNull => _edit.Id == 0 ? null : _edit.Id;
|
||||||
|
|
||||||
|
private void Cancel()
|
||||||
|
{
|
||||||
|
_open = false;
|
||||||
|
Drafts.Discard(DraftKey(MeterId, SourceIdOrNull));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadEndpointsAsync()
|
||||||
|
{
|
||||||
|
await using var db = await DbFactory.CreateDbContextAsync();
|
||||||
|
_endpoints = await db.IngestionEndpoints.AsNoTracking().OrderBy(e => e.Name).ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Fill(MeterSource? source)
|
||||||
|
{
|
||||||
|
if (source is null)
|
||||||
|
{
|
||||||
|
_edit = new SourceEdit();
|
||||||
|
OnSourceTypeChanged(_edit.SourceType);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var config = SourceConfig.Parse(source.Config);
|
||||||
|
_edit = new SourceEdit
|
||||||
|
{
|
||||||
|
Id = source.Id,
|
||||||
|
SourceType = source.SourceType,
|
||||||
|
EndpointId = source.EndpointId,
|
||||||
|
ValueKind = source.ValueKind,
|
||||||
|
Scale = source.Scale,
|
||||||
|
Offset = source.Offset,
|
||||||
|
Priority = source.Priority,
|
||||||
|
IsEnabled = source.IsEnabled,
|
||||||
|
EntityId = config.EntityId,
|
||||||
|
Attribute = config.Attribute,
|
||||||
|
PollMinutes = config.PollMinutes,
|
||||||
|
Topic = config.Topic,
|
||||||
|
Path = config.Path,
|
||||||
|
TimePath = config.TimePath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveAsync()
|
||||||
|
{
|
||||||
|
// 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 (SourceRouting.RequiredEndpoint(_edit.SourceType) is { } needed)
|
||||||
|
{
|
||||||
|
var selected = _endpoints.FirstOrDefault(e => e.Id == _edit.EndpointId);
|
||||||
|
if (selected is null)
|
||||||
|
{
|
||||||
|
Snackbar.Add(Loc.F(S.MeterDetail_PickConnector, needed.Display(), _edit.SourceType.Display()), Severity.Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selected.Type != needed)
|
||||||
|
{
|
||||||
|
Snackbar.Add(
|
||||||
|
Loc.F(S.MeterDetail_ConnectorTypeMismatch, selected.Name, selected.Type.Display(), _edit.SourceType.Display(), needed.Display()),
|
||||||
|
Severity.Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selected.IsEnabled)
|
||||||
|
{
|
||||||
|
Snackbar.Add(Loc.F(S.MeterDetail_ConnectorDisabled, selected.Name), Severity.Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_edit.EndpointId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var config = new SourceConfig
|
||||||
|
{
|
||||||
|
EntityId = Trim(_edit.EntityId),
|
||||||
|
Attribute = Trim(_edit.Attribute),
|
||||||
|
PollMinutes = _edit.PollMinutes,
|
||||||
|
Topic = Trim(_edit.Topic),
|
||||||
|
Path = Trim(_edit.Path),
|
||||||
|
TimePath = Trim(_edit.TimePath),
|
||||||
|
};
|
||||||
|
var configJson = System.Text.Json.JsonSerializer.Serialize(config,
|
||||||
|
new System.Text.Json.JsonSerializerOptions { DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull });
|
||||||
|
|
||||||
|
await using (var db = await DbFactory.CreateDbContextAsync())
|
||||||
|
{
|
||||||
|
if (_edit.Id == 0)
|
||||||
|
{
|
||||||
|
db.MeterSources.Add(new MeterSource
|
||||||
|
{
|
||||||
|
MeterId = MeterId,
|
||||||
|
SourceType = _edit.SourceType,
|
||||||
|
EndpointId = _edit.EndpointId,
|
||||||
|
Config = configJson,
|
||||||
|
ValueKind = _edit.ValueKind,
|
||||||
|
Scale = _edit.Scale,
|
||||||
|
Offset = _edit.Offset,
|
||||||
|
Priority = _edit.Priority,
|
||||||
|
IsEnabled = _edit.IsEnabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var existing = await db.MeterSources.FirstAsync(s => s.Id == _edit.Id);
|
||||||
|
existing.SourceType = _edit.SourceType;
|
||||||
|
existing.EndpointId = _edit.EndpointId;
|
||||||
|
existing.Config = configJson;
|
||||||
|
existing.ValueKind = _edit.ValueKind;
|
||||||
|
existing.Scale = _edit.Scale;
|
||||||
|
existing.Offset = _edit.Offset;
|
||||||
|
existing.Priority = _edit.Priority;
|
||||||
|
existing.IsEnabled = _edit.IsEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
_open = false;
|
||||||
|
Drafts.Discard(DraftKey(MeterId, SourceIdOrNull));
|
||||||
|
Snackbar.Add(S.MeterDetail_SourceSaved, Severity.Success);
|
||||||
|
await Saved.InvokeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? Trim(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
|
||||||
|
// 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<IngestionEndpoint> ConnectorsFor(EndpointType type) =>
|
||||||
|
_endpoints.Where(e => e.Type == type && e.IsEnabled).ToList();
|
||||||
|
|
||||||
|
// Changing the source type can invalidate the chosen connector (an HA connector cannot serve an
|
||||||
|
// MQTT source), so drop a selection that no longer fits rather than saving a mismatched pair.
|
||||||
|
private void OnSourceTypeChanged(SourceType sourceType)
|
||||||
|
{
|
||||||
|
_edit.SourceType = sourceType;
|
||||||
|
|
||||||
|
var needed = SourceRouting.RequiredEndpoint(sourceType);
|
||||||
|
var selected = _endpoints.FirstOrDefault(e => e.Id == _edit.EndpointId);
|
||||||
|
if (needed is null || (selected is not null && selected.Type != needed))
|
||||||
|
{
|
||||||
|
_edit.EndpointId = 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 && _edit.EndpointId is null)
|
||||||
|
{
|
||||||
|
var candidates = ConnectorsFor(kind);
|
||||||
|
if (candidates.Count == 1)
|
||||||
|
{
|
||||||
|
_edit.EndpointId = candidates[0].Id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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; }
|
||||||
|
public SourceValueKind ValueKind { get; set; } = SourceValueKind.Register;
|
||||||
|
public double Scale { get; set; } = 1;
|
||||||
|
public double Offset { get; set; }
|
||||||
|
public int Priority { get; set; }
|
||||||
|
public bool IsEnabled { get; set; } = true;
|
||||||
|
public string? EntityId { get; set; }
|
||||||
|
public string? Attribute { get; set; }
|
||||||
|
public int? PollMinutes { get; set; } = 60;
|
||||||
|
public string? Topic { get; set; }
|
||||||
|
public string? Path { get; set; }
|
||||||
|
public string? TimePath { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||