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.
44 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
What this repo is
MeterVault is a self-hosted, local-first energy & utility metering platform: it ingests meter data from Home Assistant, Tasmota and MQTT on a schedule, stores every reading timestamped and immutable, normalizes it into consumption, and turns it into cost dashboards. Energy types (electricity, water, heating oil, gas, …) and meters are user-defined, never hardcoded.
Status: implemented (M0–M7) + the dashboard/analysis rework (next release 0.4.0).
- Size: five projects, ~2,490 tests (Core 1,733, Integration 753 incl. 2 opt-in performance facts), working Docker deploy.
- Docs:
docs/SDD.mdis 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 indocs/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(+ pureTankLevels),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 theroletoken inMeter.Meta(MeterRoles/MeterMeta). It is saved only throughMeterRoleAssignment, which keeps a role unique per type among meters in service and names the meter it moved from. Nothing is found by name.
- Overview
- 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 throughMeterDraftAnalysis).
- recomputes the meter when mode, baseline, install date, unit, role or tank change (
- 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/settingsis 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".
- The shared
- 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 stampedReadingQuality.Manualand 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) drivesHomeAssistantWorker's REST poll.- With the connector's WebSocket push toggle (
HaEndpointConfig.UseWebSocket),HomeAssistantWebSocketWorkerholds a persistentstate_changedsubscription and ingests in real time. The poll worker skips WS endpoints, so each endpoint is served once. HaWebSocketProtocolis the pure, unit-tested handshake/parse logic.HaConnectionTesterpowers the connector "Test connection" button.
- With the connector's WebSocket push toggle (
- Meter topology & flow: a
MeterLinkis a directedfrom→toedge: 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".
MeterLinkServicechecks for cycles, other types, duplicates and legacy virtual meters inside a transaction withLOCK 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) fromFlowService.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 revertibleimport_batch. The/importpage lists batches, with one-click revert and the meters/categories each batch wrote to. instant_rate:InstantRateNormalizerintegrates 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 noCorrection: nothing reads it.Validategives 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
Manualreadings 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-scopedDraftStoreon dispose, and only Cancel discards it. The way back is a meter id, never a URL, so it cannot redirect off-site. - The Sources tab links each connector to its editor, the source dialog has "Edit connector", and the connector list shows which meters use each connector.
- The Overview shows small setup notes from
DashboardService.GetCostSetupAsync→CostSetup.FirstGap(no meters/tariffs next to the cost card, no categories/members under the composition). They are never a prerequisite for seeing quantities.
- UI language: English + German end to end. See Localization.
- Demo: set
MeterVault__SeedReferenceData=true(compose:METERVAULT_SEED=true) for a one-command populated demo: meters 1–5 Strom (Haus, Netz, Auto, Solar 1, Solar 2), 6 Wasser, 7 Öltank + 8 Brenner (Heizöl), 9 Summe Solar (virtualm4 + 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'sJahreskosten(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-nnthere. - 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 inInfrastructure/Analysis/Costing, and presentation inApp.
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
readingis 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 readUnresolved(A-16). Swaps, resets and decreases are never divided. - Month labels: imported monthly-table rows are month-end snapshots. The importer marks them
ReadingFlags.MonthLabelwhen 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:
EffectiveTimereads a label as the end of its month.ReadingTimeline(Core) orders by effective time, then stamp.CounterNormalizerBaseandRuntimeCounterNormalizeruse it, and so do the ingestion decrease guard,MeterEventService.GetContextAsyncand the manual-entry dialog (viaRegisterNeighbours), 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), andRegisterBoundary.Advancenever counts a start value above the reading.StampTimekeeps a label's row inside its own local month (zones behind UTC; also used byDirectDeltaNormalizer). A non-label row closing exactly at a local midnight is stamped 1 s earlier, inside the day it closes (D-11). - Guards:
NormalizationEnginecoalesces rows that still share a (time, kind) key.GapAttribution.LocalMidnightverifies its answer, so contradictory zone data cannot stall the month walk.GapSplittingIsInertOnFixturesTestspins that no fixture interval is divided, in UTC or Berlin.
- 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
- Everything buckets in the configured
MeterVault__TimeZone: normalization, rollups,AnalysisReader,CostReader, the export. Requested dates become instants throughPeriodResolver/InstanceTimeZone.StartOf(local midnight, never UTC midnight). A hard-coded zone or a UTC-midnight range would re-file the divided shares.Programpost-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:
- Migrations, then the seed.
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".NormalizationUpgrade.app_settingrecordsnormalization_revision(now 3) andnormalization_zone.- Before revision 2 it first flags the month rows of older imports: reference profiles by name, wizard
MonthNamebatches, and wizardAutobatches 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_stateis missing or outdated, plusnormalization_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.
- Before revision 2 it first flags the month rows of older imports: reference profiles by name, wizard
- Until its rebuild runs, a meter reads as
Pending("analysis being prepared"), never "no data". BumpCurrentRevisionwhenever 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.readingis 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 ismeter_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/settingsand 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.
Unitsis the only unit normalizer (m3 = m³). Raw units appear only on the Readings tab.
- runtime:
- 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, defaultEurope/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).tois 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 throughInstanceClock(Now,Today). Services never read the clock (D-01). Tests useFixedTimeProvider. - 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 throughAnalysisReader(quantities) andCostReader(money). Never add a figure that queriesconsumptionorreadingdirectly.AnalysisReader: one request = scope + resolved period +BucketPlan+ comparison.- It loads
AnalysisCatalogonce: 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 fromconsumption, coverage, freshness. - The 400-point and 6-series limits are checked before any SQL runs.
- It loads
CostReader: runsBillRun→ CoreCostCalculatorwith aTariffBook: one catalog, tariff and manual-cost load, plus one reader pass for every meter any figure prices.CostServiceis only the adapter behind/api/v1/consumption|cost.DashboardService.GetMonthlyTrendAsync/GetCategoryBreakdownAsync/GetCategoryDifferenceAsyncandFlowService.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, notMissing, 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 stopsautoat month buckets (ResolutionClassifier.PlanningResolution), and a comparison line on a page with several figures names the one it is about (ComparisonSummary.Subject). - Separate dimensions:
Provenanceflags (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).
- Every bucket has a
- Virtual meters (D-25 – D-33, A-08, A-12, A-15):
- The definition is the
Meter.Metakeysexpression(m<id>references),referencedMeterIds(derived and rewritten on save),resultKind(consumption, generation, net, indicator),resultUnitandcostRule(none, sourceCosts, ownQuantity), written throughVirtualDefinitionJson. FormulaParserbuilds an AST:+ − * /, parentheses, numbers; at most 2,000 characters and depth 64. An unknown identifier is an error, never 0.VirtualValidator+DependencyGraphcheck 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 fromCoverageEvaluator(A-12):- strict: a missing source →
Missingwith the source named; an observed zero is valid; - a non-finite result or a loop →
Invalidwith 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").
- strict: a missing source →
- Legacy meters without an expression are read as their implied link sum with status
LegacyuntilVirtualDefinitionUpgradestores it (LegacyVirtualDerivation). - The editor previews unsaved definitions through
MeterDraftAnalysis. Export/import remaps meter ids inside definitions and carriesmeter_link(D-32).
- The definition is the
- Totals (D-22, D-23):
TotalsPolicy/TotalsGraphclassify each type's meters into the measures Use, GridImport, Export, Generation and Runtime.- Use is the
total_loadmeter, 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|neveroverrides this.alwayslets 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.
- Use is the
- The bill (D-34 – D-43, A-15 – A-19, A-21, A-22, A-26):
- What is billed: per type,
grid_importmeters 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 togrid_exportmeters. 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) vsPriceGap(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
PeriodStartday (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):sourceCostsfor pure sums,ownQuantityfor linear formulas,noneotherwise and for generation sums (so Summe Solar is not costed).
- What is billed: per type,
- Changes (D-06 – D-09, A-10, A-13, A-23):
ComparisonResolvershifts in calendar units (MTD/YTD at the same elapsed wall time) and pairs buckets by index (PairBuckets).MatchedCoveragemeasures a change only over what both periods cover.Change.Betweenalways 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|customwithfrom/to(local, inclusive),bucket=auto|day|week|month|year(≤ 400 points),compare=none|prev-period|prev-year|year:YYYY. - Defaults (
AnalysisDefaults): Overviewmtd, history pages12m(12 buckets ending with the current partial month), alwaysprev-year(A-13). Defaults are never written. - An invalid token falls back to the default with a notice.
allspans availability (D-19). - Page-specific keys:
tab,action, the energy page'sview=total|meters, the Overview'schart=. - 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,TariffLinksandAnalysisNavigation. They carry the period against the target page's defaults.
- Keys:
- 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. Legacytab=consumption→ normalized. Energy tabsoverview|history|flow|meters(AnalysisLinks.ResolveEnergyTab/EnergyTabIndex). One-shotaction=stays separate from the analysis reload. - Page pattern:
AnalysisQuery.Parse(Nav.Uri, Defaults). If equal to the last query, stop: a tab change or action drop is not a new analysis.AnalysisPeriods.ResolveAsync(query, Clock.Now).- Read through a small loader:
MeterAnalysisLoader,AnalysisPageLoader,EnergyAnalysisLoader,DashboardService.GetOverviewAsync,SolarService/ConsumableService.GetAsync. - Build the chart/table inputs inside the load, since they format eagerly and re-key the chart.
- Commit one value through
LoadSequencer.RunAsyncinto aLoadState<T>. Superseded loads are cancelled and never committed. - Render with
LoadPanel: initial placeholder, refreshing (old value dimmed),PanelErrorwith Retry.
- The initial load happens in
OnParametersSet, because render tests read prerendered HTML. Subscribe toNav.LocationChangedfor 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.AnalysisTableis 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 themv-themecookie.App.razorreads 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 onThemeState.Changed. The sidebar's expanded groups use cookiemv-nav(NavGroups). Both are written throughBrowserPreferences→metervault.jssetPreference, which whitelists exactly those two names. - Currency:
InstanceCurrency(MeterVault__Currency, default EUR) andFormat.Money/MoneySigned/CurrencySymbolfor every amount.Format.Eurois 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(frameworkHtmlRenderer),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.
- URL contract and links:
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) andStrings.de.resx. MSBuild generates a strongly-typedStringsclass from the neutral resx (see theEmbeddedResourceblock inMeterVault.App.csproj), aliased asSin_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.
StringResourceTestsfails 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()(partialsDisplayNames.Analysis.cs,DisplayNames.Problems.cs) is the single place that decides how each value is spoken.EnumDisplayNameTestsfails if a value inLocalizedEnumshas 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/setendpoint writes, elseAccept-Language, elseMeterVault__Locale(defaulten). 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 againstCurrentCulture, 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,49cm,2287L) — strip and validate. - Two date formats:
Monat YYYY(German month names, monthly tables) andDD.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_swapevent. - Electricity has 5 meters (Haus, Netz, Auto, Solar 1, Solar 2) plus derived columns. Verified relations to reproduce:
Netz Einsparung = Haus − Netz(a virtual meterm1 − m2, checked throughVirtualEvaluator),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'sKostenis 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
fixedfrom nozzle spec, orempirical= Δlevel ÷ Δhours). Early rows (1997–2004) carry deliveries only (no burner hours yet). Includes cm→litre dipstick calibration and forecast-to-empty.
Git
Remote origin is https://git.finalfactory.de/FinalFactory/MeterVault.git (Gitea; default branch master). CI/release is Gitea Actions under .gitea/workflows/ — edit VERSION on master to tag + publish the image to the Gitea container registry.