Files
MeterVault/CLAUDE.md
T
Florian Schmidt a08e9f781f
ci / build-test (push) Successful in 2m41s
Analysis: read a rarely-read meter as coarse, not absent; newest rows first
Three things a reported Heizoel page got wrong at once. Its tank is dipped
a few times a year and its burner read every few months, which is exactly
the shape the coverage rules had not been walked through.

"No data" for data that exists. A tank books nothing until the next
dipstick closes the interval, so the stretch after the last dipstick is
covered by no run at all, and a bucket no run covers was reported missing.
The burner, whose run reaches into the window, said "only coarser data" --
the honest answer -- so one card claimed there was nothing while the
coverage panel beside it listed years of data. A bucket that no run covers,
no gap overlaps and no opening balance explains now reports the meter's
resolution when its preceding coverage is within one interval of its own
class: it is not silent, it is read rarely. A meter that does book its own
buckets and stops -- a dead hourly source, a sheet asked about a later
month -- still reads missing.

Auto answering twelve months with one bar. Coarse only means "longer than
a month", so a dipstick taken each autumn straddles a New Year as surely
as a month start: coarsening the chart to years bought nothing and cost
every point. The planning resolution now caps coarse at month when a run
crosses a local year edge, and a series that cannot resolve the natural
size no longer coarsens the whole chart -- it is drawn at that size with
its buckets marked, which the chart and table already explain.

A page contradicting itself. The comparison line above the ranking was fed
the leading measure's matched coverage but worded as if it spoke for the
page, directly above a burner row that did compare. It now names the figure
it is about.

Alongside: the "largest changes" ranking no longer drops a meter whose
change is not comparable. It ranks what can be ranked, then lists the rest
with their values and the reason -- the tank had been vanishing from its
own energy type. And every dated table now reads newest first, as lists
are read; charts stay chronological left to right, and the CSV export
stays ascending for spreadsheets.

A-41 to A-43 in the note record the three rules.
2026-09-20 12:44:32 +02:00

44 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,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.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 — 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<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 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.