Analysis: read a rarely-read meter as coarse, not absent; newest rows first
ci / build-test (push) Successful in 2m41s

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.
This commit is contained in:
Florian Schmidt
2026-09-20 12:44:32 +02:00
parent 5a6f34a467
commit a08e9f781f
38 changed files with 1301 additions and 184 deletions
+80
View File
@@ -9,6 +9,7 @@ The note was kept current through Phase 5:
- §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
@@ -738,3 +739,82 @@ value changes; only what a request reads does.
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.