MAINT-20 part 1: price_history.py comments cut; FCST-04 note gets the store details

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-11-01 01:16:53 +01:00
co-authored by Claude
parent c08eb124c8
commit a3f725e8a4
2 changed files with 156 additions and 559 deletions
@@ -188,3 +188,64 @@ still raises; so does the same published point emitted twice; and a carried
point sharing an hour with a real print now raises too, which the count never
looked at. Each of those three is driven by its own mutation in
`test_price_forecast.py`.
## 5. Store details (moved here from `app/price_history.py` by MAINT-20)
**Why a file, and which one.** FCST-03 shipped the fill with no caller, so no
series ever contained a carried point. Without a yesterday that survives a
restart, the mornings after an operator touched something spend the whole
window on the truncated series. The cache is `/data/price_history.json`, not
`/data/state.json`: `Store.set()` rewrites the whole of state.json on each
write, and the refresh runs every `TARIFF_REFRESH_S` (1800 s), so 48 extra
rewrites a day would go through the file whose corruption reads as "no
maintenance ever ran". Separate file, separate blast radius. The directory is
not created (`tariff.PeakStore`'s rule): `/data` is always mounted, so a
`makedirs` could only succeed where the durable volume is missing and build a
cache on the ephemeral layer. "Cannot persist" is one question asked after the
attempt: see [[FCST-06]]; `self._days` never holds a point the file does not.
**Brussels date, not UTC.** 23:30 UTC in July is already tomorrow in Belgium.
`_previous_day()` subtracts on the local `date`, not the instant: the old
`brussels(stamp) - timedelta(days=1)` took 24 absolute hours and, at
2026-03-30T00:30+02:00, returned 2026-03-28 (a two-day-old profile flagged as
yesterday's). Rows are keyed by instant, not local hour, so the two 02:00s of
the 25-hour October day both survive and `fill_from_yesterday()`'s own rule is
the only collapsing rule. The merge key is the validated instant; the old
string key merged by spelling and sorted the `+01:00` 02:00 before the `+02:00`.
`KEEP_DAYS` is a sort-order cap, not a horizon: nothing is deleted by arithmetic
against the clock (a jumped clock, WITNESS-03, would otherwise delete everything
on the one morning the fill is for), and the two dates `yesterday()` can be
asked for are pinned on top of the cap, which can only retain.
**One validator (FCST-05, MEDIUM).** The read path was defensive about every
field and the write path trusted `q["start"]` to be hashable and sortable. An
int, null, dict or list `start` raised `TypeError` out of `carry()` on the day
being written; `run_tariff_refresh` caught it, so the add-on stayed up and every
refresh failed for good (the plan retires after `TARIFF_MAX_AGE_S`, arbitrage and
the cheap window go off, and `_remember()` raised before `_save()`, so no
self-heal). `_point_of()` is now the one standard for both paths and returns
`None` rather than raising.
**A broken cache is a short horizon, never a controller that will not start**
(6: absent machinery must not disable the loop). `PriceHistory(...)` and `.words`
are unguarded in `Controller.__init__`. Shipped defects, all fixed:
- `_load()` caught `json.JSONDecodeError` and `OSError` only; one non-UTF-8 byte
raised `UnicodeDecodeError` (a `ValueError`, not an `OSError`) and the add-on
did not start (FCST-05, HIGH). The handler names the `ValueError` family. The
schema version is compared with its type (`1.0 == 1` in Python).
- `{"days": {"2026-01-14": "oops"}}` loaded as healthy and raised
`AttributeError` out of `words`, `yesterday()` and `resolve_tariff()`.
`_usable_days()` drops such records, and counts (not drops) malformed rows
inside a kept record so the store stops reporting health it lacks.
- `words` printed `f"{d} ({_n_points(rec)} h)"`: points under the hour's name
(`'2026-01-13 (96 h)'` for a 24-hour quarter-hourly day, FCST-05 LOW). Both
numbers are printed now; only rows that are points are counted. `words` must
never raise, `_held()` is total.
- A failed merge is undone from a deep copy (a one-level copy shares the day
records the merge rebinds).
`NO_HISTORY` is a name, not `None`, so `history=` can be required: `None`
meaning "skip the fill" is how a caller silently gets the truncated horizon.
`carry()` records first, then fills, so the filled series exists only as a
return value that the store never sees again (rule 1).