Analysis: one selected period, one set of numbers, on every page
ci / build-test (push) Successful in 2m31s

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.
This commit is contained in:
Florian Schmidt
2026-09-20 10:29:13 +02:00
parent c0f52dbb6f
commit 8940ef25c3
384 changed files with 82753 additions and 4518 deletions
+710
View File
@@ -0,0 +1,710 @@
# Analysis rework: implementation note
Companion to [DASHBOARD_ANALYSIS_CHANGE_BRIEF.md](DASHBOARD_ANALYSIS_CHANGE_BRIEF.md). This is the Phase 1
deliverable that resolves the brief's open choices. Every decision has an ID (D-nn) so code, tests and the final
report can refer to it. Written against `c0f52db`; revised after an adversarial design review.
The note was kept current through Phase 5:
- §11: amendments from the Phase 1 module review.
- §12: amendments from the acceptance review.
- §13: decisions recorded with the final documentation.
- §9 and §10: extended with what the implementation measured and changed.
The outcome is in [ANALYSIS_REPORT.md](ANALYSIS_REPORT.md), the user-facing changes in
[RELEASE_NOTES.md](RELEASE_NOTES.md).
## 1. What the code review established
The brief's findings A01A14 are confirmed by the source, with these refinements:
- **Nothing evaluates a virtual meter.** `VirtualNormalizer` and `ExpressionEvaluator` run only in tests.
`NormalizationService` skips virtual meters. The only value in production is `FlowService`'s sum of
incoming links, which ignores any stored formula. SDD §14.1 is not implemented in either direction.
- **The expression evaluator is unsafe for user input.** It has no AST, unknown identifiers evaluate to 0, and
recursion is unbounded.
- **Consumption rows store only the interval end.** There is no unit column, and `Estimated` covers four
different cases. Coverage cannot be recovered from sums; it has to be captured during normalization.
- **Month division only exists for cumulative and generation counters.** Tanks, runtime, direct-delta and
instant-rate modes book a whole interval at its end. The same is true of swaps, resets, decreases and first
readings.
- **The continuous aggregates cannot be used:** they are Berlin-only, materialized-only on TimescaleDB ≥ 2.13,
and never backfilled. No reader uses them, yet they are refreshed hourly.
- **Raw retention is not implemented.** Turning it on would destroy history, because every recompute rebuilds a
meter from the readings that remain.
- **The seeded costs are wrong in a way the spreadsheet proves.** The sheet bills `Kosten = Netz × price`, but
the seed prices Haus + Netz + Auto. The seed is also missing the water price rise to 7.00 €/m³ in 2026.
- **The tooling gaps are real.** There is no clock abstraction, no bUnit or Playwright, and the API responses
have no contract tests.
## 2. Periods and comparisons
- **D-01 Clock.** `TimeProvider` is registered. Pages and API endpoints read "now" once per request and resolve
a period with the pure resolver, then pass the resolved period down. Services never read the clock. Where one
genuinely needs "now" (freshness, forecast), it takes a trailing optional `TimeProvider? time = null`. Tests use
a small `FixedTimeProvider`.
- **D-02 Presets and URL tokens.**
- `period=mtd|last-month|ytd|prev-year|12m|24m|all|custom`, with `from`/`to` (yyyy-MM-dd) used only for
`custom`.
- Overview default: `mtd`. History pages default: `12m`, which is 12 calendar buckets ending with the current
partial month.
- `all` spans the availability metadata (D-19), not a fixed century.
- A page default applies only when no period key is present. An invalid token falls back to the default and
shows a notice.
- **D-03 Bounds.**
- A period resolves once, in the instance zone, into two forms: a local inclusive date range for display, and
a half-open UTC range `[from, to)` for queries.
- `to` is the local midnight after the end date, or the captured "now" for to-date periods.
- Quantities, costs, comparisons and exports all use the same bounds.
- **D-04 Now and the future.**
- Actual figures stop at "now". A row counts as recorded after now when its source interval ends after now.
Examples: a current-month label row, a future-stamped Tasmota row.
- Such rows are excluded from actuals and reported in a separately labelled "recorded after now" block (rows,
amount, dates), with an attention item.
- A range entirely in the future reports "not yet occurred".
- **D-05 Buckets.**
- Buckets are `day|week|month|year|auto`. Weeks start on Monday in local time.
- `auto` picks one bucket for the whole chart: the coarsest resolution any plotted series needs, at most 400
points.
- An explicit bucket above 400 points is refused with a coarser suggestion.
- Bucket bounds are local midnights, clipped to the period.
- **D-06 Comparisons.**
- `compare=none|prev-period|prev-year|year:YYYY`. `year:YYYY` needs a year-aligned range.
- Shifting uses local calendar units, never durations:
- Whole years shift by years, and whole months by months. Anything else shifts by days.
- `12m`/`24m` compare with the N months before. `all` has no comparison.
- The cut-off maps as local date plus wall time:
- A nonexistent time takes the first valid instant after the gap.
- An ambiguous time takes the occurrence with "now"'s offset if one matches, otherwise the first occurrence.
- A day that does not exist in the target month (31st, 29 Feb) cuts at that month's end.
- **D-07 Matched coverage.**
- A change figure is "confident" only over the range both periods actually cover. The current period's covered
range is shifted, intersected with the comparison's coverage, and trimmed to whole buckets where resolution is
coarser than the cut.
- Both requested ranges and the matched range are shown.
- An empty match means "not comparable": absolute values only, no percentage.
- **D-08 Change figures.**
- The absolute difference is always shown.
- The percentage is "not applicable" when the baseline is ≤ 0 or unavailable.
- Colours depend on the metric: more consumption is not "good", more generation is.
- **D-09 Projections** are separate and labelled "Projection (straight-line from N days)".
- Method: the covered rate × the remaining days. Standing charges are added exactly per day.
- A projection is suppressed when:
- coverage ends more than 2× the meter's typical interval before now;
- covered elapsed time is under 7 days (month) or 30 days (year);
- the resolution is coarser than the period.
- A change chip never compares a projection with an actual.
## 3. Data layer
- **D-10 Engine intervals.** Every `Consumption` row carries its source interval: `IntervalStart`,
`IntervalEnd` and `Divided`, as EF-ignored properties, so the schema does not change. Each mode sets them:
| Mode | Interval |
|---|---|
| Counters | previous effective reading → this one, with `GapSegment` bounds for divided shares |
| Runtime | previous effective time |
| Tank | previous TankLevel event |
| Instant rate | previous sample |
| Direct delta | previous reading, or the labelled month for a label |
| First reading | the labelled month for a label; `[InstalledAt, t]` when set; otherwise an unknown start (D-14) |
`Coalesce` keeps the minimum start and the maximum end.
- **D-11 Midnight stamps.** A non-label row whose interval end falls exactly on a local midnight is stamped
1 second earlier, inside the day it describes, mirroring `InsideSegment`. Label rows keep `StampTime`.
`[from, to)` stays everywhere.
- **D-12 Tables.** These are plain tables, not hypertables. Each has an FK to `meter` with `ON DELETE CASCADE`.
They are written by `RecomputeMeterAsync` in the caller's transaction, by diff, so only changed rows are
touched.
| Table | Key | Columns |
|---|---|---|
| `consumption_rollup` | `(meter_id, day, kind)` | `amount`, `measured`, `manual`, `imported`, `estimated`, `rows`, `flags` (baseline-delta, divided) |
| `consumption_rollup_month` | `(meter_id, month, kind)` | same columns; month and year reads use it |
| `meter_coverage` | `(meter_id, span_from)` | `span_to`, `resolution_class`, `divided_at_months`, `gap_reason` |
| `meter_rollup_state` | `(meter_id)` | `revision`, `zone`, `normalized_unit`, `kind`, `built_at` |
All local dates use the configured zone.
- **D-13 Coverage runs.**
- Consecutive intervals of the same resolution class merge into one run. The classes are ≤ 1 h, ≤ 1 day,
≤ 7 days, ≤ 1 local month, and coarser.
- An interval longer than a month is its own run.
- These open a gap instead of coverage:
- an unexplained decrease;
- a reset without PrevValue;
- an instant-rate gap longer than max(1 h, 10 × the median sample interval);
- deliveries before a tank's first level.
- Coverage is capped at now.
- **D-14 Bucket status.**
- **missing:** no run overlaps the bucket.
- **partial:** runs cover only part of it.
- **unresolved:** an undivided interval crosses the bucket edge by more than 5 % of the bucket length. The only
exception is an edge at a local month boundary the normalizer divided at.
- **available:** everything else. An available bucket with no rows is a true zero.
- **Opening balance:** a first reading with unknown start marks its bucket "partial (opening balance, start
unknown)". It is excluded from comparisons and projections, and the UI offers to set an install date.
- Provenance is a separate dimension, derived from the per-quality amounts plus `derived` for virtual meters.
- **D-15 Reading a period.**
- Rollups (month table for month/year buckets, day table otherwise) cover complete local days.
- For at most two partial edge days per range, one direct `consumption` query covers
`[edge-day midnight, cutoff)` (`meter_id = ANY(@ids)`).
- Each request makes one query per table and one tariff load. Virtual dependencies are expanded in memory
first. The 400-point and 6-series limits are enforced before any SQL runs.
- **D-16 Rebuild.**
- `CurrentRevision` becomes 3, because the engine books differently (D-11, intervals).
- The startup upgrade rebuilds consumption, rollups and coverage, and records `meter_rollup_state`.
- A meter whose state is missing or outdated (revision, or zone ≠ the reader's zone) reads as "analysis being
prepared", never as "no data".
- The migration purges derived rows of virtual meters. `RecomputeMeterAsync` purges them if a meter becomes
virtual.
- The upgrade skips and logs any meter whose oldest consumption predates its oldest reading or event, instead
of truncating history (D-44).
- **D-17 Continuous aggregates.** The new migration removes their policies and drops the three views.
`Monthly_continuous_aggregate_refreshes_and_matches_base` is replaced by a rollup-equals-consumption test
(water Dec 2022 = 14 m³).
- **D-18 Freshness.**
- The last reading or event time is the freshness mark.
- A live source is stale when that time is older than the larger of 3 × the median of its last 20 intervals
and 3 × its poll interval.
- Import-only meters are "historical", never "stale".
- **D-19 Availability.**
- A quantity scope's availability is its coverage.
- A cost scope's availability is its billed meters' coverage plus its manual costs' `PeriodStart` days.
- Both are capped at now.
- "Latest period with data" is the latest local month ≤ now in that union. It is returned with its month and
basis (meters / manual / both).
## 4. Quantities, units and totals
- **D-20 Normalized quantity.** A Core function `NormalizedQuantity(meter, tank, definition)` returns
`(kind, unit)`.
- Kinds: consumption, generation, export, runtime, and for virtual meters also net or indicator.
- Units by mode:
| Mode | Unit |
|---|---|
| RuntimeCounter | `h`, or the tank unit with a Fixed rate (kind stays runtime; provenance estimated) |
| InstantRate | the rate unit without `/h` (W→Wh, kW→kWh) |
| ConsumableBalance | the tank unit |
| Virtual | its declared result unit |
| Others | `Meter.Unit` |
- Aliases are normalized (m3 = m³).
- The normalized unit is stored in `meter_rollup_state`. Raw units appear only on the Readings tab.
- **D-21 Roles.** `total_load`, `grid_import` and `grid_export` are unique per energy type; saving a role moves
it and says who held it. The editor shows localized names and one-line meanings, and offers each role only for
compatible modes.
- **D-22 Per-type totals algorithm.** Pure and ordered:
1. **Supply meters** are grid_import or grid_export meters, GenerationCounter meters, and generation-kind
virtual meters. A link out of a supply meter is a *supply* edge and never makes its target a submeter.
2. **Containment.** A link from a physical, consumption-kind, non-supply meter makes the target a breakdown of
its parent.
3. **Measures per type:**
- *Use* is the total_load meter if there is one, otherwise the consumption roots. Consumption roots are
physical consumption-kind meters that are not supply meters, have no containment parent and are not
runtime meters. Tanks count.
- *Grid import* is the grid_import meters.
- *Export* is the grid_export meters, which are never consumption.
- *Generation* is the GenerationCounter roots.
- *Runtime* is the runtime meters.
4. Measures are never added across units.
5. Virtual meters are analysis views. Retired meters keep their history.
6. Seeded result: use = {Haus}, breakdown = {Auto}, grid import = {Netz}, generation = {Solar 1, Solar 2},
analysis-only = {Summe Solar}, runtime = {Brenner}, water use = {Wasser}, oil use = {Öltank}.
- **D-23 Override.**
- `Meter.Meta.totals` is `auto|always|never`. `always` on a virtual meter replaces its expanded dependencies in
that measure and in the bill. `always` on a meter whose ancestor or dependent is already counted is refused
on save, naming the other meter. `never` removes a meter from the measures it would join.
- The resulting cover is shared by the quantity totals and the bill.
- **D-24 Lifecycle.** Outside `[InstalledAt, RetiredAt]`, when set, a meter contributes a known zero to totals
and to virtual evaluation.
## 5. Virtual meters
- **D-25 Definition.**
- `Meter.Meta` holds `expression`, `referencedMeterIds` (always derived from the expression and rewritten on
save), `resultKind` (consumption|generation|net|indicator), `resultUnit` and
`costRule` (none|sourceCosts|ownQuantity).
- Topology links never define a calculation.
- **D-26 Formula.**
- The grammar is the existing one (`+ - * /`, parentheses, numbers). It is parsed to an AST, with limits of
2,000 characters and nesting depth 64.
- References are `m<id>`; any other identifier is invalid.
- Validation on save and on read covers: syntax, unknown or self references, cycles through nested virtual
meters (with the path), and kind/unit.
- Kind/unit rules for operands:
- `+`/`` need the same unit and kind, or a declared `net`.
- Meter × or ÷ meter needs a declared `resultUnit` and kind `indicator`.
- Indicators are non-additive, never totalled and never costed.
- **D-27 Evaluation.**
- Evaluation runs on read, per bucket, from the sources' rollups.
- A virtual meter's coverage is the intersection of its sources' coverage, and its resolution is the coarsest
among them.
- A missing source makes the bucket missing (strict); an observed zero is a valid input. A non-finite result
makes it invalid, with the reason.
- A period total is the formula applied to the sources' totals over the joint coverage. It is partial when that
coverage is smaller than the period. For a linear formula without a constant this equals the sum of its
buckets; otherwise the series is marked non-additive.
- The result carries every source's series, status and dependency path.
- **D-28 Legacy definitions.**
- At startup, an expression-less virtual meter whose same-type incoming links name sources of one unit and
kind gets the equivalent explicit sum. Meters are processed in dependency order, the run is idempotent, and
the counts are logged.
- Anything else is flagged "needs configuration".
- Until converted, the reader evaluates the implied sum with status "legacy — confirm".
- `ReferenceDataImporter` writes Summe Solar's definition directly: `m(Solar 1) + m(Solar 2)`, generation,
kWh.
- **D-29 One evaluator.** `VirtualNormalizer` is removed from `NormalizationEngine.CreateDefault`. Its tests and
the golden Netz Einsparung reconciliation move to the new evaluator over month buckets (≥ 20 matches, ±1 kWh).
- **D-30 Flow.**
- A pure-sum virtual meter's incoming edges are its calculation dependencies, drawn at each source's own value
and marked "calculated".
- Other virtual meters appear only in the table view.
- Links are capped at the parent's value, and proportional splits are marked estimated.
- The Flow tab gets "Manage connections".
- **D-31 Editor.**
- Sum, Difference and Advanced modes, with source pickers by name (showing unit, kind and install/retire
dates) and a live preview for the selected period.
- On a virtual meter's page, a Calculation tab replaces Sources. Register details and Readings are removed.
Events keeps Note.
- Saving a Sum offers to sync the incoming links.
- **D-32 Export/import** carries `meter_link` and remaps meter ids inside definitions.
- **D-33 Deleting a meter** lists the virtual meters that depend on it and requires confirmation.
## 6. Costs
- **D-34 Billing.**
- Per energy type, the grid_import meters are billed if the type has one, otherwise the *use* meters (D-22).
- Generation meters are never billed.
- The feed-in credit is the FeedIn price × the export of grid_export meters.
- Runtime and virtual meters are not billed unless D-39 applies.
- **D-35 Separately billed submeter.** A containment child with an applicable meter-scoped UnitPrice is billed at
its own price. Its monthly quantity is subtracted from its billed ancestor's for pricing. Quantity totals do
not change.
- **D-36 Prices.**
- The monthly convention is kept: the price valid on the 15th of each local month.
- Every bucket size is priced month by month, so week and year buckets are split by local month. Changing the
bucket never changes a total.
- **D-37 Tariff applicability.**
- A UnitPrice or FeedIn tariff applies only when the unit denominator matches the meter's normalized unit.
Known scales are converted (ct, per 100 L, per MWh).
- A parsed mismatch makes the cost "unavailable (unit)". An unparseable unit applies, with a warning.
- BasePrice units are per day, per month (the default) or per year.
- The tariff editor validates units on save and states that Bonus, Discount and Tax are not applied yet.
- **D-38 Coverage.**
- A billed scope with no UnitPrice tariff at any date is "not priced (no tariff)". That is an attention item,
not "unavailable".
- A gap inside a priced scope's tariff history makes the cost "unavailable" for those months.
- An explicit zero tariff is a valid zero.
- A missing FeedIn price is reported only where a grid_export meter exists.
- **D-39 Virtual costs.**
- `sourceCosts` adds the sources' metered costs. It is allowed only for pure sums and excludes scope-level
standing charges.
- `ownQuantity` prices the virtual quantity with normal precedence. It is allowed only for linear formulas
without a constant.
- The default is `sourceCosts` for pure sums and `none` otherwise. The rule is named next to every virtual
cost.
- A virtual meter is part of the bill only through D-23.
- **D-40 Standing charges.**
- A standing charge accrues per local day, at value ÷ days in that local month, over the scope's service
period. The service period runs from the earliest InstalledAt or first data to the latest RetiredAt or now,
regardless of reading gaps.
- Meter-scoped charges stay on their meter.
- Type- and global-scoped charges are their own rows ("Standing charge — <type>" / "— global"), never split
across meters.
- **D-41 Manual costs** are booked in full on their `PeriodStart` local day, when that day is in `[from, to)`
and ≤ today. `PeriodEnd` is informational. A cost with `MeterId` set goes to that meter's categories.
- **D-42 Categories.**
- A category's cost is the priced cost of the non-overlapping cover of its members, using the bill algorithm
restricted to them, plus its manual costs. For example, Strom {Haus, Netz, Auto, Solar 1, Solar 2} gives
Netz × price, and a category {Auto} gives Auto × price.
- A type- or global-scoped standing-charge row joins a category only if the whole type (or, for global, every
billed meter) is a member.
- The composition is the disjoint categories, plus Uncategorized, plus standing-charge rows, and it reconciles
to the bill.
- A category that overlaps another, or covers meters outside the bill, is an "overlapping view" and stays
outside the composition.
- The donut is drawn only when every slice is ≥ 0; otherwise signed bars are used.
- **D-43 Currency.** `MeterVault__Currency` is used everywhere through one `Format.Money`.
- **D-44 Seed.**
- The water tariff 7.00 €/m³ from 2026-01-01 is added.
- Summe Solar gets an explicit definition.
- The seed tariffs are otherwise unchanged.
- Golden: the seeded yearly bill equals the sheet's `Jahreskosten` within ±0.02 € for 2022 (421.52),
2025 (7,907.64) and 2026 (2,940.19). This is computed on a frozen clock after 2026-05-31, with the tank
unpriced.
- 2023 and 2024 differ by 3.78 € and 0.46 €, because the sheet rounds its displayed prices. That is documented
and not tuned away.
## 7. API
- **D-45 Compatibility.**
- Contract tests for `/consumption`, `/cost` and `/dashboard/summary` are written before anything is rerouted.
- Every existing field and type is kept.
- `/consumption` and `/cost`:
- They keep exact-instant bounds, now converted with `ToUniversalTime()`.
- `cost` stays numeric (0 when nothing is priced), and `costStatus` and `missingPrices[]` are added.
- Virtual meters return evaluated values with a status.
- `/dashboard/summary`:
- It keeps its calendar month and year windows (legacy semantics, documented) but uses the new billing set.
- It adds `deltaPercentApplicable` and `latestMonth`.
- The numeric change is listed in the release notes.
## 8. Pages, navigation, state
- **D-46 URL state.**
- The query parses into an immutable `AnalysisQuery` value. Analysis reloads only when that value changes,
with a generation counter and cancellation.
- An action drop or tab change never reloads it.
- Toolbar and tab changes replace the history entry; drill-downs push. Defaults are never written on load, so
a deep-linked dialog is not dismissed.
- Initial loads stay in `OnInitialized`/`OnParametersSet`, because render tests read prerendered data.
- **D-47 Keys.**
- Meter tabs are `analysis|readings|normalized|events|tariffs|sources|calculation`. Legacy `consumption` maps
to `normalized`. On virtual meters, `sources` maps to `calculation` and `readings` to `analysis`.
- Energy-type tabs are `overview|history|flow|meters`.
- Analysis page scope is `scope=portfolio|type|category|meter|meters` with `id`/`ids` (at most 6), plus
`metric=consumption|generation|export|runtime|net|cost|balance`.
- Link helpers append the new keys after the existing ones.
- **D-48 Navigation.**
- Sidebar entries: Overview, Analysis, Meters, Energy types (with a retry item on error), Specialized views
(Solar, Tanks & consumables — always listed, with a setup state when unsupported), Data import,
Configuration.
- Expanded groups persist in a cookie. `NavState` gains `MetersChanged`.
- Breadcrumbs (Overview → type → meter) carry the period, and Back returns to the parent.
- Search shows a text label from the md breakpoint up, and its results link to Analysis (with the period) plus
quick entry.
- **D-49 Theme.**
- A scoped `ThemeState` is backed by a cookie that App reads, so prerender and the language switch keep the
mode.
- Charts use a transparent background and the theme's mode, and are re-keyed on theme or result change.
- Units and currency go into JS formatter strings. Points are nullable; there is no smoothing and no joining
across gaps.
- **D-50 Tables.**
- Readings, Normalized data and Events are paged server-side (100 rows), keyset-ordered, with `from`/`to`
filters.
- The manual-entry dialog runs its own queries (latest reading, reading at T with flags, boundaries), so its
verdicts never depend on a page of rows.
- **D-51 Drill-down.** Clicking a chart bucket keeps the scope, sets the bucket's range and the next finer
supported bucket. If there is none, it opens Normalized data filtered to that bucket.
- **D-52 Deep links.**
- Tariffs: `/admin/tariffs?scope=&id=&component=&from=&action=new` opens a pre-filled new-tariff dialog.
Missing-cost explanations link there with the first uncovered month.
- **D-53 Attention items.** Missing required price (scope and first month), stale live source, invalid or
unconverted virtual definition, recorded-after-now rows, possible overlap (a total_load and a grid_import root
that are not linked).
- **D-54 Solar and consumables.**
- Both adopt the shared toolbar, cards and charts. Units come from D-20.
- Solar shows a setup card for each missing role.
- Tanks show "Last dipstick: <value> on <date>" separately from "Estimated now (incl. deliveries since)". For a
historical range they show the balance at the range end. Deliveries are filtered to the range.
- The forecast is suppressed when the dipstick is older than 60 days.
- **D-55 CSV export.** The analysis table as CSV: one row per bucket and series, with local ISO bucket bounds,
timezone, invariant numbers, empty cells for unavailable values, and status, provenance, cost, cost status,
currency and the comparison value. Served by an App endpoint that takes the same URL keys.
## 9. Evidence, limitations, deviations
- **D-56 Evidence.**
- Frozen-clock tests cover New Year, Berlin DST in spring and autumn, 29 Feb, 31 Jan → Feb, and New York.
- Seeded goldens: D-44, the D-22 classification, and water Dec 2022 = 70 € / 14 m³.
- Worked virtual examples: A+B, AB, missing vs zero, nested, cycle, division by zero.
- A synthetic generator (test trait) for 1,000 meters × 10 years with recorded timings.
- Screenshots and a manual checklist (EN/DE × light/dark × 360/768/desktop) from the seeded instance. No bUnit
or Playwright is added.
- *As implemented:*
- The frozen-clock, seeded-golden and worked-virtual suites exist as planned. `docs/ANALYSIS_REPORT.md` lists
them with counts. At the end: Core 1,733 tests, Integration 746.
- The synthetic generator and timings are `tests/Integration.Tests/Performance` (trait `Category=Performance`,
skipped unless `METERVAULT_PERF=1`).
- The manual checklist ran as Chrome DevTools Protocol scripts against seeded instances: four acceptance
reviewers plus the page agents, in EN/DE, light/dark, at 1440/390/360 px. Those scripts and the screenshots are
outside the repository. Server-rendered pages are covered by `HtmlRenderer`-based render tests in EN and DE.
- **D-57 Limitations.**
- Raw retention is not implemented; `/admin/settings` labels it "not enforced", and the Readings tab explains
it. The brief's "Retained history" scenario is a documented blocker.
- Monthly imports are never interpolated to days (SDD §8.7 / §14.2 unchanged).
- A full recompute still runs per live reading.
- Bonus, Discount and Tax tariffs are not applied.
- *Measured and found later (see `docs/ANALYSIS_REPORT.md`):*
- The per-reading recompute is linear in a meter's reading count: ~0.1 s for a monthly meter, ~1.4 s for a year
of hourly data. The startup rebuild is ~20 % slower per meter than in 0.3.0.
- The freshness query (`AnalysisQueries.RecentReadingsAsync`) has no time bound, so it plans across every raw
chunk.
- The window-sum query (`AnalysisQueries.WindowSumsAsync`) gets no plan-time chunk exclusion.
- Both grow with history length, and neither is fixed.
- The billing basis cannot change month by month (A-17).
- Batteries are not modelled in Solar's calculated feed-in.
- The Solar page has no CSV export, because the export has no derived measures.
- **D-58 SDD deviations.**
- §14.1: virtual meters are computed on read, and nothing is materialized.
- §5.4 / §10: the configured zone is used, and rollups replace the continuous aggregates.
- §8.1: the Overview shows one selected period.
- §7.4: prices inside expressions are not supported; the `ownQuantity` cost rule covers savings.
- *Also marked in the SDD at the end of the rework:*
- §3 (FR-9, FR-11, FR-12, FR-16), §4.1 / §4.2 (no aggregates, the two-reader pipeline) and §5.1 (the new tables).
- §5.5 (raw retention not enforced, D-57).
- §7.1 (revision 3), §7.3 (tank "now" vs period), §7.5 (the bill, D-34 D-43).
- §8.0 (the shared contract), §8.2 §8.7 (the pages), and the monthly-history note (no interpolation).
- §9 (additive API fields, D-45, A-21), §10 (half-open bounds, `TimeProvider`), §11 §13 (layout, milestones,
tests) and §14.1 §14.4 plus the new §14.9 §14.14.
- Appendix B (Ersparnis is not an expression).
## 10. Deliberate behaviour changes
| Area | Old | New |
|---|---|---|
| Seeded Strom bill | Haus + Netz + Auto priced | Netz (grid import) billed; matches the sheet |
| Feed-in | credited on all generation | credited on grid_export only |
| Missing tariff | cost 0 | not priced / unavailable (D-38) |
| Standing charge | per meter per month with data | once per scope, per day of service |
| Midnight readings | booked in the next day | booked in the day they close (D-11) |
| "Last 12 months" | 1314 buckets, including a future month | 12 buckets, actuals up to now |
| Virtual meters | no analysis; flow sums links | full analysis from the formula |
| Overview "this year" | full year vs complete previous year | selected period vs matched coverage |
| Currency | hard-coded € | configured currency |
| Continuous aggregates | refreshed hourly, unused | dropped |
| API | — | additive fields only; the summary's values follow the new bill |
Added as the amendments and pages landed (the release notes, `docs/RELEASE_NOTES.md`, list them for users):
| Area | Old | New |
|---|---|---|
| Year and week buckets | a year priced at the 1 July price, a type/global base price per meter and bucket | every bucket priced month by month at the price of the 15th (D-36) |
| Separately priced subsection | added on top of its parent | billed at its own price, out of its parent (D-35, A-19) |
| Manual costs | in the summary but not the trend; a cost later this month counted at once | once, on its start day, once that day has come, everywhere (D-41) |
| Categories | sum of member meters' costs | priced non-overlapping cover; overlapping categories are views (D-42) |
| Intervals longer than a month (tank, runtime, direct delta) | booked whole in the later month, zeros between | months "only coarser data"; longer buckets priced when the months share one price (A-16) |
| Months without a grid meter in service | — | cost unavailable, with an attention item (A-17) |
| Meter fee on a meter no line prices | per meter and bucket | its own standing-charge row (A-18) |
| Rows recorded after now | counted in to-date totals | reported apart; a day holding one reads partial (D-04, A-14, A-20) |
| Summe Solar / generation sums | not costed (no analysis) | analysed; cost rule `none` (A-15) |
| Percentage against a negative baseline | divided by its absolute value | not applicable (D-08) |
| `/api/v1/cost` of generation, runtime, invalid meters | `Priced` (a generation meter could carry a negative feed-in cost) | `NotPriced` with `costRule`/`notCosted` (A-21) |
| `/api/v1/consumption` months without data | 0 | left out (only rows holding quantity data) |
| Deleting a meter or energy type | its scoped tariffs stayed behind and could be restored onto another meter | deleted with it (`EntityDeletion`); export/import skips such orphans (A-37) |
Tests rewritten on purpose (none weakened; each rewrite states the new rule):
- `MeterPeriodServiceTests`: deleted with `MeterPeriodService`. Its cases moved to `MeterAnalysisLoaderTests`, with
the virtual-null case replaced by positive and error-state cases (the worked A+B example, missing vs zero, an
invalid calculation).
- `FlowServiceTests.Virtual_sum_meter_aggregates_its_upstreams` became `Virtual_sum_meter_is_its_formula`, plus
legacy, non-sum, capped-link, missing-sub-meter, other-unit and after-now cases.
- The CAgg test became `CostReconciliationTests.Monthly_rollup_equals_the_consumption_it_sums`. `SchemaTests` now
pins that the aggregates and their jobs are gone.
- `FormatCultureTests`: the currency case became `Money_is_in_the_configured_currency_written_the_readers_way`, plus
formatter cases.
- `LocalTimeEntryTests.Tab_keys_map_to_panel_indexes` became `Tab_keys_resolve_by_key_and_mode`.
- `VirtualMeterTests` and `ElectricityReconciliationTests.Netz_einsparung_virtual_matches_the_sheet` now run through
`VirtualEvaluator` (D-29).
- `ExpressionEvaluatorTests`: deleted with the evaluator. `FormulaParserTests` pins that unknown identifiers are
errors.
- `DashboardRenderTests`: the literal labels of the old pages, replaced by assertions on the new ones in EN and DE.
- `DashboardServicesTests`: four Solar and tank tests moved to `Specialized/` with the new services' assertions.
- `AnalysisChartModelTests`: a missing bucket is marked "", not "*" (A-28). `MeterAnalysisLoaderTests` reads the
cost change's new type (A-23). `AttentionItemsTests` has the specific duplicate-role text.
## 11. Amendments after the Phase 1 module review
These refine the decisions above where the independently built Core modules met.
- **A-01 Opening balance.** An opening balance is never a coverage run. The rollup day it is booked in
carries a baseline-delta flag, which the coverage evaluator takes as an input. Matched coverage gets the
booked stamps of opening-balance rows. `CoverageGapReason.OpeningBalance` is removed.
- **A-02 DividedAtMonths.** This is true when no interval of a run straddles a local month boundary undivided.
An interval is either divided at month boundaries, or lies inside one local month. A zero increase across a
month boundary counts as divided, because a register that did not move is exactly 0 in every month.
- **A-03 Divided intervals** are classified at most `Month`: they are never coarser for month and year
buckets. Runs are rejoined by source interval identity, not by adjacency.
- **A-04 Capping at now.**
- Stored runs are uncapped and carry `LastIntervalStart`. A reader caps a run at now, but when now falls
inside the run's final interval, the run ends at `LastIntervalStart`. That final interval's row is
recorded after now (D-04).
- A to-date bucket counts as fully covered when its coverage reaches within one interval of the run's class
of its end. The consumption since the last reading is not yet known, and the bucket is not reported
Partial for that reason.
- One module owns capping: `CoverageRuns.CapAt`.
- **A-05 Recorded after now (Phase 2).** Each rollup day stores the latest interval end among its rows. A day
whose rows end after now is reported as recorded after now, not as an actual.
- **A-06 Auto bucket** is chosen from the period's nominal range (ytd → month, mtd → day), so one URL renders
the same way all year. The point limit is checked on the elapsed range.
- **A-07 Roles.**
- Analysis reads roles only through `MeterRoleRules.Effective`: case-insensitive, and only for modes that
may hold them.
- Virtual meters never hold a role.
- A role is unique among meters that are not retired. A retired meter keeps its role for its history, and
each meter counts only within its own service period (D-24).
- **A-08 Virtual result kinds** are consumption, generation, net or indicator, nothing else. Save writes the
effective (inferred) kind, unit and cost rule, so readers never re-infer them.
- **A-09 One classifier, one unit table.**
- The resolution classifier lives once, in `Coverage`.
- `Units` (Quantities) is the only unit normalizer, and every module uses its comparer.
- **A-10 Comparison mapping details (D-06).**
- A range starting on a day the target month lacks (30 March → February) starts at the end of that month,
so a range starting 30 March matches from 1 March.
- A "now" in the second pass of an autumn fold maps to the end of the fold when the target date has no fold,
which keeps the mapping monotonic.
- Comparison buckets are paired with the current buckets by index (`ComparisonResolver.PairBuckets`), never
planned separately.
- **A-11 A separately connected heat pump** (its own supply, not below the main meter) is modelled as its own
energy type with its own grid_import meter. Containment children with their own price remain D-35.
- **A-12 Joint coverage.** The reader derives each virtual source's per-day coverage and bucket states from
`CoverageEvaluator`, and feeds them to `VirtualEvaluator`. The evaluator does not re-derive coverage rules.
- **A-13 Default comparison.** D-06 lists the comparisons without fixing a default. Every page compares with the
previous year when `compare` is absent (`prev-year`): the Overview's month to date with the same elapsed days a
year earlier, a history page's last 12 months with the 12 months a year before. Compared with the period just
before, a seasonal utility would show the season as a trend. Like every default it applies only to an absent key
and is never written into a URL; `compare=none` and `compare=prev-period` remain one click away.
- **A-14 Capping inside an earlier interval (A-04).** When now falls inside an interval that is not the run's last
(two readings stamped ahead, a reading stamped weeks ahead whose month shares are several intervals, a sheet row that
carries the current month's register into a later month), the stored run does not say where that interval starts.
The run then ends at the earliest instant it can start: the later of the local month start of now and now minus one
interval of the run's class; a coarse run ends where it starts. Such a run is always divided at months, because an
undivided interval across a month edge is its own run. So coverage never claims time whose row is recorded after
now: a label run gives up exactly the current month, finer data at most one interval. The shares of such an interval
that closed before now stay actuals, as A-05 reads interval ends per share.
- **A-15 Virtual source costs (D-39).**
- `sourceCosts` adds, for each physical source, what that source's own scope costs: a consumption source at its unit
price (or its bill line), an export source as its feed-in credit, a generation or runtime source nothing. A sum
whose sources price nothing is not costed, and the reason is named (generation, runtime).
- The sources come from the formula's weights, through nested pure sums, each once: `m1 + m1 - m1 + m2` is m1 and m2.
- A generation sum defaults to `none`, because generation is never billed (D-34). The seed and the legacy derivation
write the default, so Summe Solar is stored with `none`. A stored `sourceCosts` on a generation sum costs nothing.
- A sum over a nested calculation that is not a pure sum is not a sum of metered costs. Its default is `none`. A
stored `sourceCosts` stays valid for the quantity but is taken as `none` on read (not costed: "a source calculation
is not a plain sum"), and is reported as `CostRuleNeedsPureSum` for the editor to refuse on save
(`VirtualValidation.CostRuleProblem`, `IsSavable`).
- **A-16 Intervals longer than a month (D-36).** Pricing month by month left a meter whose reading intervals span
several months (a tank dipped every few months, a quarterly delta, burner hours read quarterly) without a cost at
every bucket size. When a bucket of several months holds an unresolved month, the cost engine also reads the bucket
whole. If every month the bucket has data in has the same price (the same tariff outcome and converted unit price,
D-37), the bucket costs its quantity at that price. A price change inside it leaves it unavailable, with an attention
item naming the meter and the months. Month buckets stay unknown; year buckets and period totals are priced, and the
bucket size still never changes a total. The legacy adapters no longer turn such an unknown cost into a priced 0:
`ConsumableSummary.CostKnown`, `MeterPeriodView.YearToDateCostKnown`, and `costAvailability` on `/api/v1/cost`
(additive, D-45). The dashboard summary keeps D-45's numeric legacy windows.
- **A-17 Months without a grid meter (D-34 with D-24).** The billing basis is chosen per energy type for all time. In
a month where no billed grid_import meter is in service on every day (before its install date, after it retired
without a successor) while a use meter in service measured something, the grid meter's known zero would bill that use
as free. Such months are unavailable instead, with an attention item naming the grid meter and the months. Switching
the basis month by month is deferred: the category composition (D-42) would need the same per-month basis to stay
reconciled with the bill.
- **A-18 Meter fees without a line (D-40).** A meter-scoped standing charge of a physical meter that no line of the
figure prices (a PV or house meter behind the billed grid meter) accrues as its own standing-charge row on that meter,
over its service period: in the type's bill, the portfolio and the meter's own scope. In the composition it is a row
like the type's: it joins the one disjoint category that holds its meter, and is a slice of its own otherwise.
- **A-19 Kaskade (D-35 with D-22).** A consumer with its own meter-scoped unit price, linked directly below a billed
grid_import meter with no house meter in between, is billed at its own price and taken out of the grid meters that
link to it. D-22 still reads that link as a supply edge, so the measures do not change. A priced meter nothing links
is still reported as an unused meter price.
- **A-20 Withheld days are not complete (A-05, D-14).** A-05 withholds a whole rollup day or month once a row in it
closes after now, and that can take rows recorded before now with it (a current-month label beside live readings
takes the day's live share). Coverage cannot see this, so such a bucket, and a total holding it, reads partial with
the issue "recorded after now", never available: an empty day is not a true zero. The rows stay in the "recorded
after now" block.
## 12. Amendments after the acceptance review
These refine decisions where the acceptance review found a gap. No golden bill or reconciliation figure changes.
- **A-21 Not-costed meters on `/api/v1/cost` (A-16, D-45).** A meter without a cost rule (generation, runtime, an
indicator, a calculation that cannot be evaluated) returns `costStatus: NotPriced` and, as `costAvailability`, the
status of its quantity (`Invalid` for a loop or a division by zero) — never "Priced, Available" beside the numeric 0
D-45 keeps. Two additive fields say why: `costRule` (`MeterCostRule`) and `notCosted` (`MeterNotCostedReason`). A costed
meter's month whose quantity is invalid or pending never reports an available cost either. Release note: physical
generation and runtime meters changed from `Priced` to `NotPriced`.
- **A-22 A category whose members price nothing (D-39, D-42).** The cost math is unchanged: a calculated view, a
generation or runtime meter adds nothing to a category. The cost reader now reports it
(`CostAttentionKind.CategoryPricesNothing`, with the category and the members), for a category scope and for every
category of a portfolio read. The Analysis page shows the explanation instead of "No data yet", the Overview lists the
category in its composition as "No cost members not billed", and the meter editor says under a virtual meter's cost
categories that membership adds no cost.
- **A-23 One cost-change rule on every page (D-07).** The Overview's rule (`OverviewComparison.Between`: the totals when
both periods are complete, else the paired buckets complete on both sides, else not comparable) is used by the energy
type page, the Analysis page (cards and the table's total row) and the meter page (which now also states it from the
totals when both are complete), with the same "over the part both periods cover" caption. The Analysis page's
one-meter quantity view reads the meter's comparison cost for its cost card.
- **A-24 A measure's resolution (D-51).** A per-type measure carries the coarsest resolution of the meters it counts
(a virtual member's evaluated resolution), so a type or Solar view over monthly data never drills a month into days;
the Overview's own fallback is gone. Solar bounds drilling by every series it charts. Pages offer a click, a drill
column and a drill hint only where some bucket leads somewhere.
- **A-25 A virtual meter's bucket with nothing finer (D-51).** A virtual meter has no records, so where a physical meter
opens its Normalized data, a virtual meter's bucket opens the meter's own analysis over that bucket; its source
contributions link on to each source's records for it. The bucket that already is the whole view leads nowhere.
- **A-26 Nothing booked (D-19, D-41).** A cost bucket with no line, no charge, no manual cost and nothing missing stays
unknown in the engine (SeededBillTests) and now reads "No data" everywhere: never "Priced" beside "—", never complete,
and `Missing` (not `Available`) in the CSV export.
- **A-27 A tariff's value (D-38, D-52).** The tariff editor starts a new tariff without a value and refuses to save
without one, so the missing-price deep link cannot turn a gap into a free period by one click. A typed 0 for a unit
price, base price or feed-in is saved as the valid zero D-38 defines, with the note "A price of 0 makes this period
free of charge".
- **A-28 Chart marks (D-49, brief §4.3).** Bars are outlined in their colour, so a true zero is a line on the baseline
and a gap draws nothing; a bucket without a value is marked "" (its own note), a qualified value keeps "*". A chart
with nothing to draw says why: data only coarser than the buckets (naming the resolution, with the interval that
shows it) or a cost without a price — "no data" only when there is none. A unit mismatch in an attention item says
what does not fit: the currency, a base price's period, or the meter's unit.
- **A-29 Record tabs and "now" (D-04, D-50).** The record tabs list the whole named range, so their toolbar shows those
dates (the end of the month for month to date), and every row dated after now carries an "After now" mark.
- **A-30 Preview period (D-31).** The calculation preview opens on the period of the page the editor was opened from
and offers every preset, custom dates and all available history (the sources' own dates, however old), through the
shared toolbar; a range too long for months previews in years.
## 13. Amendments recorded with the final documentation
The page agents and the integration made these decisions while building. They are implemented and tested, but were
not written down above. They are recorded here so the note stays the complete list. None of them changes a golden
figure.
- **A-31 Page-specific URL keys (D-46, D-47).**
- The energy History tab uses `view=total|meters`.
- The Overview uses `chart=` for its chart selection.
- The record tabs reuse the page's `period`/`from`/`to`, and `all` there means no date bound.
- None of these keys belongs to `AnalysisUrlKeys`. They are written with replace and never reload the analysis.
- **A-32 Overview projection (D-09).**
- It is offered only for month or year to date, only from a complete figure, and only after 7 or 30 days.
- Metered use (net of feed-in credit) and standing charges are extended at their observed rate per elapsed day. D-09
said standing charges are added exactly per day; that is not done.
- Manual costs are kept as booked, not projected.
- **A-33 Series on one chart (D-15, brief §7.4).**
- The Analysis page draws comparison overlays for at most three series. With more, the comparison stays in the
table, with a note.
- The energy History "individual meters" view charts at most six meters and links to the Analysis page for the rest.
- A category is analysed by quantity only when all its meters share one kind and unit. The meters are then shown
side by side and never added, because members can overlap. Otherwise the page explains why and offers the
alternatives.
- **A-34 Context and counts on the meter page (brief §7.2, D-50).**
- Events and tariff changes in the range are listed under the chart, not drawn on it: the shared chart has no
annotation support.
- Record tab labels carry no counts, because that meant counting every row on each load. Each table states its own
count, capped at 10,000.
- **A-35 Solar figures (D-54).**
- Self-consumption is total load grid import, else generation grid export.
- Feed-in is the grid export meter, else generation self-consumption. The calculated form is labelled, and
batteries are not modelled.
- Site use is the total load meter, else self-consumption + grid import.
- Savings are self-consumption × the grid unit price, month by month through `CostCalculator`. The feed-in credit is
the cost reader's own line.
- Mixed units make a figure invalid, naming the units.
- **A-36 Tariff deep link (D-52, A-27).**
- The prefilled dialog opens once. `action`, `component` and `from` are then dropped from the address.
- `scope`/`id` stay and filter the list to the tariffs that can price that meter or type, with "Show all tariffs".
- The suggested unit follows the scope until the user types one.
- A stored tariff whose unit no longer fits shows an issue icon and cannot be saved again until the unit is fixed.
- Both admin and meter tariff lists show the effective end: the day before the next tariff of the same kind starts
(`TariffValidity`).
- **A-37 Deleting a meter or an energy type (D-32, D-33).** `tariff.scope_id` has no foreign key, so `EntityDeletion`
deletes the tariffs scoped to the meter or type together with it, in one transaction. For a meter, its readings and
consumption go too. Export/import no longer restores a tariff whose meter or type is gone onto whichever id replaces
it.
- **A-38 Where the toolbar sits (brief §7.2, §7.3).**
- The energy page has one toolbar above its four tabs:
- Interval and comparison show on Overview and History; metric and export only on History.
- A bucket refused for too many points is read again on auto, so every tab still shows figures while the toolbar
offers the coarser size.
- The meter page keeps its toolbar inside the Analysis tab, so its tab bar sits directly under the header.
- **A-39 Shell after the acceptance review.**
- Buttons, icon buttons, links, chips, tabs and nav links get a 2 px focus ring in the theme's text colour.
- `MeterVaultMudLocalizer` gives MudBlazor's own labels German text. The English values are MudBlazor's own.
- Meter → Sources links each source's connector to its editor, and the source dialog has "Edit connector". Both
keep the existing detour and its draft.
- The theme defaults to dark when no `mv-theme` cookie is set.
- The Calculation tab words a calculation problem as the attention list does, one wording per `VirtualProblemKind`.
+372
View File
@@ -0,0 +1,372 @@
# Dashboard, navigation, and historical analysis: change brief for Claude Code
**Status:** proposed implementation brief; no application changes made by this review.
**Reviewed:** 2026-09-19, repository revision `c0f52db`.
**Scope:** overview dashboard, navigation, meter detail, energy-type pages, trends, virtual meters, and the relevant calculation services.
## 1. Objective and review boundaries
Make MeterVault feel like one coherent application in which users can find an option where they expect it, inspect historical data at any useful period, and understand why a particular number is unavailable.
The central requirement is that a virtual meter combining two meters must have the same applicable consumption/generation analysis as a physical meter: period totals, history, comparisons, quality information, and costs where a valid costing rule exists. Lack of raw readings is expected for a virtual meter and must not prevent derived analysis.
This is a source-code and product-flow review, not a browser usability test. Findings below are grounded in the checked-in Razor components, services, models, migrations, and tests. No running instance, production data, screenshots, or query timings were inspected. Layout improvements are implementation proposals; verify them in a running seeded instance before declaring completion.
Read [CLAUDE.md](../CLAUDE.md) and [SDD.md](SDD.md), especially §§5.45.5, 7.47.5, 8, 10, and 14.1. The historical analysis work also fills existing SDD §8.3 requirements. Treat the phases below as incremental work on the existing application, not a restart of M0M7. Proposed product defaults in this brief are explicit design decisions for this work, not claims about existing behavior. Document any necessary deviation from the SDD, including any deliberately changed reconciliation result.
## 2. Confirmed problems and their causes
Paths below are relative to the repository root. Method/component names are provided because line numbers will move during implementation.
| ID / priority | Finding and user impact | Evidence / implementation starting point |
|---|---|---|
| A01 / P0 | Virtual meters are explicitly excluded from meter period analysis. A functioning combined meter is redirected to the flow page instead of getting its own history. | `src/Infrastructure/Dashboard/MeterPeriodService.cs`, `GetAsync`: returns `null` for `MeterMode.Virtual`. `src/App/Components/Pages/MeterDetail.razor`: virtual notice; `tests/Integration.Tests/MeterPeriodServiceTests.cs`, `A_virtual_meter_reports_nothing_rather_than_a_confident_zero` pins this limitation. |
| A02 / P0 | Virtual means different things in different layers. The flow service sums all upstream meters for every virtual meter; the core normalizer supports expressions; the editor exposes upstream selection without a formula editor. A subtraction formula can therefore disagree with flow. | `FlowService.GetFlowAsync`, `Core/Normalization/Normalizers/VirtualNormalizer.cs`, `Infrastructure/Normalization/MeterConfigFactory.ParseVirtual`, `Shared/MeterEditor.razor`. |
| A03 / P0 | The documented virtual read/materialization pipeline is incomplete in the inspected services. Normalization skips virtual meters, and costing reads stored consumption without resolving virtual expressions. Assigning a cost category is not a demonstrated fix for missing virtual analysis. | `Infrastructure/Normalization/NormalizationService.cs`, `RecomputeMeterAsync`; `Costing/CostService.cs`, `GetMeterCostsAsync` and `QueryConsumptionAsync`. Verify all write paths before introducing any materialization. |
| A04 / P0 | Costs can count overlapping meters more than once. Overview totals sum every meter; energy-type cost sums every meter of the type, whereas its throughput is a topology-root total. Categories deduplicate meter IDs only within that category, not parent/child coverage. | `DashboardService.ActiveMeterIdsAsync` / `TotalCostAsync`; `Pages/EnergyView.razor`, `LoadAsync`; `CostService.GetCategoryCostsAsync`. This is a structural risk; actual inflation depends on topology, memberships, and tariffs. |
| A05 / P0 | Zero, missing data, missing prices, and invalid calculations are conflated. History fills absent months with zero and hides all-zero history. Missing tariffs resolve to zero. Virtual evaluation substitutes zero for absent source timestamps and non-finite results. | `MeterPeriodService.BuildHistory`; `Core/Costing/TariffResolver.ResolveValue`; `VirtualNormalizer.Normalize`. `EnergyView.NodeValue` also falls back to zero. |
| A06 / P1 | Time ranges are inconsistent. Overview has no selector; meter detail has a fixed 12-month mini-chart; Trends defaults to 24 months with Apply; energy/Solar/consumables default to 60 months and reload immediately. “All time” is 1,200 months. | `Pages/Dashboard.razor`, `MeterDetail.razor`, `Trends.razor`, `EnergyView.razor`, `Solar.razor`, `Consumables.razor`. |
| A07 / P0 | Comparison and cutoff semantics differ. Dashboard summary requests full calendar years; breakdown ends at `asOf.AddMonths(1)`; difference truncates that span to whole months. Several pages derive today from UTC. Meter quantity SQL has no upper bound and starts at UTC Jan 1, while its cost query stops at now. | `DashboardService.GetSummaryAsync` / `GetCategoryDifferenceAsync`; `Dashboard.OnInitializedAsync`; `MeterPeriodService.MonthlySql` / `GetAsync` / `LoadCostsAsync`. Future-dated rows and local year boundaries can produce inconsistent totals. |
| A08 / P1 | History is too limited to investigate changes. Trends is total monthly cost only. Energy pages provide a flow diagram and meter list, without a historical series. Meter consumption/readings tabs show only the latest 200 records. | `Pages/Trends.razor`, `EnergyView.razor`; `MeterDetailService.MaxRows`; SDD §8.3 calls for more. |
| A09 / P1 | Overview and Trends can disagree even for matching dates: summary includes manual costs, monthly trend does not. “Latest month with data” supplies an amount without its month and derives recency only from consumption. | `DashboardService.TotalCostAsync`, `GetMonthlyTrendAsync`, `LatestMonthCostAsync`; `DashboardSummary`. A manual-cost-only instance is not handled consistently. |
| A10 / P1 | Navigation mixes analysis by energy type with specialized Solar/consumables pages. Type links open a page headed “flow,” not a general type overview. Important settings remain spread between the meter editor, Sources tab, and admin pages. Existing shortcuts help but do not provide a consistent analysis journey. | `Layout/NavMenu.razor`, `Pages/EnergyView.razor`, `MeterDetail.razor`, `Shared/MeterEditor.razor`, `MeterLinks.cs`. |
| A11 / P1 | Visual semantics vary. Meter history is a custom 110px HTML bar chart; other charts use ApexCharts. Its bars use absolute values, obscuring negative results. Chart components hardcode dark mode despite the app theme toggle. | `MeterDetail.BarStyle`; `Shared/SeriesChart.razor`, `TrendChart.razor`, `CategoryDonut.razor`; `Layout/MainLayout.razor`. |
| A12 / P1 | Labels and formatting can mislead: most cost views call `Format.Euro`, meter detail uses configured currency; `Meter.Unit` is the raw unit and is also used for normalized period results. This needs explicit handling for runtime conversions. | `App/Format.cs`; `MeterPeriodView.Unit`; `Core/Domain/Meter.cs`; `RuntimeCounterNormalizer`. |
| A13 / P1 | Rapid navigation/filter changes can be discarded by an `_loading` early return. Errors generally lack a panel-level retry state; nav DB errors silently remove the energy-type links. | `EnergyView.LoadAsync`, `Solar.LoadAsync`, `Trends.LoadAsync`, `NavMenu.LoadEnergyTypesAsync`. Reproduce stale-page behavior with delayed requests. |
| A14 / P1 | Long-history reads are aggregated directly from `consumption` in several services. Existing continuous aggregates are not a drop-in solution: the migration fixes the zone to Berlin and stores only amount sums, without coverage/quality. Flow sums all meters before filtering to the selected type. | `CostService.QueryConsumptionAsync`, `MeterPeriodService.MonthlySql`, `FlowService.GetFlowAsync`, `Persistence/Migrations/20260713094634_ContinuousAggregates.cs`. Actual performance and refresh behavior require measurement. |
P0 means calculation/meaning must be settled before exposing more totals. P1 is required for the finished user experience, not optional polish.
## 3. Target navigation and user journeys
### 3.1 Sidebar and terminology
Use one stable navigation structure:
```text
Overview /
Analysis /trends (retain route; improve existing page)
Meters /meters
Energy types expandable group of user-defined types
<type name> /energy/{id}
Specialized views
Solar /solar
Tanks & consumables /consumables
Data import /import
Configuration existing /admin/* routes
Energy types / Tariffs / Cost categories / Connectors / Settings
```
- “Energy types” is the analysis entry; “Configuration → Energy types” edits definitions. Make that distinction visible in page titles and descriptions.
- Keep specialized views grouped and available with useful setup states. Do not infer electricity or oil from names or IDs; scope relevant links by capabilities and configured roles.
- Preserve existing routes and bookmarked query parameters. Extend the current helpers rather than constructing competing URLs in individual components.
- Add breadcrumbs: `Overview → <energy type> → <meter>`. Preserve the incoming analysis period and selected metric through drill-down and Back navigation.
- Persist expanded navigation groups and ensure the active item is visible after reload. If type links fail to load, keep the group with an error/retry affordance instead of silently removing it.
- Keep global meter search and its quick-entry actions. Show search text on desktop and an accessible icon on narrow screens. Search results should link to analysis and appropriate entry actions without forcing a trip through the meter list.
### 3.2 Concrete discoverability requirements
| User intention | Required path |
|---|---|
| Understand this period's usage/cost | Overview → energy-type card or cost breakdown row → scoped Analysis |
| Explain a spike | Chart bucket → finer supported period / table → meter → relevant records/events |
| Inspect a combined meter | Meter search/list/type list → virtual meter → Analysis, with source contributions visible |
| Compare two historical years | Analysis → select scope and years → previous-year overlay and comparison table |
| Add a reading or delivery | Existing meter header and list quick action; no need to locate a tab first |
| Change the source/connector | Meter → Sources → Edit connection; preserve the existing connector detour and draft |
| Understand a missing cost | Cost panel explanation → tariff editor scoped to the relevant meter/type and dates |
| Configure a virtual sum | Add/edit meter → Virtual → Sum → select source meters by name → preview |
| Configure energy topology | Energy type → Flow → Manage connections, with clear source/destination names |
Keep the existing successful behaviors: shared meter editor, event rules, stable manual-entry keypad, tank setup shortcut, source draft restoration, one-shot URL actions, and nav refresh after energy-type edits.
## 4. Shared period and analysis contract
Create a reusable analysis query/result contract and period selector. Suggested names are illustrative; fit the existing project conventions.
### 4.1 Query state
An `AnalysisQuery` should carry scope (meter/type/category/explicit meter selection/overview), metric, local start date, local end date, bucket, comparison, and aggregation basis. Resolve relative presets against an injected `TimeProvider` and the configured instance timezone.
- Presets: this month to date, last complete month, year to date, previous calendar year, last 12 months, last 24 months, all available history, custom dates.
- Default Overview to month to date. Default history pages to last 12 months including the current partial month. This means exactly 12 calendar buckets, not 13 or a partial extra future month.
- Display the effective dates next to the preset, plus the timezone in the range details.
- The UI's inclusive end date becomes a local-midnight exclusive upper bound on the next day. To-date presets stop at the captured current instant. Use the same resolved bounds for quantities, costs, comparisons, and exports.
- Never silently include future-dated readings in a “to date” total. A deliberately selected future range should distinguish recorded future data from actual-to-date and projections.
- All available history comes from availability metadata, not an arbitrary century-long range.
- Presets apply immediately. Custom date editing applies once both dates form a valid range, using one consistent Apply interaction across pages.
- Encode state in query parameters, e.g. `/meters/42?tab=analysis&from=2025-01-01&to=2025-12-31&bucket=month&metric=generation&compare=previous-year`. Use stable invariant tokens and localized visible labels.
- Use the URL as the authoritative state for reload/share/back. Preserve one-shot `action` handling separately; changing filters must not reopen a reading or source dialog.
- Validate bounds, IDs, enum tokens, maximum series count, and bucket/point limits. Invalid input must produce a recoverable message or documented fallback.
### 4.2 Buckets and comparisons
- Support day, week, month, year, and Auto where the stored data supports them. Week starts Monday in the instance timezone; preserve actual start/end dates for partial weeks.
- Auto chooses an appropriate bucket with at most 400 visible points per series. Explicit choices that exceed the limit should offer a coarser bucket rather than silently truncate.
- Compare complete periods with complete periods. For MTD/YTD, default to the same elapsed calendar portion of the comparison period, including the local time-of-day cutoff. Clamp missing dates at shorter month/leap-year boundaries and show both exact ranges.
- Distinguish actual change from projection. A “vs last year” label must not secretly compare a current-year projection with a prior-year actual.
- Show absolute difference even when percentage is unavailable. A zero or negative baseline yields “percentage not applicable” by default; do not report 0% when a denominator is absent.
- Keep signed values signed. Generation increases and consumption increases do not share the same good/bad interpretation; use metric-specific or neutral colors and explicit wording.
- Projections remain secondary, explicitly labeled, and describe their method. Suppress projections for unavailable, stale, or insufficient coverage; do not extrapolate a lone old monthly reading as though it were live data.
### 4.3 Result and missing-data semantics
Return structured results rather than `null`, `[]`, or `0` with no explanation. Each series needs stable meter/scope identity, quantity kind, normalized unit/currency, available range, effective requested range, calculation basis, and bucket-level values/status.
Keep separate dimensions: availability (available/missing/partial/error), provenance (measured/manual/estimated/interpolated/derived), freshness, and price coverage. A derived value can be complete and current; these are not mutually exclusive states.
| Situation | Display and action |
|---|---|
| Valid observations yield zero | Show numeric zero, an actual chart point, and its coverage |
| No values in selected range, older history exists | “No data for this period”; show available dates and “Go to latest data” |
| No normalized history yet | Explain the mode-specific next step; do not assume every counter requires two readings because initial-baseline behavior already exists |
| Partial source coverage | Show a partial total only if meaningful, identify missing periods/sources, exclude it from confident comparisons |
| Valid quantities, no applicable tariff | Keep quantity analysis; show cost as unavailable with a tariff action |
| Explicit applicable zero-priced tariff | Show a valid zero cost |
| Invalid virtual expression / missing dependency | Name the problem and affected source; offer Edit calculation or Open source |
| Query/refresh error | Local panel error with Retry; distinguish retained stale data from current data |
| Valid virtual meter without raw readings | Show derived analysis; raw-reading controls are not applicable |
Do not promise an exact coverage percentage unless the stored metadata supports it. Monthly observations are not evidence of day-level completeness. Expose source resolution and known coverage bounds; where unknown, say so. Totals, charts, tables, comparisons, and exports must share these semantics.
## 5. Virtual meters as full analysis subjects
### 5.1 Canonical definition and editor
Separate **calculation dependencies** from **physical flow topology**. An upstream link says where energy flows; it must not silently overwrite a configured formula.
- Provide Sum, Difference, and Advanced expression modes in the shared editor, using source meter selectors with names, quantity kinds, and compatible units.
- Store one canonical definition: expression, referenced IDs, result kind, result unit, and supported evaluation/aggregation semantics. Derive referenced IDs from validated expressions or verify they agree exactly.
- Sum of two generation meters defaults to generation. Consumption sums default to consumption. Mixed-kind/net calculations require an explicit result meaning. Do not retain the current unconditional consumption kind.
- Validate self-reference, cycles including nested virtual meters, missing IDs, syntax, unit compatibility, and result semantics on save and again on read for legacy data. Reuse the restricted expression evaluator; do not evaluate arbitrary code.
- Give the editor a preview for the selected historical period, including per-source values and incomplete-data warnings. Show friendly source names beside any `m123` formula tokens.
- Show the formula and linked dependencies on virtual meter detail. Replace register/baseline/source-ingestion controls with appropriate calculation controls; keep any applicable note/event capability.
### 5.2 Existing data compatibility
The seeded `Summe Solar` meter is virtual with upstream links but no explicit formula (`Infrastructure/Import/ReferenceDataImporter.cs`). Do not break this example or existing installations configured the same way.
1. Preserve existing explicit expressions as authoritative.
2. For expression-less virtual meters with upstream links, compatible normalized units, and unambiguous quantity kind, migrate the existing implied sum to an explicit dependency definition. Update seed creation too.
3. Preserve the topology links as topology; changes to flow links after migration must not secretly alter a saved calculation. Offer an explicit calculation edit when desired.
4. Flag ambiguous/mixed-unit/cyclic/no-source definitions as needing configuration. Do not invent conversions or overwrite metadata unrelated to virtual calculations.
5. Make migration idempotent and report converted/unresolved meter counts. Never modify raw readings. Describe any historical semantic change in release notes.
### 5.3 Evaluation rules
Introduce a shared Infrastructure reader (for example `MeterSeriesService`) used by meter history, energy analysis, dashboard, costing, and flow value lookup. Physical meters read normalized aggregate data; virtual meters recursively resolve dependency series.
- Load all unique physical dependencies in bounded batches and evaluate the dependency graph in topological order. Detect cycles and enforce depth/series/point limits.
- Align source buckets by canonical instants and timezone, not exact raw timestamps or localized chart labels.
- Missing source data is unknown, not zero. Under the default strict policy, a sum bucket is complete only when all required inputs are available for that bucket. Explicitly known zero is a valid input. Preserve provenance from dependencies.
- Propagate nested failures with a useful dependency path. Non-finite arithmetic, including division by zero, produces an invalid bucket with an explanation, never a fabricated zero.
- Additive formulas such as `m1 + m2` and `m1 - m2` can roll up their evaluated base buckets. Arbitrary expressions are not necessarily additive: `sum(m1 / m2)` is not `sum(m1) / sum(m2)`.
- Define an evaluation basis for non-additive expressions and a metric-appropriate reducer (e.g. ratio of totals or weighted average). If those semantics are not supported, make that metric/granularity explicitly unavailable; do not silently change the formula's meaning when zooming.
- Preserve negative net values in history. Sankey rendering may use a separate nonnegative/directional representation, but its rendering limitation must not alter the canonical analysis value.
- Evaluation must not depend on cost-category membership. Implement read evaluation first. If costing needs materialized results under SDD §14.1, use the same evaluator with explicit dependency invalidation, rebuild rules, and tests.
- Invalidate caches after source ingestion, corrections, events, import/revert, normalization changes, definition edits, and relevant tariff edits. Avoid process-wide unbounded caches or recomputation per chart cell.
### 5.4 Minimum worked example
Given generation meters A and B with complete monthly data:
| Month | A | B | Virtual Sum A+B |
|---|---:|---:|---:|
| January | 100 kWh | 150 kWh | 250 kWh |
| February | 80 kWh | 120 kWh | 200 kWh |
The virtual meter shows 450 kWh for the two-month period, a generation label, both history points, source contribution details, and the same numbers in type analysis. It needs no raw readings and no cost category. If B is missing in February, that month is incomplete, not a confident 80 kWh. If B has an observed zero, the complete result is 80 kWh. A separate AB meter shows 50 and 40 kWh rather than inheriting the flow service's sum.
## 6. Totals, costs, and energy-type semantics
Do not achieve visual consistency by making every page sum all meters. Define a shared aggregation policy and include its selected basis in results and visible explanations.
### 6.1 Quantity totals
- Separate consumption, generation, runtime, tank balance, and net quantities. Same energy-type membership does not guarantee addable units or independent measurement coverage.
- Default physical throughput to the appropriate non-overlapping topology roots, with consumption and generation separate. List exactly which meters contribute and which are excluded.
- A virtual view of already-counted sources is visible and analyzable but excluded from an additive portfolio total by default. Explicit selections can replace source coverage with a virtual result; they must not add both.
- Detect known overlap using topology and virtual dependencies. Do not claim completeness where overlapping measurements cannot be established from configuration; request scope configuration through a clear page action.
- Distinguish throughput from billed import and total household use. Multi-parent topology and grid-plus-solar supply do not justify summing every node as “consumption.”
- Retired meters retain their historical contribution. `IsActive` controls current operation, not erasure from historical totals; respect effective install/retire dates where reliable.
### 6.2 Costs
- Make cost inclusion explicit at the relevant meter/scope configuration. Default new virtual meters to analysis-only for portfolio costing so enabling their analysis does not increase the bill.
- Meter detail may show the cost of a physical or virtual scope without automatically including that scope in portfolio totals.
- Virtual costing must name its rule: tariff applied to the virtual quantity, or aggregation of already-priced independent sources. These differ when sources have different tariffs. Default to unavailable until a valid rule is inferable or configured; never sum source costs and reprice the combined quantity together.
- Handle standing charges once for the intended billing scope. Do not replicate a type/global base charge across every analytical submeter and virtual view. Preserve existing tariff precedence and document any changed billing rule.
- Add applicable price coverage to cost results. Historical tariff gaps produce partial/unavailable cost; existence of any tariff anywhere is insufficient. Missing optional credits must be distinguishable from missing required unit prices.
- For a year with tariff changes, aggregate correctly priced underlying billing periods; do not price the whole year from a July sample. Changing chart granularity must not change the total bill. Keep the existing monthly pricing convention unless a deliberate change is documented and reconciled.
- Include manual costs exactly once in matching overview, trend, and category totals. Show uncategorized contributions rather than dropping them. If categories overlap, label them as overlapping views and do not present their sum/donut as a disjoint breakdown of the bill.
- Use signed bars/tables for cost credits and negative totals. A donut is appropriate only for a nonnegative, disjoint composition.
- Make “Latest month with data” return its actual period and availability basis, including manual costs. Never silently switch every dashboard panel to historical data; offer an explicit action to open that period.
- Use configured currency consistently. Use normalized quantity units for analysis and raw units only for raw register values; resolve runtime-to-volume conversion explicitly.
## 7. Page specifications
### 7.1 Overview dashboard
Use a consistent header, period toolbar, and compact coverage/freshness summary. The initial viewport should answer: what happened in this period, what changed, and where to investigate.
1. Period cost with coverage and comparison. Show usage/generation per energy type with their own units, rather than adding unlike quantities to one total.
2. Energy-type cards with quantity, available cost, comparison, and a clear link to that type's analysis. Quantity cards work even without tariffs or categories.
3. Shared historical chart with metric toggle and previous-period overlay; the selected range applies to every analytical panel.
4. Ranked change table by category or meter: current, previous, absolute delta, percentage where meaningful. Rows link to scoped analysis with the same dates.
5. Cost composition that reconciles to the selected scope, with explicit overlapping/uncategorized handling.
6. Compact attention items only for relevant issues: missing prices, missing data, invalid virtual dependencies, stale sources. Provide a targeted action for each.
Keep the update banner separate from analytical status. Avoid making configuration of cost categories a prerequisite for viewing valid quantities.
### 7.2 Meter detail
Move the tab/navigation bar directly below the identity and action header. Put the analytical content inside the default **Analysis** tab so Sources and Events do not sit below a long wall of charts.
- Tabs: Analysis, Readings where applicable, Normalized data, Events, Tariffs, Sources for physical input meters / Calculation for virtual meters.
- Preserve old `tab=readings|consumption|events|tariffs|sources` links with a compatibility mapping. Resolve tabs by stable keys and capability, not fixed numeric indexes after conditional tabs are introduced.
- Analysis: shared period controls; selected-period quantity/cost/comparison; actual-versus-projection distinction; full-size chart; year-over-year view; accessible table; CSV export; data-quality/coverage explanation.
- History table: period, quantity, cost, comparison, quality, and coverage. Include year in date labels across multi-year ranges. Chart selection can drill to supported finer detail while retaining scope.
- Show lifecycle events and tariff changes as optional contextual markers, bounded to the selected range. A chart click should lead to records/events capable of explaining that interval.
- Raw and normalized-data tabs need server-side date filtering and pagination with a stable ordering. The latest-200 view is not full history. Explain raw retention separately from retained analytical history.
- Virtual Analysis includes source contributions and formula details. Do not show fake register totals or suggest adding a raw reading to fix virtual history.
### 7.3 Energy-type page
Title the page with the energy type's user-defined name. Use **Overview / History / Flow / Meters** tabs, defaulting to Overview.
- Overview: separate appropriate quantity kinds, cost with billing basis, coverage, trends, and largest changes.
- History: shared chart/table, day/week/month/year selection, calendar-year comparison, and optional per-meter series. Offer “total” and “individual meters” views with overlap explanations.
- Flow: retain Sankey as a topology tool, using the canonical meter values. Mark inferred proportional allocations as estimates. Provide a textual/table equivalent and a connection-management entry point.
- No topology must not prevent analysis. Negative/net values remain available in History even if unsuitable for a ribbon.
- Meters: searchable list with period values, quality/coverage, physical/virtual distinction, and existing quick actions. A missing graph node must not become a fake zero meter value.
- Add appropriate links to Solar/consumables without making users rediscover a different period selector there.
### 7.4 Analysis page (existing `/trends`)
Replace the single monthly total-cost chart with one reusable exploration page. Scope selector: portfolio cost, energy type, category, individual meter, or explicit meter comparison. Metric selector: supported quantity kind or cost. Support at most six simultaneous meter series by default, with an explanation when the selection exceeds that limit.
Use the same series reader, toolbar, chart, and table as meter/type pages. Do not build a second formula engine here. Comparable meter quantities must have compatible normalized units; otherwise split charts or explain why the comparison is unavailable. Category analysis is always available for cost; quantity analysis needs a single compatible quantity kind/unit.
### 7.5 Solar and consumables
Adopt the shared toolbar, theme, cards, history components, and missing-data semantics while retaining their specialized measures. Distinguish current tank balance/forecast from historical period totals. Show the balance's observation date explicitly; a historical date range must not label today's balance as a historical observation.
Replace visible instructions to edit raw role tags with friendly meter-role configuration controls. Missing Solar context should identify the required role and provide a scoped setup path. Use configured units/conversions rather than assuming every generation counter is measured in kWh.
## 8. Shared UI and interaction standards
- Extract common components for page header, period toolbar, metric card, analysis chart/table, and availability state. Reuse MudBlazor and the existing ApexCharts integration.
- Use the existing theme for colors, spacing, borders, typography, and density. Charts must respond to light/dark changes in the current circuit.
- Chart points retain temporal identity, nullable value, and provenance; avoid reducing them to only `string Label, double Value` before rendering. Preserve numerical precision in calculation and round only for display.
- Display units in axes/tooltips and currency in cost series. Do not smooth or connect across unknown intervals. Render signed bars around a real zero baseline.
- Provide keyboard-accessible links/actions, chart table alternatives, touch-friendly controls, visible focus, and meanings that do not rely only on red/green.
- Use localized EN/DE strings in both resources and existing typed accessors. Format user data without translating meter/type names. Cover long German labels on narrow screens.
- Validate at approximately 360px, 768px, and desktop widths. Controls should wrap predictably; keep page-wide overflow out of the main layout and contain wide tables locally.
- Cancel superseded loads or use request-generation IDs so only the latest requested scope/range is committed. Publish a coherent result atomically; do not mix the previous type's chart with the next type's title.
- Expose initial loading, refresh, error/retry, and stale-but-visible states consistently. Dispose subscriptions/cancellation sources with the circuit/component.
## 9. Data access, history resolution, and performance
Preserve immutable raw readings and existing normalization rules, including month-label attribution, local-month splitting, swaps/resets, and import/revert behavior. Do not rescan raw readings to draw long-range charts.
1. Centralize physical/virtual analytical reads in Infrastructure. Keep the formula algebra, compatibility checks, and reducers pure in Core where practical; keep presentation out of the domain model.
2. Select meter IDs and time bounds in SQL before aggregation. Batch dependency and tariff reads. Avoid one full scan/context/tariff load per meter per panel.
3. Audit existing continuous aggregates before switching readers: timezone, real-time/materialized behavior, backfill after historical imports, late corrections, retention, and current incomplete buckets. Their comments are not proof of freshness.
4. Add migrations for required aggregate/metadata changes rather than editing previously applied migrations. Support the configured instance timezone, not a hardcoded Berlin-only implementation.
5. Preserve coverage/provenance and available resolution through aggregation. The existing amount-only aggregates cannot answer these questions by themselves; define auxiliary summaries or sufficient aggregate fields.
6. Legacy monthly data must remain honestly monthly. Daily/week views cannot display one month-end amount as a measured daily spike. If interpolation is offered, make it explicit, mark it interpolated, and preserve monthly totals; otherwise explain the unavailable resolution.
7. Never include both a refreshed aggregate bucket and its underlying consumption in a live-tail merge. Test exact refresh boundaries and backfilled periods.
8. Bound raw/event retrieval and chart output separately. Prove history still works after raw readings are removed by the retention policy.
9. Keep current public API contracts compatible. Reuse the shared reader behind relevant endpoints where possible; add versioned/additive fields for availability rather than silently changing existing numeric response types.
10. Record representative timings and query plans before/after. Proposed review target: cached metadata plus a 10-year monthly request for 100 selected meters should complete within two seconds on documented local test hardware. Treat this as a target to measure, not a verified property. Test 1,000-meter selection/query planning and enforce output limits without a per-meter scan storm.
## 10. Implementation phases and exit criteria
### Phase 1 — Shared semantics and regression fixtures
- Add frozen-clock period resolution, analysis contracts, zero/missing/partial semantics, normalized units, and explicit aggregation/cost policy.
- Add fixtures reproducing virtual absence, overlapping scopes, missing prices, historical-only data, and inconsistent cutoffs.
- Resolve proposal choices in this brief in a short implementation note; identify any incompatible legacy assumptions before migration.
**Exit:** deterministic tests pin interval boundaries, scope membership, missing-data rules, and arithmetic semantics. No new UI claims rely on unresolved totals.
### Phase 2 — Virtual evaluation and compatible migration
- Implement shared physical/virtual series reader, canonical definitions, validation, editor, compatibility migration, and source contribution results.
- Remove the blanket virtual rejection from period analysis. Replace the old “virtual returns null” test with positive and error-state behavior tests.
- Route flow and cost quantity lookup through the same evaluator, keeping flow rendering separate.
**Exit:** the worked A+B/AB examples and seeded Summe Solar have full analysis without raw readings/category setup; nested/error cases are explained; enabling analysis does not double portfolio totals.
### Phase 3 — History and page navigation
- Implement shared toolbar/chart/table; upgrade meter, energy-type, and Analysis pages.
- Add period-preserving URLs, breadcrumbs, compatibility tab routing, history pagination, exports, and stale-request protection.
**Exit:** a user can navigate Overview/type → meter → historical month and back without losing dates, and compare historical years for physical and virtual meters.
### Phase 4 — Overview and specialized consistency
- Rework dashboard quantities/costs/change tables, aligned manual costs, latest-data period, targeted setup actions, sidebar grouping, and Solar/consumable controls.
- Complete theme, currency, normalized-unit, localization, mobile, and accessibility work.
**Exit:** matching scopes/periods reconcile across cards, charts, tables, and exports. Valid quantity analysis remains visible when costs are unavailable.
### Phase 5 — Integration, performance, and documentation
- Run targeted regression suites and then the required full build/tests. Measure query behavior with historical and synthetic data.
- Walk through the seeded application in EN/DE, light/dark, and mobile/desktop. Capture representative before/after screenshots and any remaining limitations.
- Update SDD/CLAUDE descriptions that currently claim unsupported virtual behavior or no longer describe the navigation/calculation model.
**Exit:** all acceptance scenarios below pass or have an explicitly documented blocker. Do not mark the feature complete after only changing menus or removing the virtual `return null`.
## 11. Acceptance scenarios and validation
| Scenario | Required evidence |
|---|---|
| Two-source virtual generation sum | 100+150=250 and 80+120=200 by month; 450 total; generation units; physical and virtual pages/type history agree |
| Missing versus zero source | Missing B makes the bucket partial/unavailable; observed B=0 yields a complete sum |
| Difference and nesting | Negative AB plots below zero; nested dependencies resolve once; cycle reports a named dependency error |
| Invalid arithmetic | Division by zero and unsupported non-additive rollup produce explanations, never zero or infinity |
| Legacy virtual configuration | Seeded Summe Solar acquires a compatible definition; migration rerun changes nothing; existing formulas retain precedence |
| Overlapping topology | Parent 300 and child 100 display 300 for the non-overlapping parent scope, with 100 as a breakdown, not 400 |
| Virtual overlap | Sources 100+150 and virtual 250 remain a 250 portfolio quantity where that is the selected coverage, not 500; costing has equivalent explicit coverage |
| Missing versus free tariff | Valid quantity with missing required price has unavailable cost; explicit zero tariff has valid zero cost |
| Cost stability | Manual costs appear once; standing charges follow the chosen billing scope; changing chart bucket does not reprice annual totals from one sampled tariff |
| Historical-only instance | Current period explains no data and offers actual available dates; selecting historical year shows quantities/costs for physical and virtual meters |
| Manual-cost-only instance | Overview, trend, category breakdown, and latest-data month agree without requiring a meter |
| Local-time boundaries | Frozen clock tests at local New Year, Berlin spring/fall DST, leap day, shorter months, and a zone behind UTC; from/to are half-open consistently |
| Future rows and partial periods | To-date totals exclude future data; comparisons state actual matched dates; projections are explicitly separate |
| Zero/negative history | Legitimate all-zero year remains visible; negative net history is signed and percentage rules are consistent |
| Monthly legacy resolution | Monthly import stays monthly or explicitly interpolated; daily chart never invents measured detail |
| Retained history | Charts still work after old raw readings are absent; raw-record tab explains retention |
| Rapid filter/navigation change | Delayed first request cannot overwrite the later selected type/range; reload/back preserves URL state |
| Compatibility actions | Existing reading/event/edit/source URLs still open the intended action once; connector detour preserves typed draft and return context |
| UI consistency | Theme toggle updates charts; EN/DE resource tests pass; narrow-screen controls and keyboard/table alternatives work |
| Import/correction freshness | Import, revert, reading deletion, swap/reset, and tariff/definition edits refresh affected physical/virtual history without duplicates |
Use the existing suites as starting points:
- `tests/Core.Tests/VirtualMeterTests.cs`, `ExpressionEvaluatorTests.cs`, and relevant normalization tests.
- `tests/Integration.Tests/MeterPeriodServiceTests.cs`, `FlowServiceTests.cs`, `CostSetupTests.cs`, and costing/reconciliation suites.
- `tests/Integration.Tests/DashboardRenderTests.cs`, `LocalTimeEntryTests.cs`, and `Localization/StringResourceTests.cs`.
Add tests around substantive data behavior and navigation state; do not rely on snapshots of markup alone. Server-render tests do not prove interactive ApexCharts updates, browser history, or responsive usability. Add meaningful interaction coverage using the repository's available tooling, or record a manual browser checklist when no suitable harness exists.
Commands for the implementing agent:
```powershell
dotnet build
dotnet test tests/Core.Tests
dotnet test tests/Integration.Tests
```
Integration tests need Docker/TimescaleDB. Report actual commands/results and prerequisites that prevented execution. Preserve golden CSV reconciliation unless a deliberate correctness fix is described with old/new values and a focused regression test. The source review that produced this brief did not run these tests.
## 12. Delivery requirements for Claude Code
Deliver working code in reviewable phases, necessary migrations, both language resources, substantive tests, and updated documentation. Keep the current stack and user-defined energy types. Do not rewrite ingestion, introduce a new frontend, or change raw-reading history to make charts look correct.
The final implementation report should state which findings were fixed, how physical and virtual results now agree, which cost/aggregation decisions were applied to existing data, what was tested, and any remaining limitations. Include screenshots of the new Overview, physical/virtual meter history, and energy-type history at desktop and mobile widths.
**Definition of done:** a user can find the correct action without guessing which page owns it, examine the same selected period consistently across the application, analyze a valid combined virtual meter as fully as its compatible physical inputs, and distinguish a true zero from unavailable or incomplete information.
+221
View File
@@ -0,0 +1,221 @@
# Release notes
User-visible changes per release. Earlier releases are described in the git history and in the README's upgrade
sections. Decision ids (D-nn, A-nn) refer to [`ANALYSIS_IMPLEMENTATION_NOTE.md`](ANALYSIS_IMPLEMENTATION_NOTE.md).
## 0.4.0 — Dashboard, navigation and historical analysis (unreleased)
This release rebuilds how MeterVault analyses and prices data, and the pages that show it. Every page now works on
the same selected period and the same numbers. A virtual (calculated) meter can be analysed like a physical one. A
true zero, missing data, data that exists only per month, and a missing price are always told apart.
**Some figures change on purpose.** Most visibly, an energy type's bill now counts its grid meter, not every meter
that exists. [Changed figures](#changed-figures) lists every deliberate change. The seeded demo's yearly costs now
match the spreadsheet: 2025 comes to 7,907.65 € against the sheet's 7,907.64 €. The seeded "this year" cost for 2026
was 4,402.19 € in 0.3.0, which priced Haus + Netz + Auto and water at 5 €/m³. It is now 2,940.19 €, the sheet's
figure.
### Upgrading
Back up the database (`pg_dump`) first.
- **The first start rebuilds all analysis data** (normalization revision 3):
- For every meter it rebuilds consumption and the new day and month rollups plus their coverage. It records the
state of each meter.
- This runs **before the web server listens**. The app is unreachable meanwhile, and Compose may report the
container unhealthy. Let it finish.
- The time grows with the number of raw readings. One meter took about 0.1 s with monthly readings, ~0.6 s with ten
years of daily readings (3,700 readings), and ~1.4 s with a year of hourly readings (9,300 readings).
- A synthetic 1,000-meter × 10-year instance (1.34 M readings) took 5.66.5 minutes (338392 s) on a Ryzen 9
9950X3D. That run was preliminary, on a busy machine. It is about 20 % slower per meter than the 0.3.0 rebuild,
because rollups and coverage are written too.
- Progress is logged every 100 meters. A meter that fails is logged, kept pending and retried at the next start.
A meter whose stored consumption is older than its oldest remaining reading is skipped and logged, so its
history is not truncated.
- Until a meter is rebuilt, its pages say "analysis being prepared", never "no data".
- **Virtual meters without a formula** (such as a seeded *Summe Solar* from an earlier version) are converted at
startup. When their incoming links name meters of one unit and kind, the implied sum is stored as an explicit
formula (D-28). The log lists the converted meters, and the meters that still need configuration because their
links are ambiguous, in mixed units, or loop. The conversion runs once; a rerun changes nothing. From then on, links
are topology only and never change a calculation.
- **The migration drops the unused continuous aggregates** and their hourly refresh jobs. It also deletes consumption
rows stored for virtual meters, which nothing read (D-17).
- **Check the attention items** on the Overview after the first start. They name missing prices with a direct "Add
tariff" link, invalid calculations, stale live sources, rows dated after now, and meters that may be counted twice.
- **Rolling back** to 0.3.0 works. The old version ignores the new tables and never read the dropped aggregates.
Consumption stays as 0.4.0 booked it until each meter next ingests a reading. Stored virtual formulas remain, and
0.3.0 goes back to summing links.
### What's new
**Navigation.**
- The sidebar has a fixed structure: Overview, Analysis, Meters, an **Energy types** group, **Specialized views**
(Solar, Tanks & consumables; always listed, with a setup hint when not configured), Data import, and
**Configuration**.
- "Configuration → Energy types" edits the definitions; the Energy types group is for analysis.
- Expanded groups are remembered, and the current page's group always opens. If the energy types cannot be loaded,
the menu shows an error with Retry instead of silently dropping them.
- Breadcrumbs (Overview → energy type → meter) keep the selected period, and so does Back.
- "Find a meter" opens the meter's analysis with the current period and still offers quick entry. It is a text
button on desktop and an icon on phones.
**One period everywhere.**
- Every analysis page has the same toolbar: this month to date, last month, year to date, previous year, last 12 or
24 months, all history, or custom dates with one Apply.
- It also offers a bucket size (automatic, day, week, month, year) and a comparison: previous period, same period
last year (the default), or any calendar year.
- The effective dates are shown next to the choice, with the time zone. The selection lives in the address, so
reload, Back and shared links reproduce the page.
- Rapid clicks can no longer leave one page showing another selection's data.
**Overview.**
- It shows one selected period (default month to date):
- the cost, split into metered use, standing charges, manual costs and feed-in credit;
- a card per energy type with its quantities in their own units, its cost and billing basis, the change and
freshness;
- a history chart with a table view;
- "What changed", by category or by meter;
- the cost composition;
- attention items, each with one targeted action.
- Changes are compared only over the part both periods cover, and the page states both date ranges.
- When the period has no data, the page names the dates that do have data and offers "Go to latest data". It never
silently switches to an older month.
**Analysis page** (`/trends`, formerly the cost trend).
- Explore everything, one energy type, a cost category, one meter, or up to six meters side by side, by quantity or
by cost.
- Compare calendar years with an overlay and a comparison table. Click a bar or a row to drill into a finer period.
- Export exactly what is shown as CSV.
**Energy type pages** are titled with the type's own name and have four tabs:
- **Overview:** measures such as total use, grid import, generation and runtime, each in its own unit and never added
across units; the cost with its billing basis; coverage; the largest changes.
- **History:** the total, or up to six individual meters with an explanation of how each one counts.
- **Flow:** the Sankey, now using the same values as every other page. Calculated and estimated connections are
marked. It comes with a table version and **Manage connections**.
- **Meters:** each meter's value for the period and its data quality.
**Meter page.**
- The tabs sit directly under the header: Analysis, Readings, Normalized data, Events, Tariffs, Sources. A calculated
meter has Calculation instead of Sources.
- The Analysis tab shows:
- the period total with its unit and status, and the cost with its rule, or the reason it has none;
- the change against the comparison period;
- a labelled projection, where there is enough data for one;
- a full chart with the previous-year overlay, and a table;
- a "Data quality and coverage" section;
- events and tariff changes in the range.
- The record tabs page through the **whole history** (100 rows at a time, filtered by the selected dates), no longer
just the latest 200. Rows dated after now are marked.
- Existing links such as `?tab=consumption`, `?tab=readings&action=reading` and `?tab=events&action=swap` still open
the intended tab and dialog once.
**Virtual (calculated) meters.**
- Create them with **Sum**, **Difference** or a **Formula** over other meters, picked by name. A live preview of the
selected period shows every source's values and flags incomplete months.
- The result kind (consumption, generation, net or indicator), unit and cost rule are stored with the formula.
- A virtual meter gets the same analysis as a physical one, plus "Source meters" showing each source's contribution.
- **The rules:**
- A missing source month makes the result "no data" for that month, never a silent zero.
- An observed zero is a real zero.
- A division by zero or a loop is reported, and names the meters involved.
- Differences stay negative.
- New calculated meters are *analysis only*: they never add to a type's totals or the bill. The "Always count" option
lets one replace its sources instead.
**Solar and Tanks & consumables** use the same toolbar, cards and charts.
- **Solar** works out self-consumption, feed-in, site use, savings and autarky from the meters' roles. For a missing
role it shows a setup card with candidate meters instead of raw role tags.
- **Tanks** keep "Last dipstick (date)" apart from "Estimated now". A past period shows the contents at its end, not
today's. The forecast is a labelled projection, hidden when the dipstick is older than 60 days.
**Tariffs and configuration.**
- An "Add tariff" link from a missing price opens the tariff editor once, prefilled with the scope, component and
first uncovered month.
- The unit is checked against what it prices: a wrong unit or currency blocks the save.
- A new tariff needs a value; a typed 0 is a deliberate free period.
- The editor notes that Bonus, Discount and Tax are stored but **not applied** yet.
- Meter roles have friendly names and one-line meanings. A role is unique per energy type, and saving it names the
meter it moves from.
- The Settings page shows the analysis data state and that raw retention is not enforced.
**Also:**
- CSV export of any analysis view (`/export/analysis.csv`): statuses, provenance, costs and comparison values; unknown
values are empty cells, never 0.
- The light/dark choice persists across reloads and language switches, and charts follow it immediately.
- Visible keyboard focus, and tables for every chart.
- MudBlazor's own labels are in German too.
- Pages work at phone width.
- Amounts use the configured currency (`MeterVault__Currency`) instead of a hard-coded €.
- Deleting a meter or an energy type also deletes the tariffs scoped to it.
- JSON export/import now carries meter connections, re-links virtual formulas to the new meter ids, and no longer
restores the tariffs of deleted meters onto other meters.
### Changed figures
Every change below is deliberate. The golden spreadsheet reconciliation (consumption of all four sheets, Netz
Einsparung) is unchanged. The seeded yearly bill matches the sheet's `Jahreskosten` within ±0.02 € for 2022, 2025 and
2026. It differs by 3.78 € (2023) and 0.46 € (2024) only because the sheet multiplies by unrounded prices.
| Area | 0.3.0 | 0.4.0 |
|---|---|---|
| What an energy type's bill counts | Every meter's cost was summed: Haus + Netz + Auto for the seeded Strom | The type's grid import meter when it has one, otherwise its household use. Submeters are breakdowns; generation is never billed. Seeded Strom is Zähler Netz × price, like the sheet (2025: 4,742.64 €) (D-22, D-34) |
| Feed-in credit | Credited on all generation | Only on a meter with the grid-export role, at the feed-in price (D-34) |
| A subsection with its own meter price | Added on top | Billed at its own price and taken out of the meter above it; quantities unchanged (D-35, A-19) |
| Missing tariff | Cost 0 | "Not priced (no tariff)" with an Add tariff action; a hole in a price history is a price gap (unavailable). The quantities stay visible. An explicit 0 tariff is still a valid zero (D-38) |
| Standing charges | Per meter, per month that had readings, and copied onto every meter of the type | Per local day over the scope's service period (including reading gaps), **once per scope**. Type and global charges are their own rows; meter fees stay on their meter (D-40, A-18) |
| Price of a longer bucket | Month buckets used the price of the 15th, year buckets the price of 1 July | Every bucket is priced month by month at the price of the 15th, so a year equals the sum of its months and changing the bucket never changes a total (D-36) |
| Months in which the billed grid meter was not yet (or no longer) in service | — (0.3.0 summed every meter) | Unavailable rather than free while use was measured, with an attention item naming the grid meter and the months (A-17) |
| Tank, runtime, direct-delta or instant-rate readings more than a month apart (a tank dipped every few months, quarterly burner hours) | Booked whole in the month of the later reading, with zeros in between | The months in between read "only coarser data" (not zero). A year or longer bucket is priced when all its months share one price; otherwise it is unavailable with an attention item (A-16) |
| Manual costs | Counted in the Overview but not in the trend; a cost dated later this month counted at once | Counted once, on their start day, when that day has come, everywhere: Overview, Analysis, categories, export (D-41) |
| Cost categories | Sum of their member meters' costs | The priced non-overlapping cover of their members plus their manual costs. Seeded Strom = Netz × price. A category overlapping another is shown as a view, apart from the composition. A category whose members price nothing says so (D-42, A-22) |
| Virtual meters | No analysis; the flow summed incoming links, ignoring any formula | Full analysis from the stored formula; the Sankey uses the same values (D-27, D-30) |
| Summe Solar and other generation sums | — | Analysed as generation (seeded 2025: 4,750 kWh = Solar 1 3,123 + Solar 2 1,627) and **not costed**, because generation is never billed (A-15) |
| Readings at exactly midnight | Booked in the following day | Booked in the day they close (D-11) |
| "Last 12 months" | 1314 buckets, including a partial future month | 12 calendar buckets ending with the current month; actuals stop at now (D-02) |
| Rows dated after now | Counted in "to date" totals | Excluded and shown as "recorded after now", e.g. a sheet row labelled the current month or a future-stamped reading. A day that holds such a row reads partial (D-04, A-14, A-20) |
| Overview "this month / this year" | Current month and year against the complete previous ones | The selected period against the same elapsed part of the comparison period, measured over what both cover (D-07) |
| "Latest month with data" | An amount without its month; consumption only | The month and its basis (meter data, manual costs or both) (D-19) |
| Percentages | A negative baseline was divided by its absolute value | "Not applicable" for a zero or negative baseline; the absolute difference is always shown (D-08) |
| Meter dates | — | Outside its install and retire dates a meter counts as a known zero; retired meters keep their history (D-24) |
| Currency | € hard-coded in most views | `MeterVault__Currency` everywhere; a tariff in another currency is reported as not fitting, never converted (D-43) |
| Continuous aggregates | Refreshed hourly, read by nothing | Dropped; rollup tables are written with each recompute (D-12, D-17) |
| Seed | — | Adds the water price of 7.00 €/m³ from 2026-01-01, and stores Summe Solar's formula (`m4 + m5`, generation, not costed). This affects new seeds; existing seeded instances get the formula through the startup conversion (D-44) |
### REST API
Every existing field keeps its name and type. What changed is only added as new fields. The numbers follow the new
engine, as listed above: actuals stop at now, virtual meters are evaluated, and costs are the bill's.
- **`GET /api/v1/consumption`:**
- New fields: `status`, `issue`, `kind`, `unit`.
- A month without data is left out instead of reported as 0.
- `from`/`to` with a UTC offset are accepted; they used to fail with a server error.
- Virtual meters return evaluated values.
- **`GET /api/v1/cost`:**
- New fields: `costStatus` (`Priced`, `Partial`, `NotPriced`, `PriceGap`, `UnitMismatch`), `costAvailability` (the
state of the quantities behind the cost, e.g. `Unresolved`, `Invalid`), `costRule`, `notCosted`, `missingPrices[]`
(component, reason, scope, first and last month, tariff, unit issue, credit), `status`, `issue`, `kind`, `unit`.
- `cost` stays numeric and is 0 when nothing could be priced; check `costStatus` and `costAvailability` before
trusting a 0.
- **Behaviour change:** generation and runtime meters, indicators and calculations that cannot be evaluated now
report `costStatus: NotPriced`, with `notCosted` giving the reason. They used to report `Priced`, and a generation
meter could carry a negative feed-in cost (A-21).
- **`GET /api/v1/dashboard/summary`:**
- New fields: `deltaPercentApplicable` per KPI, and `latestMonth` `{period, basis}`.
- The month and year windows are unchanged (the calendar month and year to now, against the whole previous ones),
but the values are the new bill. `deltaPercent` is 0 when not applicable.
### Known limitations
- **Raw retention is not enforced** (D-57). `MeterVault__RawRetentionDays` is shown but nothing deletes readings,
because every recompute rebuilds a meter from its readings.
- **Monthly data is never interpolated to days** (D-57). A day or week view of monthly data says "only coarser data"
and offers the monthly view.
- **Bonus, Discount and Tax tariffs are stored but not applied** (D-57).
- **Every live reading recomputes its meter in full** (D-57). That is fine for monthly and daily meters, but costs
about 1.4 s per reading for a meter with a year of hourly data, and grows with history.
- **Months cannot switch billing basis:** the billing basis (grid meter or household use) is chosen per energy type
for all time (A-17).
- **Batteries are not modelled.** Without a grid-export meter, Solar's feed-in is calculated, and labelled as such.
- **No CSV export on the Solar page**, because the export has no derived measures.
+569 -5
View File
@@ -5,6 +5,13 @@
> **What this is:** a self-hosted, local-first energy & utility metering platform that pulls meter data from Home Assistant, Tasmota and MQTT on a schedule, stores every reading with a timestamp, and turns it into cost dashboards. Not limited to electricity/water/oil — energy types are user-defined.
>
> **Status:** design spec, pre-code. This document doubles as the build brief for Claude Code.
>
> **Implementation status (0.4.0):** M0M7 are implemented, and the dashboard/analysis rework
> ([`DASHBOARD_ANALYSIS_CHANGE_BRIEF.md`](DASHBOARD_ANALYSIS_CHANGE_BRIEF.md)) replaced the aggregation, virtual-meter,
> cost and page model. Its decisions are numbered D-01 D-58 and A-01 A-39 in
> [`ANALYSIS_IMPLEMENTATION_NOTE.md`](ANALYSIS_IMPLEMENTATION_NOTE.md). The original design text below is kept.
> Wherever the system now works differently, a **Deviation** or **Current behaviour** block says so in place (list:
> D-58). The results are in [`ANALYSIS_REPORT.md`](ANALYSIS_REPORT.md).
---
@@ -104,6 +111,14 @@ Early rows (19972004) only carry **deliveries** (no burner hours — tracking
| FR-18 | **Deploy** via Docker Compose (app + TimescaleDB); Unraid template; healthcheck endpoint; backup guidance. |
| FR-19 | **Auth**: optional local accounts *and* reverse-proxy trust (honour `X-Forwarded-User` behind Authelia/Traefik). |
> **Where the implementation differs (D-58):**
> - **FR-9:** prices are monthly (the 15th of each local month); there is no day-accurate proration mode. Bonus,
> discount and tax are stored but not applied (§7.5, D-57).
> - **FR-11:** rollups are by local day and month. Weeks and years are built from them, and there is no hourly level
> (§5.4).
> - **FR-12:** the Overview shows one selected period instead of fixed today/month/year cards (§8.1).
> - **FR-16:** raw retention is shown but not enforced (§5.5).
---
## 4. Architecture & tech stack
@@ -125,6 +140,10 @@ Early rows (19972004) only carry **deliveries** (no burner hours — tracking
> The **domain model and DB schema are UI-agnostic.** If a future maintainer swaps Blazor for an SPA, everything from §5–§7 and the REST API in §9 is reusable.
> **Deviation (D-17, D-58):** continuous aggregates are not used. They were dropped in favour of rollup tables that the
> recompute writes (§5.4). Dapper serves the hot-path reads of those tables (`AnalysisQueries`). No hosted service
> refreshes aggregates.
### 4.2 Components & data flow
```
@@ -146,6 +165,13 @@ Early rows (19972004) only carry **deliveries** (no burner hours — tracking
Ingestion writes **raw `reading`** rows. A normalization step derives **`consumption`** (append-only, base unit). Continuous aggregates roll consumption up to hourly/daily/monthly/yearly. The cost engine joins aggregates with time-ranged tariffs. The dashboard and API read aggregates + cost views (never scan raw for charts).
> **Current pipeline (D-12 D-17, D-27, D-34):** the recompute derives `consumption`. In the same transaction it
> writes per-meter rollups by local day and local month, plus coverage runs, all in the configured zone (§5.4). One
> shared **analysis reader** (`AnalysisReader`) reads those rollups. It evaluates virtual meters on read (§7.4) and
> classifies per-type totals. One **cost engine** (`CostReader`) prices the bill from those quantities and the
> time-ranged tariffs (§7.5). The pages, the REST API and the CSV export read only these two readers. None of them
> scans `reading`.
---
## 5. Data model & database
@@ -168,6 +194,18 @@ Design principles: raw readings are immutable audit truth; everything derived (c
- `import_batch` — provenance + revert for CSV/manual bulk loads.
- `app_setting` — currency, locale, timezone, retention, fallbacks.
Added since this sketch (see §5.4 and the note, D-12):
- `meter_link` — directed flow topology (`from → to`: the downstream meter is a subsection of the upstream one;
several parents are allowed). It is topology only: it never defines a virtual meter's calculation (D-25).
- `consumption_rollup` / `consumption_rollup_month` — per-meter sums of `consumption` by local day and local month,
with per-provenance amounts, row count, flags and the latest interval end.
- `meter_coverage` — per-meter coverage runs with their resolution class.
- `meter_rollup_state` — the revision, zone, normalized unit and kind each meter's analysis rows were built with.
> **Deviation (D-57):** the principle "long-horizon retention is served by aggregates, not by keeping every raw row
> forever" is not realised. Raw readings are kept indefinitely (§5.5).
### 5.2 Measurement modes (`meter.mode`)
| Mode | Meaning | Consumption derived by |
@@ -183,6 +221,10 @@ Design principles: raw readings are immutable audit truth; everything derived (c
### 5.3 Schema sketch (PostgreSQL + TimescaleDB)
> Illustrative DDL; EF Core migrations own the relational tables, and **raw-SQL migrations** own the Timescale-specific DDL (`create_hypertable`, compression, continuous aggregates, retention). Timescale objects are *not* expressible through EF's model builder.
>
> *Current:* the Timescale DDL in use is the two hypertables (`reading`, `consumption`) and raw compression. The
> continuous aggregates were dropped by the `AnalysisRollups` migration (D-17), and no retention policy exists (D-57).
> The rollup, coverage and state tables are ordinary EF-owned tables with `ON DELETE CASCADE` to `meter` (D-12).
```sql
CREATE TABLE energy_type (
@@ -353,6 +395,48 @@ CREATE TABLE app_setting (
### 5.4 Continuous aggregates & cost view
> **Deviation (analysis rework, `docs/ANALYSIS_IMPLEMENTATION_NOTE.md` D-10 D-17, D-36, D-58):** the continuous
> aggregates below were Berlin-only and materialized-only. They were never backfilled after historical imports, and
> no reader used them. The `AnalysisRollups` migration dropped them together with their refresh policies. The SQL
> below is kept as the original design. What replaced them:
>
> - **Rollup tables** (plain tables, FK to `meter` with `ON DELETE CASCADE`), all keyed by local dates of the
> **configured** zone:
>
> | Table | Key | Holds |
> |---|---|---|
> | `consumption_rollup` | `(meter_id, day, kind)` | amount; measured/manual/imported/estimated shares; row count; flags (baseline delta, divided); latest interval end |
> | `consumption_rollup_month` | `(meter_id, month, kind)` | the same per local month (read for month and year buckets) |
> | `meter_coverage` | `(meter_id, span_from)` | coverage runs: span, resolution class (≤ 1 h, ≤ 1 day, ≤ 7 days, ≤ 1 month, coarser), whether divided at months, gap reason, last interval start |
> | `meter_rollup_state` | `(meter_id)` | normalization revision, zone, normalized unit and kind, build time |
>
> - **Written by the recompute, by diff, in its transaction.** `NormalizationService.RecomputeMeterAsync` stages them
> through `AnalysisDataWriter` in the same transaction as the meter's `consumption`. Only changed rows are touched.
> Import, revert, events, manual readings, live ingestion and meter edits all reach the next read without a refresh
> job. No cache sits in between.
> - **Intervals.** Every normalized row carries its source interval (EF-ignored, D-10), so coverage is captured while
> normalizing instead of being guessed from sums. A non-label row that ends exactly on a local midnight is stamped one
> second earlier, inside the day it closes (D-11).
> - **Bucket status** (D-14) comes from coverage, never from the amount:
> - `Available`: covered. A zero is a true zero.
> - `Partial`: only partly covered.
> - `Missing`: nothing covers the bucket.
> - `Unresolved`: covered only by an undivided interval that crosses the bucket edge, e.g. monthly data asked by day.
> - `Invalid`: a calculation failed (virtual meters, §7.4).
> - `Pending`: the rollups are being rebuilt.
> A to-date read caps coverage at "now". Rows whose interval closes after now are reported, never counted (D-04,
> A-04, A-05, A-14, A-20).
> - **Reads.** Month and year buckets read the month table. Days and weeks read the day table. At most two partial
> edge days per range come straight from `consumption`. Every request makes one query per table, and the point and
> series limits are checked before any SQL runs (D-15).
> - **Rebuild.** Normalization revision 3 (D-16) rebuilds consumption, rollups and coverage for every meter at the
> next start (`NormalizationUpgrade`). It rebuilds again whenever the revision or the configured zone differs from
> `meter_rollup_state`. Until then a meter reads as "analysis being prepared" (`Pending`), never as "no data".
>
> **Cost is not a view either.** The cost engine (`CostReader`, §7.5) prices each bucket month by month, at the price
> valid on the 15th of each local month (D-36), so the bucket size never changes a total. There is no day-accurate
> proration mode (§14.3). A price change inside a reading interval longer than a month is reported, not guessed (A-16).
```sql
-- Daily normalized consumption per meter (local-tz buckets)
CREATE MATERIALIZED VIEW consumption_daily
@@ -390,6 +474,20 @@ Normalized/rolled-up volumes are tiny regardless: daily consumption = 1000 × 36
> Design conclusion: 1000 meters × 50 years is comfortably within TimescaleDB on modest hardware **provided** dashboards read aggregates and raw retention is bounded. Don't let the UI scan `reading` for charts.
> **Deviation (D-57, a documented limitation):** raw retention is **not enforced**. `MeterVault__RawRetentionDays`
> (default 1095) is shown, but nothing deletes readings. Every recompute rebuilds a meter's consumption from the
> readings that remain, so dropping old readings would destroy analytical history. Bounded raw retention first needs a
> recompute that can start from stored consumption. `/admin/settings` shows "Not enforced" with that reason, and the
> meter page's Readings tab explains it.
>
> **History reads the rollups** (§5.4 deviation): `consumption_rollup_month` for month/year buckets and
> `consumption_rollup` for day/week buckets. `consumption` itself is read only for at most two partial edge days per
> range and by the meter page's Normalized data tab. No chart reads `reading`. It is read by the paged Readings tab
> (100 rows per page, keyset-ordered, date-filtered, D-50), by the manual-entry checks, and for freshness: the latest 20
> reading times per meter (D-18). That freshness query has no time bound yet (§13, report). `consumption` and the
> rollups are the analytical history. Performance was measured against a synthetic 1,000-meter × 10-year dataset
> (`docs/ANALYSIS_REPORT.md`, §13).
---
## 6. Ingestion
@@ -435,46 +533,385 @@ Imported monthly tables keep the golden fixtures reconciling (§13). A row label
Every reader that buckets consumption by month or day (cost, trends, solar, consumables, flow, meter detail) buckets in the configured instance timezone — the same zone the division uses — never a hard-coded one, and starts and ends requested periods at local midnight. The zone id is normalised to its IANA form, and one unknown to .NET or PostgreSQL is reported at startup. Because consumption is derived, a change to these rules is applied to stored data at startup: `app_setting.normalization_revision` and `normalization_zone` record the rule revision and zone the stored series was built with, and every non-virtual meter is recomputed when either differs (the first run also flags month rows of earlier monthly imports, identified from each batch's stored mapping; a batch whose dates were auto-detected counts as monthly when all its rows sit on the 1st across at least two months, which is logged; if the flagging fails, nothing is rebuilt and the upgrade is retried at the next start). A meter whose rebuild fails is logged and listed in `normalization_pending`, retried at the next start, and never stops the application from starting.
> **Current (normalization revision 3, D-10, D-11, D-16):**
> - Every normalized row carries its source interval, so coverage and resolution can be recorded (§5.4).
> - A reading that closes exactly at a local midnight is booked in the day it closes, not the next one.
> - The startup rebuild also writes the rollup and coverage tables, and records each meter's `meter_rollup_state`.
> Virtual meters store no consumption: the migration purged their rows, and a meter that becomes virtual is purged
> on its next recompute.
> - A meter whose oldest consumption predates its oldest reading or event is skipped and logged, so its history is not
> truncated.
> - Month division still applies only to cumulative and generation counters. Tank, runtime, direct-delta and
> instant-rate intervals are booked whole. Where such an interval crosses a month edge, the months on either side are
> `Unresolved` and only coarser buckets are resolved (D-14, A-16).
### 7.2 Runtime → consumption (burner)
For `runtime_counter`: `amount = Δhours × rate`. `rate` comes from the linked `tank`: `fixed` (nozzle spec, L/h) or `empirical` (`Δlevel ÷ Δhours` measured between deliveries/level reads — reproduce the spreadsheet's 1.87/1.94/2.92 … behaviour). Expose both; default empirical when level data exists, else fixed.
### 7.3 Consumable/tank balance & forecast
`balance(t) = Σ deliveries(≤t) Σ consumption(≤t)`, reconciled to physical `tank_level` events when present (cm → litres via calibration). Forecast to empty from a trailing consumption rate (e.g. last-30-day L/day) → `Vorraussichtliches Ende`. Surface low/reorder thresholds.
> **Current (D-54, `TankLevels`):** the last dipstick ("Last dipstick: value on date") is kept apart from "Estimated
> now (incl. deliveries since)", which does not deduct use since the dipstick. For a period that ended before now,
> the Consumables page shows the contents at the period's end, never today's balance. The forecast is a labelled
> projection. It is hidden when the dipstick is older than 60 days, when too little time has passed, or when no use
> was measured. Deliveries before a tank's first level open a coverage gap (D-13).
### 7.4 Virtual meters
For `virtual`: evaluate `config.expression` (whitelisted, sandboxed — a small safe expression evaluator over referenced meters' consumption/generation series), e.g. `self_consumption = generation grid_feed_in`, `savings = self_consumption * unit_price`. Persist results to `consumption` (or compute on read — decide per §14). This is how PV self-consumption/savings and net figures are modelled without hardcoding.
> **Deviation (D-25 D-33, D-39, A-08, A-12, A-15, D-58):**
>
> - **Canonical definition in `meter.meta`:** `expression`, `referencedMeterIds` (always derived from the expression
> and rewritten on save), `resultKind`, `resultUnit` and `costRule`. The result kind is one of `consumption`,
> `generation`, `net` or `indicator`. Save writes the effective (inferred) kind, unit and cost rule, so readers never
> re-infer them (A-08). Topology links (`meter_link`) never define or change a calculation. `meter_source` rows of
> type `virtual` are not used for this.
> - **Formula:**
> - Grammar: `+ * /`, parentheses and numbers, parsed to an AST (`FormulaParser`), at most 2,000 characters and
> nesting depth 64.
> - References are `m<id>`. Any other identifier is an error; it is never read as 0.
> - The old string evaluator and `VirtualNormalizer` were removed.
> - **Validation** (`VirtualValidator`), on save and again on read for legacy data, covers:
> - syntax;
> - unknown or self references;
> - loops through nested virtual meters, reported with their path;
> - kinds and units: `+`/`` need the same unit and kind or a declared `net`; meter × or ÷ meter needs a declared
> unit and kind `indicator`. Indicators are non-additive, never totalled and never costed.
> - **Evaluated on read, never materialized** (`VirtualEvaluator`, through `AnalysisReader`):
> - Evaluation runs per bucket from the sources' rollups, in dependency order. Each physical source is read once,
> however deeply it is nested.
> - Coverage is the intersection of the sources' coverage, and the resolution is the coarsest among them.
> - Strict: a missing source bucket makes the result missing and names the source. An observed zero is a valid input.
> - A non-finite result (division by zero) is `Invalid` with the reason. So is a loop, which is reported with its
> dependency path.
> - A period total is the formula over the sources' totals across their joint coverage. For a linear formula without
> a constant this equals the sum of its buckets; any other formula (for example a ratio) is marked non-additive,
> and its total is the ratio of totals.
> - The result carries every source's series (the "source contributions" on the meter page).
> - Nothing is written to `consumption` for a virtual meter. This decides §14.1.
> - **Totals:** a virtual meter is an *analysis view* by default and is never added on top of the meters it reads.
> The meter's `totals` override (`auto|always|never`) can make it replace its sources in its type's totals and in the
> bill (D-23).
> - **Legacy definitions (D-28):** at startup (`VirtualDefinitionUpgrade`), an expression-less virtual meter whose
> same-type incoming links name sources of one unit and kind gets the equivalent explicit sum stored. The run is
> idempotent and logged. Anything ambiguous is flagged "needs configuration" and is never guessed. The seed writes
> Summe Solar as `m4 + m5`, generation, kWh, cost rule `none`.
> - **Prices are not part of expressions** (`savings = self_consumption * unit_price` is not supported). A virtual
> meter's cost follows its cost rule (§7.5): `sourceCosts` (pure sums: the sources' own metered costs), `ownQuantity`
> (linear formulas: the evaluated quantity at its unit price) or `none`. PV savings are computed by the Solar page
> (self-consumption × the grid unit price, month by month) or through `ownQuantity`.
> - Export/import carries `meter_link` and remaps meter ids inside definitions (D-32). Deleting a meter names the
> virtual meters that read it and asks for confirmation (D-33).
### 7.5 Cost
`cost(bucket) = Σ(consumption_amount × active_unit_price) + base_price(prorated) feed_in_credit bonus`. Prices resolved by date from `tariff` (time-ranged). Currency from `app_setting`. Provide monthly-price and day-accurate-proration modes (§5.4). Categories roll costs up per `cost_category`; add `manual_cost` for meter-less categories (pool).
> **Deviation: the bill (D-22, D-34 D-43, A-15 A-19, A-21 A-22, A-26 A-27).** One cost engine (`CostReader` →
> `BillRun` → the Core `CostCalculator`) prices the portfolio, an energy type, a meter or a category for one resolved
> period. Every page, the REST API and the CSV export use it. Before the rework, costs summed every meter; this is
> what the engine does now:
>
> - **What is billed (D-22, D-34):**
> - Per energy type, the `grid_import` meters are billed if the type has one. Otherwise its *use* meters are billed:
> the `total_load` meter, or else the consumption roots of the topology.
> - Generation meters are never billed.
> - The feed-in credit is the FeedIn price × the export of `grid_export` meters.
> - Runtime meters and virtual views are not billed.
> - Submeters (topology children) are breakdowns, never added. So the seeded Strom bill is Zähler Netz × price, as
> the sheet's `Kosten` is.
> - **Separately billed subsections (D-35, A-19):**
> - A containment child with its own meter-scoped unit price is billed at that price, and its quantity is taken out
> of its billed ancestor.
> - The same applies to a consumer linked directly below a billed grid meter.
> - Quantity totals do not change.
> - **Prices (D-36):**
> - The spreadsheet's monthly convention is kept: the price valid on the **15th of each local month**.
> - Every bucket is cut into its local months and each part priced at its month's price, so week, month and year
> buckets, and the period total, agree.
> - There is no day-accurate proration (§14.3).
> - A reading interval longer than a month (a tank dipped every few months, quarterly burner hours) leaves its months
> unknown. A longer bucket over such months is priced as a whole when every month in it has the same price;
> otherwise it is unavailable, with an attention item (A-16).
> - **Tariff applicability (D-37):**
> - A UnitPrice or FeedIn tariff applies only when its unit's denominator matches the meter's normalized unit.
> Known scales are converted (ct, per 100 L, per MWh).
> - A parsed unit or currency mismatch makes the cost "unavailable (unit)", and the explanation names what does not
> fit (A-28). An unparseable unit applies, with a warning.
> - BasePrice units are per day, per month (the default) or per year.
> - The tariff editor checks units on save. It states that **Bonus, Discount and Tax are stored but not applied**
> (D-57).
> - **Not priced vs price gap vs zero (D-38, A-26, A-27):**
> - A billed scope with no UnitPrice tariff at any date is **not priced (no tariff)**. That is an attention item, not
> a partial total.
> - A hole in a priced scope's tariff history makes those months a **price gap** (cost unavailable).
> - An explicit zero tariff is a **valid zero**. The tariff editor refuses to save a new tariff without a value, so a
> deep link cannot create a free period by accident.
> - A missing FeedIn price is reported only where a `grid_export` meter exists.
> - Months with no grid meter in service, while use was measured, are unavailable rather than free (A-17).
> - A bucket with nothing booked reads "No data", never "Priced" (A-26).
> - The quantity analysis stays visible whenever the cost is unavailable.
> - **Standing charges (D-40, A-18):**
> - A standing charge accrues per local day, at value ÷ days in that local month, over the scope's service period
> (install or first data to retirement or now, regardless of reading gaps).
> - Type- and global-scoped charges are their own rows, **once per scope**, never copied onto each meter.
> - Meter-scoped fees stay on their meter. A fee on a meter that no bill line prices is its own row.
> - **Manual costs (D-41):**
> - A manual cost is booked in full, **once**, on its `PeriodStart` local day when that day is in the period and not
> after today. `PeriodEnd` is informational.
> - They count in the Overview, the Analysis page, categories and the export alike.
> - **Categories (D-42, A-22):**
> - A category's cost is the priced **non-overlapping cover** of its members, plus its manual costs.
> - The disjoint categories, *Uncategorized* and the standing-charge rows form the **composition**, which reconciles
> to the bill.
> - A category that overlaps another, or covers meters outside the bill, is an **overlapping view**. It is listed
> apart, never summed, and never drawn in the donut. The donut is drawn only when every slice is ≥ 0; otherwise
> signed bars are used.
> - A category whose members price nothing (calculated views, generation, runtime) says so rather than "no data".
> - **Virtual meters (D-39, A-15):** they are costed by their named rule:
> - `sourceCosts` (pure sums only): each physical source's own metered cost, once, with no scope-level standing
> charges.
> - `ownQuantity` (linear formulas without a constant): the evaluated quantity at its unit price.
> - `none`: every other formula, and generation sums (generation is never billed), which is why Summe Solar is not
> costed.
> - The rule is named next to every virtual cost. A virtual meter enters the bill only through the `always`
> override, and then replaces its sources.
> - The REST API reports a meter without a cost rule as `NotPriced` with the reason (A-21).
> - **Currency (D-43):** the configured `MeterVault__Currency` everywhere, through `Format.Money` / `InstanceCurrency`.
> A tariff in another currency is a unit mismatch; no conversion is made.
> - **Changes (D-07, A-23):** a cost change is stated only between complete figures, by one rule on every page
> (`OverviewComparison.Between`). If both totals are complete, it compares the totals. Otherwise it compares only the
> paired buckets that are complete on both sides, with the caption "over the part both periods cover".
---
## 8. Dashboard & UX
> **Deviation (analysis rework, brief §3–§8, D-46 D-55, D-58):** the panels below were rebuilt as one set of pages
> that share one period contract, one reader pair and one set of components. §8.0 describes what they share; the
> notes under §8.1 §8.7 say what each page now is.
### 8.0 Shared analysis contract (current)
**Pages and navigation (D-47, D-48):**
| Sidebar entry | Route | What it is |
|---|---|---|
| Overview | `/` | One selected period (default month to date): the cost with its composition, a card per energy type (quantities in their own units, cost with billing basis, change, freshness), the history chart, "What changed", attention items |
| Analysis | `/trends` (route kept) | Explore any scope: `portfolio`, energy `type`, cost `category`, one `meter`, or up to six `meters` side by side, by quantity or cost |
| Meters | `/meters` | All meters with period values, status, resolution, "data up to", how each counts, quick entry, filter by type |
| Energy types → *type* | `/energy/{id}` | Tabs `overview`, `history` (total or `view=meters`), `flow` (Sankey + table + Manage connections), `meters` |
| Specialized views | `/solar`, `/consumables` | Always listed; each shows a setup state when unsupported (no generation meter, no tank) |
| Data import | `/import`, `/import/wizard` | Import batches with revert; the CSV mapping wizard |
| Configuration | `/admin/*` | Energy type *definitions*, tariffs, cost categories, connectors, settings |
- The meter page `/meters/{id}` is the per-meter hub. Its tabs are `analysis`, `readings`, `normalized`, `events`,
`tariffs`, `sources`. A virtual meter shows `analysis`, `events`, `tariffs`, `calculation`: no Readings or
Normalized data, and Calculation instead of Sources.
- Old links still work: `tab=consumption` opens `normalized`, and on a virtual meter `sources` opens `calculation` and
`readings` opens `analysis`. Tabs are resolved by key and meter mode, never by index.
- One-shot `action=` links (reading, swap, reset, delivery, tank level, note, edit, source) open their dialog once and
are then dropped from the address.
- Breadcrumbs (Overview → type → meter) carry the period. Expanded sidebar groups persist in a cookie (`mv-nav`), and
the group of the current page is always open. If the energy types cannot be loaded, the nav shows an error with
Retry instead of dropping the group.
- The app-bar "Find a meter" (a text button from the md breakpoint, an icon below) opens a meter's Analysis tab with
the current period, next to the quick-entry action.
**Period and URL state (D-01 D-08, D-46, A-13, `AnalysisQuery`):**
| Key | Values |
|---|---|
| `scope`, `id`, `ids` | `portfolio`, `type`, `category`, `meter`, `meters` (`ids`, at most 6) |
| `metric` | `consumption`, `generation`, `export`, `runtime`, `net`, `cost`, `balance` |
| `period` | `mtd`, `last-month`, `ytd`, `prev-year`, `12m`, `24m`, `all`, `custom` (with `from`/`to`, local, inclusive) |
| `bucket` | `auto`, `day`, `week` (Monday), `month`, `year` |
| `compare` | `none`, `prev-period`, `prev-year`, `year:YYYY` |
- The URL is the state: reload, share and Back reproduce the page. Defaults are never written: the Overview uses
`mtd`, history pages `12m` (12 calendar buckets ending with the current partial month), and every page compares with
the previous year (A-13).
- Toolbar and tab changes replace the history entry; drill-downs push. An invalid token falls back to the default
with a notice.
- `all` spans the scope's available data, never a fixed century.
- `auto` picks one bucket, at most 400 points per series. A finer explicit bucket is refused with a coarser
suggestion.
- A period resolves once per request against a captured "now" in the configured zone (§10). Quantities, costs,
comparisons and the export use the same half-open bounds.
- Comparisons shift in calendar units. The change figure is measured only over the range both periods cover, and both
exact ranges are shown (D-07). The percentage is "not applicable" for a zero or negative baseline; the absolute
difference is always shown (D-08). Colours depend on the metric: more generation is good, more consumption is not.
- Projections are separate, labelled with their method, and suppressed where coverage is insufficient (D-09).
- Pages load through a `LoadSequencer`, so a superseded load never overwrites a later one. Panels show a
loading/refresh/error-with-Retry state.
- Other page-specific keys: the energy page's `tab` and `view`, the Overview's `chart`, the record tabs' `from`/`to`.
**Missing vs zero vs not priced (brief §4.3, D-14, D-38, A-24 A-28):**
- Every figure carries its bucket status (§5.4) and provenance (measured, manual, imported, estimated, derived,
opening balance), worded next to it (`FigureText`).
- A true zero is a number and a bar on the baseline.
- An unknown bucket is a gap in the chart, marked "", and "—" with its reason in the table: no data, only coarser
data, cannot be calculated, being prepared.
- Qualified values (partial, estimated) are marked "*".
- A period without data says "No data for this period", names the available dates and offers "Go to latest data". A
future range says it has not started yet.
- A chart never says "no data" when the reason is a coarser resolution (it names the resolution and offers the
interval that shows it) or a missing price (it names the cost status).
- Drill-downs only go where finer data exists. A monthly bucket never opens 31 unknown days. A physical meter's finest
bucket opens its Normalized data. A virtual meter's opens its own analysis over that bucket, whose source list links
on to the sources' records.
- Missing prices, stale sources, invalid calculations, rows dated after now and possible overlaps become attention
items, each with one targeted action (D-53). For example, "Add tariff" opens
`/admin/tariffs?scope=&id=&component=&from=&action=new` prefilled with the first uncovered month (D-52).
**Shared components (`Components/Shared/Analysis`):** `PageHeader`, `AnalysisBreadcrumbs`, `PeriodToolbar`,
`AnalysisChart` (ApexCharts; one axis per unit; signed values around a real zero line; follows the light/dark theme
in the current circuit), `AnalysisTable` (the accessible equivalent of every chart), `MetricCard`, `ChangeChip`,
`ValueStatus`, `EmptyPeriodState`, `PendingState`, `PanelError`, `LoadPanel`, `ProjectionNote`, `ComparisonSummary`,
`AttentionList`, `SeriesContributions`.
**CSV export (D-55):** `GET /export/analysis.csv` takes the same URL keys as the pages; every toolbar has "Export CSV".
It writes one row per bucket and series, with these columns:
- `series_id`, `series_name`, `kind`, `unit`;
- `bucket_start`, `bucket_end` (local ISO with offset; the end is exclusive, and a to-date bucket ends at now),
`timezone`;
- `value` (invariant, full precision; empty when unknown), `status`, `provenance`;
- `cost`, `cost_status`, `currency`, `comparison_value`.
A bucket with nothing booked is `Missing`, never `Available`. Invalid requests (a notice, too many buckets, an unknown
scope) get a 400 with a plain-text reason. The endpoint is a UI endpoint, like the pages, and needs no API key.
### 8.1 Overview
- KPI cards: **Today**, **This month**, **This year** cost — each with Δ (absolute + %) vs the previous comparable period and an ↑/↓ indicator.
- "Cost now" total across all categories.
> **Deviation (D-58, brief §7.1):** the Overview shows **one selected period** instead of Today/This month/This year
> cards. It has a period toolbar and defaults to month to date. The page shows:
>
> - the cost of the period, split into metered use, standing charges, manual costs and feed-in credit;
> - one card per energy type, with its measures in their own units (unlike quantities are never added), the cost with
> its billing basis, the change, freshness, and a link to that type with the same period;
> - the history chart with a metric selector (`chart=`), the previous-year overlay, a table toggle and drill-down;
> - "What changed", by category or by meter, with rows linking to the scoped Analysis page with the same dates;
> - the cost composition (§7.5): a donut only for non-negative disjoint slices, otherwise signed bars. Overlapping
> views are listed apart;
> - attention items, and "Latest month with data" with its month and basis (meters, manual costs or both, D-19).
>
> Changes are measured over the coverage both periods share (D-07). A period without data offers "Go to latest data";
> the page never switches to history on its own. Missing categories or tariffs are small setup notes, never a
> prerequisite for seeing quantities. The REST summary keeps its legacy month/year windows (D-45).
### 8.2 Cost breakdown / "what costs most"
- Stacked bar or donut by `cost_category` for a selectable period; ranked list (most → least).
- **Difference view** (explicitly requested): a table answering *"what cost more, what cost less this time"* — per category **and** per meter, **this month vs last month** and **this year vs last year**, columns `now | previous | Δ | Δ% | ↑/↓`, sorted by absolute impact.
> **Current:** both live on the Overview for the selected period and its comparison, not for fixed month/year windows.
> The composition is §7.5's. "What changed" lists categories or meters with current, previous, change and percentage
> where applicable, sorted by impact, plus a bill total row. A change is shown only between complete figures (A-23).
### 8.3 Trends
- Consumption and cost over time; **granularity toggle** day/week/month/year; per-meter or per-category; **previous-year overlay**.
> **Current:** this is the **Analysis** page (`/trends`, brief §7.4, nav "Analysis"). It offers:
>
> - Scope: all energy types, one energy type, a cost category, one meter, or a comparison of up to six meters. A
> seventh is refused with an explanation.
> - Metric: only what the scope supports. A category is always available by cost, and by quantity only when all its
> meters share one kind and unit; otherwise the page explains why and offers the alternatives.
> - The shared toolbar, with a calendar-year select (compare with any of the five years before), the chart (overlays
> up to three series; above that the comparison stays in the table), the table, drill-down and CSV export.
> - For per-type measures, total use and grid import side by side, never added. Portfolio cost is the same bill the
> Overview shows, with manual costs once.
### 8.4 PV / Solar panel
- Generation, self-consumption, grid feed/draw, **savings (Ersparnis)**, **autarky %**, **self-consumption %**. Time-filtered.
> **Current (D-54, `SolarService`):** one section per energy type that has generation. Nothing is inferred from names:
> meters are found by mode and by the effective roles `total_load`, `grid_import`, `grid_export` (A-07).
>
> - **Generation** is the type's generation measure, so a virtual sum such as Summe Solar is listed as a view and never
> added twice.
> - **Self-consumption** is total load grid import, or else generation grid export.
> - **Feed-in** is the grid export meter, or else generation self-consumption (labelled as calculated; batteries are
> not modelled).
> - **Site use** is the total load meter, or else self-consumption + grid import.
> - **Savings** are self-consumption × the grid unit price, month by month through the cost calculator.
> - Autarky % and self-consumption % are shown when the roles allow.
>
> Every figure shows its status and how it was obtained. Units come from the meters; mixed units give "cannot be
> calculated". A missing role gets a setup card with the candidate meters, which lead into the meter editor (no raw
> role tags). The page has no CSV export, because the export has no derived measures.
### 8.5 Oil / consumable panel
- Tank level (cm + L), balance vs capacity gauge, deliveries log, burner runtime, effective **L/h** (fixed/empirical), **forecast to empty**, monthly cost.
> **Current (D-54, `ConsumableService`):** "Now" and "Selected period" are separate parts.
>
> - **Now:** the last dipstick with its date (and cm reading), an estimate that includes the deliveries since then,
> the fill bar (only when the level is known), and the forecast as a labelled projection (§7.3).
> - **Selected period:** use from the analysis reader, the deliveries of the period only, burner runtime of the type's
> runtime meters, and the burn rate (fixed, or empirical when runtime is in hours).
> - A period that ended shows the contents at its end.
> - The cost stays unknown, never 0 €, when the tank has no tariff or its months cannot be placed (A-16).
### 8.6 Meter detail
- Raw readings, normalized consumption, source status (last-seen, last value), tariff timeline, events (swaps/deliveries/corrections), measured-vs-estimated markers.
> **Current (brief §7.2, D-47, D-50):** the header carries identity, the energy type, mode and retirement chips, and
> the actions: primary entry by mode (Add reading / Record tank level), the "Record event" menu and Edit. The tab bar
> sits directly below the header.
>
> - **Analysis** tab:
> - The quantity card in the normalized unit (D-20), with any projection shown separately inside it.
> - The cost card, with its rule named or "Not costed" plus the reason, and the cost change (A-23).
> - The previous-year overlay, a full chart, the table, drill-down and CSV export.
> - Events and tariff changes inside the range, listed as context under the chart.
> - A "Data quality and coverage" section: resolution, data range, freshness, opening balance with "Set install
> date", and rows recorded after now.
> - For virtual meters, the source contributions.
> - **Readings**, **Normalized data** and **Events** tabs:
> - Server-side paging, 100 rows per page, keyset-ordered, filtered by the page's `period`/`from`/`to`.
> - Their toolbar shows the whole range listed, and rows dated after now carry an "After now" mark (A-29).
> - The Readings tab explains that raw readings are the audit record and that raw retention is not enforced.
> - **Tariffs** lists meter, type and global tariffs with their effective end and the one that applies now, plus "Add
> tariff for this meter".
> - **Sources** links each source's connector to its editor (the connector detour keeps the typed draft).
> - A virtual meter's **Calculation** tab shows the status, the formula with meter names beside each `m<id>`, result
> kind and unit, cost rule, the meters read (also through nested calculations), and any problem with its dependency
> path.
> - The manual-entry dialog runs its own queries for the entered time, so its verdict never depends on a page of rows.
### 8.7 Admin / config
- CRUD for energy types, meters, sources, tariffs, cost categories, connectors; retention & locale/currency settings; import wizard; API keys.
> **Current (D-21, D-23, D-31, D-37, D-52, A-27, A-30):**
>
> - **Meter editor** (shared `MeterEditor`):
> - Roles use friendly names and one-line meanings, and are offered only for compatible modes. Saving a role moves it
> and names the meter that held it.
> - The totals override (Automatic / Always / Never) states its meaning and "In the totals now: …". A conflicting
> "Always" is refused, naming the other meter.
> - A virtual meter gets the calculation editor: Sum, Difference or Formula mode; sources picked by name, with unit,
> kind and dates; only valid cost rules offered, each with its reason.
> - A live preview uses the page's period (every preset, custom dates, all history) and shows per-source values and
> the incomplete months.
> - Saving a Sum can bring the incoming links in line with its sources, but links never change a calculation.
> - **Tariffs:** the deep link opens a prefilled dialog once, scoped to what can price that meter or type. Units are
> checked live, a new tariff needs a value, and Bonus/Discount/Tax are marked "not applied".
> - **Energy types** under Configuration edit the definitions ("Energy type definitions"); analysis lives under the
> Energy types nav group.
> - **Settings** is read-only. It shows the zone, the currency, raw retention ("Not enforced"), the normalization
> revision and zone, how many meters have current analysis data or are waiting for a rebuild, and calculated meters
> by status.
> - Flow topology is edited from the energy type's Flow tab ("Manage connections"): cycle-, type- and
> duplicate-checked, and it never touches a stored formula. Physical meters can also set their upstream meters in
> the editor.
> **Legacy monthly history:** imported data is monthly-granular. Offer per-import choice: keep native monthly buckets, or **linearly interpolate to daily** (energietracker-style) so old and new data render on the same axes. Interpolated points are marked `quality = interpolated`.
>
> **Current behaviour (D-14, D-57):** monthly data stays monthly and is never interpolated. A day or week bucket over
> it reads "only coarser data", the chart names the data's resolution and offers the interval that shows it, and a
> drill-down never opens days a monthly import cannot resolve (D-51).
---
@@ -496,17 +933,54 @@ OpenAPI/Swagger published. API-key auth for automation endpoints; UI uses the se
| `GET /api/v1/sources/status` | Connector/source health. |
| `GET /healthz` | Liveness/readiness (for Gatus). |
> **Current contract (D-45, A-16, A-21; pinned by `ApiContractTests`):** `/consumption`, `/cost` and
> `/dashboard/summary` keep every existing field, name and type. Their numbers now come from the analysis reader and
> the cost engine, so they match the pages. What changed is only added as new fields:
>
> | Endpoint | Behaviour | Added fields |
> |---|---|---|
> | `GET /api/v1/consumption?meter=&from=&to=` | Monthly rows as before. Instants with any offset are accepted (converted to UTC; an offset used to be a 500). Actuals stop at now. A month without data is absent, not a 0. A virtual meter is evaluated from its formula. | `status` (BucketStatus), `issue` (why a value is not plain), `kind`, `unit` (normalized) |
> | `GET /api/v1/cost?meter=&from=&to=` | `cost` stays numeric: 0 when nothing could be priced, with the reason beside it. The meter is priced by its rule (the bill line, a subsection at its unit price, a virtual meter by its cost rule). Generation and runtime meters, and meters that cannot be evaluated, are not costed. | `costStatus` (`Priced`, `Partial`, `NotPriced`, `PriceGap`, `UnitMismatch`), `costAvailability` (the status of the quantities behind the cost, e.g. `Unresolved`, `Invalid`), `costRule`, `notCosted` (the reason), `missingPrices[]` (component, reason, scope, first/last month, tariff, unit issue, credit), `status`, `issue`, `kind`, `unit` |
> | `GET /api/v1/dashboard/summary` | Keeps its legacy windows (calendar month and year to now against the whole previous ones) but prices the bill (§7.5). | `deltaPercentApplicable` per KPI (the percentage is 0 and not applicable for a zero or negative baseline), `latestMonth` `{period, basis}` |
>
> `/consumption` and `/cost` take one `meter` and answer by calendar month, as before the rework; the table's `scope`
> and `bucket` parameters are not implemented. The UI's analysis CSV (`GET /export/analysis.csv`, §8.0) is not part of
> `/api/v1` and needs no API key.
---
## 10. Non-functional
- **Time & DST:** store UTC; bucket and display in the instance timezone (default `Europe/Berlin`). "Daily cost" boundaries are local-midnight — use `time_bucket(..., 'Europe/Berlin')`.
*Deviation (D-01 D-06, D-58):*
- **Zone:** the **configured** zone (`MeterVault__TimeZone`, normalized to its IANA id) is used everywhere:
normalization, rollups, readers, periods, the export. Berlin is never hard-coded, and a zone change rebuilds the
rollups.
- **Bounds:** a period resolves into a local inclusive date range for display and a **half-open** UTC range
`[from, to)` for queries. `to` is the local midnight after the end date, or the captured "now" for to-date
periods.
- **Calendar:** days start at local midnight, and a DST day really has 23 or 25 hours. Weeks start on Monday. Months
and years are local.
- **Clock:** "now" is read once per request from the registered `TimeProvider` (`InstanceClock` for pages).
Services never read the clock; tests freeze it.
- **After now:** rows whose interval ends after now are never counted as actuals; they are reported as "recorded
after now" (D-04).
- **Auth:** optional built-in local accounts; **reverse-proxy trust** mode honouring `X-Forwarded-User`/`Remote-User` behind Authelia/Traefik; API keys for machine access. Default: single admin user + one ingest API key.
- **Observability:** `/healthz`, structured logs (Serilog), optional Prometheus `/metrics`.
- **Config:** environment variables + a settings UI; secrets via env/Docker secrets or encrypted at rest (never in DB plaintext) — see §6.4.
- **i18n:** `en` (default for OSS) + `de`; locale-aware number/currency/date. Ship a German locale that matches the source data conventions.
- **Backup:** document `pg_dump`/Timescale backup; provide a full **JSON export/import** for portability.
- **Performance:** dashboards read aggregates only; raw reads paginated and time-bounded.
*Current:* dashboards read the rollup tables (§5.4). A request sends a constant handful of statements, whatever the
meter count. The point limit (400 per series) and the series limit (6 on a chart) are enforced before any SQL runs.
The brief's target is a 10-year monthly request for 100 meters in under 2 s; it was measured against a synthetic
1,000-meter × 10-year dataset (§13, `docs/ANALYSIS_REPORT.md`). Raw record tabs are paged (100 rows, keyset) and
date-filtered.
- **Accessibility & layout (brief §8):** every chart has a table equivalent; colour is never the only cue (words,
arrows, signed values, dashed overlays); visible focus rings; keyboard-reachable drill links; no page-wide overflow
at 360 px; charts follow the light/dark theme (cookie `mv-theme`) within the circuit. MudBlazor's own labels are
localized (`MeterVaultMudLocalizer`).
- **Currency:** `MeterVault__Currency` (default `EUR`) for every amount (D-43).
---
@@ -528,6 +1002,18 @@ OpenAPI/Swagger published. API-key auth for automation endpoints; UI uses the se
/docs architecture, setup, HA/Tasmota wiring, API, screenshots
```
> *Current layout:* the analysis rework added these folders.
> - `src/Core/Analysis`: pure rules for time and periods, coverage, rollups, quantities and units, totals and category
> cover, virtual formulas, and the cost calculator.
> - `src/Infrastructure/Analysis`: the reader, the catalog and the virtual upgrade.
> - `src/Infrastructure/Costing`: `CostReader` and `BillRun`.
> - `src/App/Analysis`: the URL contract and the chart, table and attention models.
> - `src/App/Components/Shared/Analysis`: the shared components.
> - Page folders under `src/App/Components/Pages`.
>
> The fixtures live in `sampledata/`. CI is Gitea Actions (`.gitea/workflows/`), publishing to the Gitea container
> registry. `CLAUDE.md` holds the maintained layout.
- **CI (GitHub Actions):** build → test (spin Timescale) → publish Docker image to **GHCR** (amd64; add arm64 if desired) on tag.
- **License:** pick before release — **MIT** (max adoption; matches energietracker/your prior assets) or **AGPL-3.0** (keeps hosted forks open). Default suggestion: **MIT**, unless keeping SaaS forks open-source matters to you.
- **Docs:** a "wire up HA/Tasmota" guide is the highest-leverage doc for adoption.
@@ -545,6 +1031,16 @@ OpenAPI/Swagger published. API-key auth for automation endpoints; UI uses the se
- **M6 — API & auth.** REST + OpenAPI; API keys; reverse-proxy trust. *Exit:* HA can push via `POST /readings`; Swagger published.
- **M7 — Release polish.** i18n (de/en); retention settings; JSON export/import; Unraid template; CI → GHCR; README + wiring guide. *Exit:* `docker compose up` from a clean host yields a working, documented instance.
> *After M7:* the dashboard/analysis rework was built in five phases:
> 1. shared semantics and fixtures;
> 2. virtual evaluation and migration;
> 3. history and navigation;
> 4. Overview and specialized pages;
> 5. integration, performance and documentation.
>
> It replaced M4's continuous aggregates and "monthly + prorated" cost view with rollup tables and the month-by-month
> bill (§5.4, §7.5), and M5's panels with the pages of §8.0. M7's "retention settings" remain display-only (D-57).
---
## 13. Testing strategy
@@ -554,21 +1050,89 @@ OpenAPI/Swagger published. API-key auth for automation endpoints; UI uses the se
- **Golden fixtures:** the four CSVs with expected monthly consumption/cost tables. A regression test asserts computed ≈ spreadsheet (define tolerance for rounding; the sheet rounds to cents / whole kWh).
- **Load smoke (optional):** synthetic 1000-meter × N-year generator to validate aggregate query latency and compression ratio.
> **Current (analysis rework, D-56):** `Core.Tests` has 1,733 tests and `Integration.Tests` 746, all passing at the end
> of the rework. The continuous-aggregate refresh test was replaced by
> `CostReconciliationTests.Monthly_rollup_equals_the_consumption_it_sums` (water Dec 2022 = 14 m³). `SchemaTests`
> pins that the aggregates and their jobs are gone and that the analysis tables cascade with their meter.
>
> - **Frozen clock, pure (Core):**
> - `PeriodResolverTests`, `ComparisonResolverTests`, `BucketPlannerTests`: local New Year, Berlin DST in spring
> and autumn (23 h and 25 h days, the repeated hour), 29 Feb, 31 March against February, shorter months, New York
> (a zone behind UTC).
> - Coverage, rollups, provenance and freshness: `Coverage*Tests`, `RollupBuilderTests`, `MatchedCoverageTests`,
> `ProvenanceRulesTests`, `FreshnessRulesTests`.
> - Totals and categories: `TotalsPolicyTests` (the seeded classification, D-22), `CategoryCoverTests`,
> `SeparatelyBilledSubmeterTests`.
> - Virtual formulas: `FormulaParserTests`, `VirtualValidatorTests`, `VirtualEvaluatorTests`, `DependencyGraphTests`,
> `LegacyVirtualDerivationTests`: A+B, AB, missing vs zero, nested, loop, division by zero.
> - Costing: `CostCalculator*Tests`, `CostingTariffBookTests`, `TariffUnitTests`.
> - Units and changes: `UnitsTests`, `ChangeTests`.
> - **Reader and costs (Testcontainers):**
> - `AnalysisReaderTests`: the worked examples, local days across DST and in New York, rows after now, new readings
> and corrections visible on the next read.
> - `AnalysisDataTests`: rollups written by diff, rows removed behind the tracker's back.
> - `CostReaderTests`: missing vs zero tariff, price gaps, bucket-independent totals, standing charges once per
> scope, manual costs once, virtual cost rules. `CostReviewFixTests` adds a category whose members price nothing.
> - `SeededBillTests`: the D-44 goldens. The yearly bill equals the sheet's `Jahreskosten` within ±0.02 € for 2022,
> 2025 and 2026; 2023 and 2024 are pinned to the documented 3.78 € / 0.46 € differences. It also pins the
> composition and a manual-cost-only instance.
> - `CostConsistencyTests`: the same cost change on four pages, and matching standing charges.
> - `CoverageOfFixturesTests`, and the unchanged reconciliation suites (electricity, water, oil, costs; Netz
> Einsparung through the new evaluator, D-29).
> - **Contracts and export:** `ApiContractTests` (fields and types of `/consumption`, `/cost`, `/dashboard/summary`,
> offsets, not-costed meters), `AnalysisExportEndpointTests`, `AnalysisCsvWriterTests`, and `ExportRoundTripTests`
> (links and virtual definitions remapped, a deleted meter's tariffs not restored).
> - **Pages without a browser:**
> - Loaders against a database: `MeterAnalysisLoaderTests`, `AnalysisPageLoaderTests`, `EnergyTypePageTests`,
> `OverviewDataTests`, `SolarServiceTests`, `ConsumableServiceTests`, `MeterDraftPreviewTests`,
> `VirtualManagementTests`, `MeterDetailServiceTests` (keyset paging, half-open filters).
> - Pure UI models: `AnalysisQueryTests`, `AnalysisNavigationTests`, `AnalysisChartModelTests`,
> `AnalysisTableModelTests`, `AttentionItemsTests`, `LoadSequencerTests`, `MeterPageLogicTests`,
> `EnergyPageTests`, `OverviewLogicTests`, `MeterEditorLogicTests`, `TariffEditingTests`.
> - Server-rendered HTML in EN and DE: `DashboardRenderTests`, `OverviewPageTests`, `AnalysisComponentRenderTests`,
> `AdminPagesRenderTests`, `MeterSourcesRenderTests`.
> - `StringResourceTests`, `EnumDisplayNameTests` and `FormatCultureTests` pin both languages and the currency.
> - **Browser checks:** interactive ApexCharts, browser history, theme switching and responsive layout were checked
> against the seeded instance. CDP scripts drove Chrome in EN/DE, light/dark, 1440/390/360 px. That acceptance walk
> is recorded in `docs/ANALYSIS_REPORT.md`. No bUnit or Playwright suite is in the repository.
> - **Performance** (`tests/Integration.Tests/Performance`, trait `Category=Performance`): skipped unless
> `METERVAULT_PERF=1`. `SyntheticLoadTests` loads a deterministic 1,000-meter × 10-year dataset (≈1.34 M readings;
> monthly, daily and hourly meters, tanks, roles, links, 20 virtual meters nested up to three levels) through the
> real pipeline. `ReaderTimingTests` times the reader and cost scenarios (the brief's target: 100 meters, 10 years
> monthly, under 2 s), counts SQL statements, records query plans, the startup rebuild and per-meter recompute
> cost. Results are in `docs/ANALYSIS_REPORT.md`.
---
## 14. Open questions & defaults
Pick the **default** and flag it if unsure; only ask when a question isn't listed here.
1. **Virtual meters: compute-on-write or compute-on-read?** *Default:* compute-on-read for dashboards, materialize to `consumption` only if a virtual meter is referenced by cost. (Avoids recompute storms; revisit if slow.)
2. **Legacy monthly import → daily interpolation or native monthly?** *Default:* offer both per import; interpolation off by default, points marked `interpolated`.
3. **Cost proration when price changes mid-month.** *Default:* day-accurate proration available, but the **displayed** monthly figure uses the month's dominant price to match the spreadsheet unless the user opts into proration.
4. **Instant-rate (power/flow) integration in v1?** *Default:* schema-supported, worker deferred to post-v1 (Tasmota already gives cumulative `ENERGY.Total`, so it's rarely needed).
1. **Virtual meters: compute-on-write or compute-on-read?** *Default:* compute-on-read for dashboards, materialize to `consumption` only if a virtual meter is referenced by cost. (Avoids recompute storms; revisit if slow.) *Decided (D-58):* computed on read everywhere, costs included; nothing is materialized.
2. **Legacy monthly import → daily interpolation or native monthly?** *Default:* offer both per import; interpolation off by default, points marked `interpolated`. *Current (D-57):* native monthly only; interpolation is not offered.
3. **Cost proration when price changes mid-month.** *Default:* day-accurate proration available, but the **displayed** monthly figure uses the month's dominant price to match the spreadsheet unless the user opts into proration. *Decided (D-36, A-16):* one price per local month, the one valid on the 15th (the sheet's convention); no proration mode. Every bucket is priced month by month, so bucket size never changes a total. A reading interval longer than a month is priced as a whole only when its months share one price; otherwise it is unavailable, with an attention item. Standing charges accrue per day (D-40).
4. **Instant-rate (power/flow) integration in v1?** *Default:* schema-supported, worker deferred to post-v1 (Tasmota already gives cumulative `ENERGY.Total`, so it's rarely needed). *Current:* the `instant_rate` normalizer integrates the rate over time (trapezoidal), and a gap longer than max(1 h, 10 × the median sample interval) opens a coverage gap (D-13). There is no rate-specific ingestion worker: rate values arrive through the ordinary sources like any other reading.
5. **Multi-user?** *Default:* single admin + reverse-proxy trust; full accounts post-v1.
6. **.NET version pin.** *Default:* current LTS at implementation time; keep `TargetFramework` in one place.
7. **License.** *Default:* MIT unless you want AGPL's copyleft on hosted forks.
8. **Name.** `MeterVault` is a placeholder — decide before first public tag.
The analysis rework settled these further questions. Each is recorded in `ANALYSIS_IMPLEMENTATION_NOTE.md`.
9. **Raw retention (§5.5).** *Decided (D-57):* not enforced until recompute can start from stored consumption. The
setting is shown as "Not enforced".
10. **What a type's total and bill count.** *Decided (D-22, D-34):* the non-overlapping topology roots, with
consumption and generation apart. The bill counts grid import where there is one, otherwise use. Submeters and
virtual views are never added; the `always` override lets a virtual meter replace its sources (D-23).
11. **Virtual costs.** *Decided (D-39, A-15):* a named cost rule. `sourceCosts` for pure sums, `ownQuantity` for
linear formulas, `none` otherwise and for generation sums. The default for new virtual meters is "analysis only"
in the totals.
12. **Default comparison.** *Decided (A-13):* the previous year, at the same elapsed point, measured over what both
periods cover (D-07).
13. **Default periods.** *Decided (D-02):* the Overview uses month to date. History pages use the last 12 months: 12
calendar buckets, the current one partial.
14. **Missing tariff.** *Decided (D-38):* "not priced", never 0. An explicit zero tariff is a valid zero.
---
## Appendix A — CSV import dialect (from the reference files)
@@ -589,7 +1153,7 @@ Pick the **default** and flag it if unsure; only ask when a question isn't liste
|------------|---------------|
| Zähler (Haus/Netz/Auto/Solar) | `meter` (`cumulative_`/`generation_counter`) |
| Verbrauch / Erzeugung | `consumption.amount` (kind 0/1) |
| Solar Erzeugung / Eigenverbrauch / Ersparnis / Netz Einsparung | `virtual` meters via expressions |
| Solar Erzeugung / Eigenverbrauch / Ersparnis / Netz Einsparung | `virtual` meters via expressions (quantities only: Solar Erzeugung = `m(Solar 1) + m(Solar 2)`, Netz Einsparung = `m(Haus) m(Netz)`). Ersparnis is a price × quantity, which expressions do not support (§7.4). It is the Solar page's savings, or a virtual meter's `ownQuantity` cost. |
| €/kWh, €/m³, €/100l | `tariff.unit_price` (time-ranged) |
| Grundpreis / Abschlag | `tariff.base_price` |
| Betriebststunden | `runtime_counter` meter |