Files
goodwe-addon/goodwe_controller/DECISIONS/TARIFF-13-the-coverage-of-a-peak.md
glenn schrooyenandClaude Opus 5 a991438e40
checks / unit (pull_request) Successful in 7m27s
TARIFF-13: the coverage figure beside the peak stops resetting at a restart
The capacity-tariff status row printed a peak read from SQLite next to a count
of banked quarters read from PROCESS MEMORY - `PeakTracker.recorded`, an `int`
set to 0 in `__init__` and counted up inside `sync()`. A 40-hour soak caught
what that means at a driven restart, on both instances independently:

  before: 2026-09: peak 365 W, set by the quarter at 2026-09-11T18:45:00+00:00
          - 10 % of the 3500 W soft limit; 120 quarter(s) banked
  after : 2026-09: peak 365 W, set by the quarter at 2026-09-11T18:45:00+00:00
          - 10 % of the 3500 W soft limit;   0 quarter(s) banked

Reproduced here before anything changed, through the real Controller and a real
restart - a second Controller object against the same file on disk - at 12 -> 0
with 12 rows standing in `quarter_blocks`.

The peak is durable and was never wrong. The COUNT is what says how much of the
billing month that peak covers, and 365 W over 120 quarters and 365 W over 0
quarters are different claims about the same number. Mid-month, after every
restart, the page made the weaker claim while displaying the stronger one's
value. That is TARIFF-01's own argument - an unreadable store must not read as a
zero peak, because a zero peak is disarming - one column over.

`PeakStore.banked_count(month)` asks the database, and `recorded` is a property
over it. Two more defects in the same variable fell out once it was driven, and
neither was in the ticket: it was never scoped to a MONTH (on 1 October the row
still showed September's tally beside October's empty month - measured), and it
missed the block `restore()` banks from the checkpoint, which never passes
through `sync()`.

⚠️ NOT 0, AND NOT None, WHEN THE STORE WILL NOT ANSWER. `banked_count()` returns
None and the row renders `an unreadable number of quarter(s) banked`.
`f"{None} quarter(s) banked"` was rejected because it reads as the same zero.
`0 quarter(s) banked` stays true and reachable - it is what a month nothing has
been written to says, and the two are asserted to be different text.

⚠️ THE DEDUPE IS NOT REMOVED WITH THE COUNTER. `_seen` still stops `cycle()`'s
once-a-second `sync()` re-banking a block `P1Ingest.blocks` keeps for a day. The
check that graded it asserted `recorded == 1` - the dedupe and the figure
reporting it were the same variable - and now grades `sync()`'s RETURN, because
counting rows cannot see a re-bank at all (PRIMARY KEY no-op).

THE TIE RULE, DECIDED RATHER THAN EMERGENT: at equal wattage the EARLIEST
quarter keeps the attribution. No bill and no guard moves - `peak_w` is still
only ever raised, the `>` term is untouched, SAFETY-07 reads the same watts.
The two readers DID disagree: the upsert's strict `>` kept whichever tied block
was WRITTEN first while `recompute_month()` has always kept the earliest, the
same only while blocks arrive in time order, and measured to diverge (09:45
against 09:00) fed newest-first.

⚠️ AND THE SOAK'S DRIFTING QUARTER WAS NOT A TIE. Six exactly equal 365.0 W
quarters re-attributed zero times on the shipped tree - `>` is strict. What
moved it was a real new maximum BELOW THE ROW'S OWN RESOLUTION: the row renders
whole watts, so 365.0000001 W is a new peak to the database and the same 365 W
to the reader. Left alone deliberately, and pinned so it is not re-diagnosed.

test_tariff.py parts 11 and 12. 12 mutations, all red, listed on the PR; M2 -
`banked_count()`'s sqlite3.Error arm returning 0 - was a SURVIVOR at -3 B on the
first battery, because a handle that never opened short-circuits before any SQL
runs and nothing reached the except arm. Closed with an open store whose table
is dropped under it, with a precondition that the same call answered 4 first.

main.py is NOT touched - TARIFF-11 holds its status rows. The row picks this up
without editing because `recorded` is an attribute access and is now a property.
The residual is stated in DECISIONS §3: `recorded` answers for now() while the
peak follows `checks(when=...)`, and the shipped install has exactly one call
site - `self.checks()` with no argument, main.py:4359 - where they are the same
month, so the live page is correct and durable. `recorded_in(when)` is shipped
and the row's move onto it is one token, for whoever holds main.py next; part 10
pins the present behaviour against a row count taken from `quarter_blocks` so
the gap cannot be quietly forgotten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-13 12:54:15 +02:00

