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

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

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

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

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

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

821 lines
62 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
- §14: amendments from the performance measurement.
- §15: amendments from the first reports of a live instance.
- §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`.
## 14. Amendments from the performance measurement
The measurement of brief §9.10 / D-56 on a synthetic 1,000-meter × 10-year instance named three costs that grow with
history rather than with what a page asks for. They are fixed here. No golden bill, reconciliation figure or displayed
value changes; only what a request reads does.
- **A-40 What a request reads (D-15, D-18, D-56).**
- **The freshness mark is stored, not searched.** `meter_rollup_state` gains `last_reading_at`: the stamp of the
meter's latest raw reading, written by the recompute that every write path already runs. D-18 is unchanged — the
last reading or event time is still the mark — but a request no longer queries `reading` to find it, so an
import-only meter's mark stays exactly as old as its data without planning across a decade of raw chunks. The
migration backfills the column in one pass, so nothing waits for a rebuild.
- **Only a live source's rhythm is sampled, and only from the recent past.** The median of D-18 is consulted for a
meter with a live source, so the reading times are read for those meters alone, bounded by
`FreshnessRules.RecentWindow` (90 days) — the bound is what lets PostgreSQL exclude the older chunks at plan time.
A live meter that delivered nothing inside the window has no rhythm there; those few meters are read again over
their whole history, so a long-silent source is still called stale by its own rhythm. An instance without any live
source reads `reading` not at all.
- **The window-sum statement carries its overall bounds.** Its windows arrive through an `unnest` join, so their
bounds are columns and exclude no chunk. The minimum start and maximum end of the window set are repeated as
constants in the `WHERE` clause. No row outside them can match any window, so no tally changes; with enough
windows it is the difference between an indexed probe and a parallel scan of the whole hypertable with a sort
spilling to disk.
- **One Overview load holds one catalog.** The page's quantities, its bill and its comparison's bill are three
figures of one period. `DashboardService.GetOverviewAsync` loads the meters, tanks, links and rollup states once
and passes that snapshot to all three (`CostReader.ReadAsync(db, catalog, …)`), so every figure answers from the
same snapshot and the load is not repeated. The cards, the change table and the composition are derived from those
results and are never priced again. `OverviewReadBudgetTests` asserts the statement count of one load.
## 15. Amendments from the first reports of a live instance
Three findings from one real instance: imported history only, a Heizöl type whose tank is dipped with a stick
a few times a year and whose burner hours are noted every few months. Two of them are about what a figure says
when the measurement is coarser than the question, one is about the order and the completeness of the lists
beside it. No figure, golden reconciliation number or seeded bill total changes.
- **A-41 A meter read a few times a year (D-05, D-13, D-14, D-07, A-02, A-03, A-06, A-24).** An instance with imported
history only, a heating-oil tank dipped with a stick once a year and a burner whose hours are noted every few months,
showed that three separate rules break down when a meter's *measurement* is coarser than the question asked of it.
The pages it reported are the energy type page, the Overview, the Analysis page and Tanks & consumables; all four read
the two readers, so all four are fixed by the three changes below.
- **A bucket waiting for a measurement is unresolved, not empty.** A dipstick interval books nothing until the next
dipstick closes it, so "last 12 months" opening eleven days after the last one is covered by no run at all. The
coverage evaluator called that *missing* — "No data for this period" — beside a coverage panel listing four years of
it, and beside a burner that read "Only coarser data" for the same period. It is the same situation A-04 already
names for a bucket that ends at now: what has accrued since the last reading is not yet known, and that is no
shortfall. So a bucket that no run covers, that no *gap* run overlaps (a known hole keeps its own reason) and that
holds no opening balance is `Unresolved` with `CoarseResolution` when the meter's last coverage before it cannot
place a bucket of that size anyway, and lies within one interval of its own class (`LimitOf`, unbounded for data
coarser than a month). It carries that resolution, so the card, the coverage panel, the chart's "only coarser data"
and the table agree; it carries no number, because there is none. A meter that books its own buckets as it goes —
an hourly source gone silent, a monthly sheet asked about a later month — is unchanged: a bucket it does not cover
really has no data. `CoverageEvaluator.AwaitsMeasurement`, pinned by `CoverageEvaluatorTests`.
- **Auto coarsens for the data only as far as the data gains by it.** `ResolutionClass.Coarse` means no more than
"longer than a local month", and `MinimumSizeFor` maps it to years. Auto therefore answered a 12-month request with
one bar per year — which resolved exactly nothing, because a dipstick taken every autumn straddles a New Year as
surely as it straddles every month start, so the yearly buckets were unresolved too. The floor now comes from
`ResolutionClassifier.PlanningResolution(runs, zone)`: one decision for the whole chart (a chart has one bucket
size, D-05), the coarsest class among the plotted runs, with divided runs counted as monthly (A-03) and with the
class capped at `Month` as soon as any coarse run in the range crosses a local year edge. A meter read on the
quarter, whose intervals all lie inside one year, still charts in years — years do hold each of those whole
(`CostReviewFixTests`). The 400-point limit and explicit sizes are untouched, and `MinimumSizeFor` keeps its
meaning for drill-downs (D-51) and for the coarser interval the empty chart offers (A-28).
- **A comparison line names the figure it is about.** Matched coverage (D-07) belongs to one series: on one page the
tank's use shared no covered day with the year before while the burner beside it compared over October to May. The
page stated the *use* measure's verdict — "Not comparable: the periods share no covered days" — directly above a
table that showed the burner's matched comparison, and beside a runtime card showing its change. `ComparisonSummary`
takes a `Subject`, and the energy type's Overview and History, Solar and the Overview's cost line pass the figure
the line speaks of whenever they show more than one. Both statements are then true. The energy type's coverage
panel also lists a measure's own availability whenever it differs from the scope's, which is what made "no data"
read as a contradiction in the first place.
- **A-42 Every dated list reads newest first.**
- A table is read from the top, so its first row is the period the reader is in. `AnalysisTableModel.Build` turns the
plan round once, after the rows are built and paired with the comparison by index, and puts the **total row above**
them — it sums what follows it. Every page that renders `AnalysisTable` inherits this: the meter's Analysis tab, the
energy type's History tab, `/trends`, Solar, Tanks & consumables and the Overview's history table. The plan itself
is never reordered, so nothing that indexes it (drill-downs, comparison pairs, chart series) has to know.
- The same rule applies to the other dated lists that were still ascending: a virtual meter's calculation preview
(and its capped warnings), the tariffs of a meter's Tariffs tab and of `/admin/tariffs` (newest validity first
inside each component, so the price in force is at the top), and the tariff changes among the meter page's
contextual markers. The markers are now **one** list: events and price changes interleave by local day
(`MeterMarkerList`), because two lists one after the other made the dates run down, jump back up and run down again.
- Already newest first, and left alone: the record tabs (readings, normalized rows, events — D-50 keysets), the
deliveries of a tank, and the import batches.
- **The chart stays chronological** — it is read left to right — and so does the **CSV export** (D-55): a file is
sorted, charted and differenced by whatever opens it, and every one of those expects time to run forwards.
- Lists that are not dated keep their own order: meters by energy type and name, the flow table by node name, a
virtual meter's source contributions by the formula, connectors and sources by name and priority.
- **A-43 A meter with no comparable change is listed, not dropped.**
- "Largest changes by meter" ranked the meters whose change could be measured over the days both periods cover (D-07)
and showed nothing at all about the others. On the reported Heizöl page that left only the burner: the tank, read by
dipstick a few times a year, has no matched coverage against last year and so vanished although it has data.
- `MeterChanges.Of` now returns three things: the ranked changes (unchanged rule), **every other meter that has data
in either period**, and how many the caps left over. A row of the second list carries the meter's two totals as the
reader read them, so the page words each of them with the same status text every other figure gets (`FigureText`,
`DisplayNames`): "No data", "Only coarser data", "Being prepared". Only one case needs more than that — both totals
known yet no shared day — and it is said with the page's existing sentence, `Comparison_NotComparable`.
- A meter with nothing on either side stays out: repeating "no data" twice is not information.
- The two lists are capped separately, and the unranked one is ordered with the meters that have no change at all in
front, so a long ranking can never crowd out the very rows this fix is about. What neither list holds is counted and
said out loud ("Showing 4 of 10 meters"), never silently dropped.
- The caption "A change is measured over the dates both periods cover" now stands above the ranked rows only.
- **Checked and left as they are:** the Overview's "What changed" (`DashboardService.LineRows`/`CategoryRows`) already
ranks rows without a comparable change last instead of dropping them; it drops only rows with no *money* on either
side, and those are exactly the ones the composition panel lists with their price coverage and the attention list
offers a tariff for (D-42, D-53). The attention list caps with a "show all", the meter list and the flow table drop
nothing.