Files
MeterVault/CLAUDE.md
T
Florian Schmidt 8940ef25c3
ci / build-test (push) Successful in 2m31s
Analysis: one selected period, one set of numbers, on every page
The dashboards told several stories at once. Overview asked for full
calendar years, meter detail for a fixed 12-month window that was really
13, Trends for 24 months with an Apply button, and the energy pages for
60. Each page derived "today" from UTC, so the first hours of a local day
belonged to yesterday. A missing tariff, a month nobody measured and a
genuine zero all rendered as 0. And a virtual meter -- the one thing the
spreadsheet leans on hardest -- was excluded from analysis outright:
MeterPeriodService returned null for it and the page offered a flow
diagram instead.

docs/DASHBOARD_ANALYSIS_CHANGE_BRIEF.md is the work order. Every choice it
left open is settled in docs/ANALYSIS_IMPLEMENTATION_NOTE.md as D-01..D-58
plus amendments A-01..A-30; code, tests and release notes cite those ids.

The analysis layer

Core/Analysis holds the pure rules: period presets resolved once in the
instance zone into a local date range and a half-open UTC range, bucket
plans, calendar-unit comparisons, coverage runs with a resolution class,
normalized quantities and units, the totals policy, the virtual formula
parser/validator/evaluator, and the cost calculator. "Now" comes from
TimeProvider; services never read the clock.

Normalization now writes, in the same transaction as consumption and by
diff, per-meter rollups by local day and month plus coverage runs and a
rollup state (AnalysisDataWriter). AnalysisReader answers a request from
those tables -- month rollups for month and year buckets, day rollups
otherwise, at most two partial edge days from consumption -- and
CostReader prices the result month by month. Pages, /api/v1 and the CSV
export read nothing else. The unused continuous aggregates are dropped.

The reader's statement count per request is constant whether it covers one
meter or a thousand. On a synthetic 1,000-meter, ten-year instance the
brief's target request (100 meters, ten years, monthly) takes 374 ms
against a two-second target, and the Overview went from 48,244 SQL
statements per load to 205.

Missing is not zero

Every bucket carries a status -- available, partial, missing, unresolved,
invalid, pending -- derived from coverage, never from the amount, with
provenance and a reason code beside it. A true zero is a number and a bar
on the baseline; an unknown bucket is a gap that says why; a month whose
data only exists monthly says so instead of inventing daily detail; a
scope with no tariff says "not priced" instead of 0. Rows whose interval
closes after now are reported separately rather than counted.

Virtual meters are analysis subjects

A virtual meter stores a canonical definition -- expression over m<id>
references, result kind, unit and cost rule -- validated on save and on
read for syntax, unknown or self references, loops and unit/kind rules.
It is evaluated on read from its sources' rollups over their joint
coverage: a missing source makes the bucket missing, an observed zero is
a valid input, a non-finite result is invalid with its dependency path,
and the page lists each source's contribution. Topology links are
topology only and never rewrite a saved calculation; expression-less
meters from older installs are converted once at startup. The editor has
Sum, Difference and Advanced modes with a live preview.

Totals and the bill

Per energy type the totals policy separates use, grid import, export,
generation and runtime, marks breakdown meters as breakdowns and virtual
meters as views, and never adds across units. The bill follows it: grid
import where there is one, separately priced subsections at their own
price, feed-in only on export meters, standing charges once per scope per
local day, manual costs once on their start day, categories as
non-overlapping covers whose composition reconciles to the bill. The
seeded demo's yearly totals now match the spreadsheet.

Pages and navigation

The period lives in the URL and every page reads the same contract, so a
link, a reload and the browser's Back button keep it. Shared components
carry it: page header with breadcrumbs, period toolbar, theme-aware chart
with an accessible table beside it, metric cards, comparison and
availability states, attention items that each link to the one action
that fixes them. Meter detail leads with an Analysis tab and resolves its
tabs by key; the energy page has Overview, History, Flow and Meters; the
old cost-only Trends page is a general Analysis page over portfolio, type,
category, meter or a meter comparison. Records tabs are paged server-side
instead of showing the latest 200. Everything is English and German,
light and dark, down to 360px.

Some figures change on purpose; docs/RELEASE_NOTES.md lists each one and
what the first start after the update does (it rebuilds all analysis data
before the web server listens). docs/SDD.md and CLAUDE.md describe the
system as it now is.

Tests: 1,733 Core and 746 integration, all green, plus an opt-in
performance suite with a synthetic 1,000-meter generator.
2026-09-20 10:29:13 +02:00

43 KiB
Raw Blame History

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 (M0M7) + the dashboard/analysis rework (next release 0.4.0).

  • Size: five projects, ~2,480 tests (Core 1,733, Integration 746), 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.GetCostSetupAsyncCostSetup.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 15 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

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 <Name> -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 the freshness query (latest 20 reading times per meter, D-18). 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.
    • 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.
    • 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 BrowserPreferencesmetervault.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 (19972004) 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.