11 KiB

TARIFF-13 — the coverage of a peak, and which quarter gets the credit

Two decisions. §1 is the defect the ticket was raised on; §2 is the smaller question it asked to be settled deliberately rather than left as an accident. §3 is what this branch deliberately did not do, and why.


0. What was actually wrong, stated once

main.py's capacity-tariff status row prints a peak read from SQLite and a coverage figure read from process memory:

_pk = self.peak.peak(when)                      # SQLite, durable
...
+ f"; {self.peak.recorded} quarter(s) banked"   # an int on the tracker

PeakTracker.recorded was self.recorded = 0 in __init__, incremented once per block inside sync(). A 40-hour soak caught what that means at a driven restart, on both instances independently:

before: 2026-09: peak 365 W, set by the quarter at 2026-09-11T18:45:00+00:00
        - 10 % of the 3500 W soft limit; 120 quarter(s) banked
after : 2026-09: peak 365 W, set by the quarter at 2026-09-11T18:45:00+00:00
        - 10 % of the 3500 W soft limit;   0 quarter(s) banked

Reproduced here before anything was changed, through the real Controller, the real P1Ingest, and a real restart — a second Controller object against the same file on disk, never a re-read of the first:

before: 2026-09: peak 365 W, set by the quarter at 2026-09-10T09:00:00+00:00
        - 10 % of the 3500 W soft limit; 12 quarter(s) banked
after : 2026-09: peak 365 W, set by the quarter at 2026-09-10T09:00:00+00:00
        - 10 % of the 3500 W soft limit;  0 quarter(s) banked

    peak survives the restart: True
    count survives the restart: False       (12 -> 0, store held 12 rows)

TARIFF-01's argument, one column over. That module's whole design is that an unreadable store must not read as a zero peak, because a zero peak is disarming. A coverage figure of zero is disarming in exactly the same way and about the same number: the count is what says how much of the billing month the watt figure actually covers, and 365 W over 120 quarters and 365 W over 0 quarters are different claims about the same 365 W. After a restart the page made the weaker claim while displaying the stronger one's value. It did not under-report the peak — it under-reported its own evidence.

Two further defects fell out of the same variable once it was driven, neither of them in the ticket:

  • it was never scoped to a month. It counted every block the process had banked, so on 1 October the row read September's tally beside October's "no completed quarter recorded yet". Measured, on the shipped tree: 2026-10: no completed quarter recorded yet - 0 % of the 3500 W soft limit; 1 quarter(s) banked.
  • it missed the block restore() banks. A stale block closed from the checkpoint goes into quarter_blocks without passing through sync(), so the counter never saw it.

1. Decision: the count is READ FROM THE TIER THE PEAK IS READ FROM

The acceptance criterion allowed either route — make the count durable, or stop printing a number that silently resets. Durable, because the count is the useful half of the row and removing it would have cost the operator the one thing that says whether the peak means anything yet.

PeakStore.banked_count(month) asks the database SELECT COUNT(*) FROM quarter_blocks WHERE month=?, and PeakTracker.recorded is a property over it rather than an attribute. Three consequences, and all three are the defects above:

  • a restart re-reads the same rows, so the figure survives it;
  • the count is per month, so it agrees with the peak it is printed beside;
  • a block banked by restore() is a row like any other and is counted.

Three things this route deliberately does not do:

  • it does not count len(blocks_in(month)). blocks_in() returns [] on a read error — safe there only because no caller reads its emptiness as a peak, which its own docstring says — so taking its length would rebuild the disarming zero one call further out. banked_count() returns None when the database will not answer.
  • it does not count monthly_peak. That tier holds one row per month and would answer 1 for every month ever measured — wrong by exactly the amount that makes it look plausible. The two tables are proven equal on the peak by recompute_month(); they were never equal on the count.
  • it does not remove the dedupe. _seen is still what stops cycle()'s once-a-second sync() re-banking a block that P1Ingest.blocks keeps for a day, and sync()'s return value is still the number newly banked. What moved is only where the status page reads its figure. ⚠️ The check that graded the dedupe used to assert recorded == 1 — the dedupe and the figure reporting it were the same variable. Counting rows in quarter_blocks cannot see a re-bank at all (it is a PRIMARY KEY no-op), so that check is now graded on sync()'s return, with the store's tally as the second half.

⚠️ The unreadable case renders a WORD, not a number

banked_count() returns int | None; recorded_in() renders the None as the module constant COUNT_UNREADABLE = "an unreadable number of", so the sentence reads

... ; an unreadable number of quarter(s) banked

