# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## What this repo is 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) + 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 `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. - **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. 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). 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) .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 ``` /src/Core domain entities + enums; pure Normalization engine (mode strategies); Parsing (German dialect); Costing (legacy TariffResolver) /src/Core/Analysis pure analysis rules: Time (PeriodResolver, BucketPlanner, ComparisonResolver, Change), Coverage (runs, evaluator, matched coverage, provenance), Rollups, Quantities (units, normalized quantity, roles, tariff units), Totals (policy, category cover), Virtual (formula parser, validator, dependency graph, evaluator, legacy derivation), Costing (CostCalculator, TariffBook, CostAmount) /src/Infrastructure MeterVaultDbContext + migrations (relational + raw-SQL Timescale); Import (CsvImporter, profiles, ImportService); Ingestion (MQTT/HA workers, IngestionService, MeterEventService); 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 ``` Central package versions live in `Directory.Packages.props`; shared build/style in `Directory.Build.props` + `.editorconfig` (`TreatWarningsAsErrors`). Snake_case table/column mapping via `UseSnakeCaseNamingConvention`. EF migrations are exempt from code-style enforcement (see `.editorconfig`). ## Commands ```powershell dotnet build # build the solution dotnet test # all tests (Integration.Tests needs Docker for Testcontainers) 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~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 -p src/Infrastructure -s src/App -o Persistence/Migrations dotnet run --project src/App # run app + workers locally (needs a Timescale DB) docker compose -f deploy/docker-compose.yml up # app + TimescaleDB together ``` **Timescale-in-EF gotchas** (already handled — follow the pattern): hypertable DDL lives in raw-SQL migrations, one statement each with `migrationBuilder.Sql(..., suppressTransaction: true)` where Timescale needs it. The old continuous 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) **Data pipeline — one direction, layered (SDD §4.2, §5, §7):** ``` sources (Tasmota/HA/MQTT/manual/CSV) → Ingestion workers write raw `reading` rows (immutable audit truth) → NormalizationService.RecomputeMeterAsync derives append-only `consumption` (deltas in base unit, each row with its source interval) and, in the same transaction and by diff, the per-meter rollups by local day and month (`consumption_rollup`, `consumption_rollup_month`), coverage runs (`meter_coverage`) and `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:** - **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. - **Consumption is attributed to the months it accrued in** (`GapAttribution`, SDD §7.1). - **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. - **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. 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 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). ## 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` 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`. 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/`) These CSVs are the German-dialect *Energiebilanz* spreadsheet export and define the minimum feature bar (SDD §2, Appendix A). When writing the importer or normalization, honour: - **German number dialect:** decimal comma (`180,8244706`), thousands dot (`2.940,19`), trailing-`€` currency (`120,00 €`), **unit suffixes on values** (`411kWh`, `49` cm, `2287` L) — strip and validate. - **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). - **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` (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. ## Git Remote `origin` is `https://git.finalfactory.de/FinalFactory/MeterVault.git` (Gitea; default branch `master`). CI/release is **Gitea Actions** under `.gitea/workflows/` — edit `VERSION` on `master` to tag + publish the image to the Gitea container registry.