Merge release/1.0 (TARIFF-01) into TARIFF-02
checks / unit (pull_request) Successful in 2m39s

TARIFF-01 (#17) landed on release/1.0 first, so this branch was two
commits behind and Gitea reported the PR unmergeable. Integrating rather
than rebasing: the two reviewed commits stay addressable, and the branch
is protected against force-push anyway.

ONE CONFLICT, AND IT IS THE BORING HALF OF THE ONE REVIEW PREDICTED.
Review of #16 expected collisions in four places - the tail of
`options:`, the tail of `schema:`, the rule list inside
validate_startup_config(), and the tail of validate.py. None of them
happened: TARIFF-01 touched neither config.yaml nor validate.py. It
added app/tariff.py, test_tariff.py, two P1 scenario captures, and edits
to main.py and p1.py, none of which this branch goes near.

The only conflict was CHANGELOG.md, where both branches prepended a
block under the same `## Unreleased` heading. Both blocks kept, TARIFF-02
above TARIFF-01 since it lands second. Nothing was dropped or reworded on
either side, and the resolution adds no line of its own beyond the blank
one between them.

Checked rather than assumed after the merge:
  - all ELEVEN test files pass, including TARIFF-01's own test_tariff.py
    (which this branch had never run) and the 93 s test_loop.py;
  - the 24-mutation battery still has 0 survivors;
  - no semantic overlap in config: TARIFF-01's peak tracker reads the
    peak_cap_w / peak_forecast_entity options that already existed, so
    the price options added here neither shadow nor duplicate anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
This commit is contained in:
2026-08-30 19:42:16 +02:00
co-authored by Claude Opus 5
7 changed files with 1748 additions and 1 deletions
+53
View File
@@ -81,6 +81,59 @@ UTC and rendered at the offset that applies to it. The checks drive both 2026
transitions - the 23-hour and 25-hour days - rather than a normal day, where a
fixed 24-slot implementation and a correct one agree.
**The capacity-tariff peak is now measured, banked and survives a restart
(TARIFF-01).** New `app/tariff.py`. The 15-minute block average itself was
already built and unwired - `p1.QuarterAverager` computes clock-aligned 900 s
blocks, time-weighted over irregular telegram spacing, offtake-only and
per-phase - and `P1Ingest.blocks` was a 96-entry list a restart erased. This
adds the SQLite behind it (`/data/tariff.db`): one row per completed quarter in
the indefinite 15-min tier, one row per calendar month in the permanent peak
tier, and no `DELETE` against either anywhere in the module.
**Blocks, not a sliding window.** The card says "rolling"; 9.1 bills "the
highest 15-minute average offtake", stated per QUARTER. "Rolling" here means
continuously updated, not a trailing-15-minutes window - a trailing window
reports a peak the meter never bills, and reports it HIGH, because a burst
straddling :15 fills it completely while contributing to two block averages at
partial weight.
**A restart mid-block resumes it, and a restart that outlives the block banks
it.** `QuarterAverager` grew `snapshot()`/`restore()`; the tracker checkpoints
the open block every 15 s (worst case 1.7 % of one quarter) and clears the
checkpoint in the same breath as a block close. The held sample keeps its
ORIGINAL timestamp across the restart, so the outage is integrated under the
same `max_hold_s` rule a running process would apply and uncovered seconds drag
the closed block DOWN rather than being held forward. `quarter_start` is the
primary key and a re-bank may only ever RAISE a figure, so a replay cannot
double-count and a partial re-run cannot lower a quarter already measured.
**An unreadable store is not a zero peak.** `peak()` answers `OBSERVED` /
`NO_RECORD` / `NOT_OBSERVED` and carries `peak_w = None` unless observed - the
`riglock.holder()` fix applied to something that spends money. A peak read as
0 W hands SAFETY-07 maximum headroom and silently disarms the guard at the
moment its own storage breaks, so there is no accessor that defaults it: a
caller who forgets gets a `TypeError`, and the status page prints a different
row for "no quarter yet" than for "the database did not answer".
**Calendar months are LOCAL, blocks are UTC.** A quarter starting 22:15 UTC on
31 August bills in September. Block alignment still needs no timezone.
**No new option, and no second peak entity.** The soft limit is the existing
`peak_cap_w`; `config.yaml` and `validate.py` are untouched, so CFG-03 is not
widened. `peak_forecast_entity` stays the input to `maintenance_charge_floor()`
- repointing that consumer changes what moves energy and belongs to SAFETY-07.
**It measures and nothing else.** No claim, no clamp, no command; graded on the
parsed syntax in `test_tariff.py`, which also proves the same grader lights up
on `main.py`. Per SAFETY-20, the quantity is DELIVERED power - `tariff.py` only
ever sees `P1Sample`, the meter's own reading, never a setpoint.
**AC 3 is not met and the check says so.** It asks for 30 days of replay; two
days of this house's real meter exist (2026-08-20, 2026-08-23), now committed as
`scenarios/p1-net-*.jsonl.gz`. `test_tariff.py` part 9 replays them, reports
the percentage under the shipped limit next to the sample size in days, and
asserts nothing about the 95 % figure.
**A computed setpoint over the rating now alarms instead of being clipped in
silence (SAFETY-03).** The clamp was never the missing half - specs.md 10.3
records an unclamped integrator climbing ~130 W per 4 s past 10 kW and reporting