0 was never available. None was rejected too: f"{None} quarter(s) banked" renders "None quarter(s) banked", which an operator reads as the same zero. This is the PeakReading trade — no peak_w_or(default), ever — applied to the coverage figure: a caller that formats it gets a true sentence in both states, and a caller that does arithmetic on it gets a TypeError, which is loud.

"0 quarter(s) banked" remains a true and reachable sentence: it is what a month nothing has been written to says, and part 11 asserts that the two states are spelled differently.


2. Decision: at EQUAL wattage, the EARLIEST quarter keeps the attribution

First, not most recent. The reasoning, in the order it matters:

  1. It is not a money question. The utility bills the wattage; the timestamp is not part of the bill. So the decision is settled entirely on what the status row is for.
  2. The row answers "when did my monthly peak happen". An operator reading it goes and looks at one event — what was running, what the weather was. A stable answer is the one that can be investigated; "most recent wins" guarantees the answer moves every time the wattage recurs, and an operator who looked yesterday is sent somewhere else today for no reason.
  3. It is what one reader already did unconditionally. recompute_month() is ORDER BY offtake_avg_w DESC, quarter_start ASC LIMIT 1 — earliest wins. Choosing "most recent" would have meant changing both readers; choosing "earliest" meant making the other one say out loud what it was already doing by accident.

⚠️ And the two readers DID diverge, which is why this is a code change

The monthly_peak upsert was WHERE excluded.peak_w > monthly_peak.peak_w — strictly greater, so an exact tie never wrote and the row kept whichever tied block was written first. That coincides with earliest only while blocks arrive in time order. Fed four equal 365 W blocks newest-first, measured on the shipped tree:

ascending arrival    monthly_peak -> 09:00:00   recompute_month -> 09:00:00   agree=True
DESCENDING arrival   monthly_peak -> 09:45:00   recompute_month -> 09:00:00   agree=False

That is a divergence in the derived record that part 8 of test_tariff.py exists to prove cannot happen — latent, because nothing in the shipped path banks out of order today. The upsert now carries the tie term explicitly:

WHERE excluded.peak_w > monthly_peak.peak_w
   OR (excluded.peak_w = monthly_peak.peak_w
       AND excluded.quarter_start < monthly_peak.quarter_start)

peak_w is untouched. The > term is exactly as it was, no tie can move the billed number, and SAFETY-07's guard reads the same watt figure it read before. The second term moves only the timestamp.

⚠️ AND THE SOAK'S DRIFT WAS NOT A TIE AT ALL

The ticket's reading — "ties at the same wattage are re-attributed to the most recent matching quarter" — is false, and driving it is what showed that. Six exactly-equal 365.0 W quarters on the shipped tree moved the attribution zero times, because > is strict.

What the soak saw was a genuine new maximum smaller than the row's own resolution. The row renders {peak_w:.0f} — whole watts — so 365.0000001 W is a new peak to the database and the same 365 W to the reader:

q0  fed 365.0        -> renders 365 W, attributed 09:00:00
q1  fed 365.0000001  -> renders 365 W, attributed 09:15:00
q2  fed 365.0000002  -> renders 365 W, attributed 09:30:00

Decision: left alone, deliberately. It is the instrument behaving correctly — the peak really did rise — and the alternative, printing more decimal places, puts noise in the one row an operator reads at the moment the guard engages, to explain a movement that is real. Both cases are pinned in part 12 so that neither can be changed by accident, and the distinction is recorded here so the next reader does not re-diagnose it as a tie.


3. What this branch did NOT do, and who owns it

main.py's status row was not touched. TARIFF-11 holds main.py's status rows on a concurrent branch, and the standing instruction is to stop rather than race there. Everything above is in app/tariff.py, and the row picks the fix up without editing, because self.peak.recorded is an attribute access and is now a property.

The residual, stated with its scope. recorded answers for datetime.now(). The peak beside it follows checks(when=...). The shipped install has exactly one call site — self.checks() with no argument, at main.py:4359 — where the two are the same month, so the live page is correct and durable. They part company only under the when= affordance TEST-17 added for the suite, and the mismatch there is strictly smaller than the one it replaces (this month's tally, rather than a process-wide total across all months).

PeakTracker.recorded_in(when) is the scoped form and is shipped. Moving the row onto it is:

- + f"; {self.peak.recorded} quarter(s) banked"
+ + f"; {self.peak.recorded_in(when)} quarter(s) banked"

one token, on one line, inside the block TARIFF-11 owns. test_tariff.py part 10 pins the present behaviour — graded against a row count taken straight from quarter_blocks, not against the property that produces it — so the gap goes red if it changes and cannot be quietly forgotten.