TARIFF-13: the coverage figure beside the peak stops resetting at a restart #83

Merged
admin merged 1 commits from TARIFF-13 into release/1.0 2026-09-13 13:03:31 +02:00
Owner

The defect

The capacity-tariff status row prints 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

The peak is durable and was never wrong. The count is what says how much of
the billing month that peak covers: 365 W over 120 quarters and 365 W over 0
quarters are different claims about the same number, and 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.

Reproduced first, driven, before anything changed

Real Controller, 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; the store held 12 rows)

and after the change, graded against a row count taken from the other tier:

    banked count printed  before: 12   after: 13
    rows in quarter_blocks before: 12   after: 13
    the printed count MATCHES the store: True

⚠️ 13, not 12, and that is correct — the restart's restore() closed and
banked the stale open block. Which is a third defect the old counter had:
it incremented only inside sync() and never saw a block restore() banked.

Two more, neither in the ticket, both found by driving the variable rather than
reading it:

  • it was never scoped to a month. Measured on the shipped tree:
    2026-10: no completed quarter recorded yet - 0 % of the 3500 W soft limit; 1 quarter(s) banked — September's tally beside October's empty month.

The route taken, and why

Durable, not "stop printing it". The count is the useful half of the row;
removing it would 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 recorded is a
property over it. All three defects above close at once.

⚠️ None, not 0, and not len(blocks_in(month)). blocks_in() returns
[] on a read error — safe there only because no caller reads its emptiness as
a peak — so counting its length would rebuild the disarming zero one call
further out. The row renders the unanswerable case as
an unreadable number of quarter(s) banked; f"{None} quarter(s) banked" was
rejected too, because "None quarter(s) banked" reads as that 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.

⚠️ Not monthly_peak, which holds one row per month and would answer 1
for every month ever measured — wrong by exactly the amount that looks plausible.

⚠️ 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
. Counting rows cannot see a re-bank
at all (PRIMARY KEY no-op), so that check now grades sync()'s return.

The tie decision: EARLIEST wins

Recorded in DECISIONS/TARIFF-13-the-coverage-of-a-peak.md §2. The wattage is
what is billed, so this moves no bill and no guard — peak_w is still only ever
raised, the > term is untouched, SAFETY-07 reads the same watts. The row
answers "when did my monthly peak happen", an operator goes and investigates
one event, and a stable answer is the one that can be investigated.

⚠️ And the two readers genuinely diverged, which is why this is a code
change and not only a note. The upsert's strict > kept whichever tied block
was written first; recompute_month()'s ORDER BY ... quarter_start ASC has
always kept the earliest. The same only while blocks arrive in time order:

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

— a divergence in the derived record part 8 of test_tariff.py exists to prove
cannot happen. The upsert now carries the tie term explicitly and both readers
agree unconditionally.

⚠️ THE SOAK'S DRIFTING QUARTER WAS NOT A TIE, AND THE TICKET'S DIAGNOSIS IS
FALSE.
Six exactly equal 365.0 W quarters re-attributed zero times on the
shipped tree — > is strict, so an exact tie never wrote. What moved the
timestamp was a real new maximum below 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

Left alone, deliberately — the instrument is right, and printing more
decimals to explain it would put noise in the one row an operator reads at the
moment the guard engages. Both cases are pinned so neither changes by accident.

Mutations

Purged __pycache__ before every run; tree mounted :ro, so no .pyc could be
written into it either; each backup at a mktemp path inside this card's own
harness directory (habit 18 — two harnesses sharing one fixed path is how
SAFETY-37 lost a source file); fingerprinted after the revert, not only
before the run, and every revert verified.

# mutation (app/tariff.py) Δ result
M1 recorded property → return 0 — the shipped defect restored −17 B RED ×3
M2 banked_count()'s sqlite3.Error arm → return 0 −3 B ⚠️ SURVIVOR, then RED ×2
M3 count monthly_peak instead of quarter_blocks −2 B RED ×4
M4 recorded_in() ignores when, always reads now() −8 B RED ×3
M5 tie term < → > (most recent wins) 0 B RED ×3
M6 tie term deleted (back to > alone) −155 B RED ×2
M7 recompute_month()'s quarter_start ASC → DESC +1 B RED ×1
M8 banked_count()'s _db is None arm → return 0 −3 B RED ×3
M9 recorded_in() renders 0 instead of the sentinel −15 B RED ×2
M10 drop self._seen.add(key) — the dedupe −36 B RED ×1
M11 WHERE month=? → WHERE month=? OR 1=1 +7 B RED ×4

⚠️ M2 was a real survivor on the first battery, and it is the point of running
one.
banked_count() has two None paths; the checks reached only the
self._db is None one, because a handle that never opened short-circuits before
any SQL runs — so the except sqlite3.Error arm was executed by nothing and
returning 0 from it survived the whole suite at −3 B. That is
MUTATION.md's third family exactly: a check whose data cannot reach the failure
it names.
Closed with an open store whose table is dropped under it, behind a
precondition that the same call answered 4 first, so "it returns nothing
now" is a change and not the store's resting state.

⚠️ M5 is a ZERO-BYTE edit. < → > in the tie term costs nothing, changes
no watt figure, and moves only the timestamp on the row — the family habit 14
and 15 are about. It is red on three named checks.

⚠️ M11 also takes part 10's known-gap pin red, which is how that pin is
shown to be live rather than decoration.

Every mutation is red on a named check, not merely on a red suite.

Fixture notes

  • The restart check drives the month being billed today and reads
    checks() with no argument — the live status page, the surface that
    misinformed, and stable on every day of the year (TEST-17's lesson).
  • It asserts the second Controller has banked nothing itself
    (len(_seen) == 0), so the number it prints cannot have come from memory.
    Without that precondition the whole part is satisfied by a counter that was
    simply never reset.
  • The count is graded against a row count from the other tier, never against
    the property under test.
  • The month-scoping fixture uses two months at different counts (3 and 5) —
    equal counts would be satisfied by a total, by a constant, and by reading
    either month for the other.
  • The tie fixture is fed newest-first, which is the only arrival order that
    separates "the earliest quarter" from "the first row written".

Not in scope, and not touched

peak_soc_floor, the clamp and SAFETY-07's guard are untouched; the peak value
was durable and correct throughout and nothing here changes what the guard does
with it.

⚠️ main.py is NOT touched — TARIFF-11 holds its status rows. The row picks
this up without editing, because self.peak.recorded is an attribute access and
is now a property.

The residual, stated rather than rounded off (DECISIONS §3): recorded
answers for datetime.now() while the peak beside it follows
checks(when=...). The shipped install has exactly one call site —
self.checks() with no argument, 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. PeakTracker.recorded_in(when) is
shipped; moving the row onto it is one token on one line, inside the block
TARIFF-11 owns. Part 10 pins the present behaviour — 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.

Trivial CHANGELOG.md conflict with TARIFF-11 expected. No version number —
owner's decision.

Checks

29/29 test files green.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU

## The defect The capacity-tariff status row prints 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 ``` The peak is durable and was never wrong. The **count** is what says how much of the billing month that peak covers: 365 W over 120 quarters and 365 W over 0 quarters are different claims about the same number, and 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. ## Reproduced first, driven, before anything changed Real `Controller`, 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; the store held 12 rows) ``` and after the change, graded against a row count taken from the other tier: ``` banked count printed before: 12 after: 13 rows in quarter_blocks before: 12 after: 13 the printed count MATCHES the store: True ``` ⚠️ **13, not 12, and that is correct** — the restart's `restore()` closed and banked the stale open block. Which is a **third** defect the old counter had: it incremented only inside `sync()` and never saw a block `restore()` banked. Two more, neither in the ticket, both found by driving the variable rather than reading it: * **it was never scoped to a month.** Measured on the shipped tree: `2026-10: no completed quarter recorded yet - 0 % of the 3500 W soft limit; 1 quarter(s) banked` — September's tally beside October's empty month. ## The route taken, and why **Durable**, not "stop printing it". The count is the useful half of the row; removing it would 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 `recorded` is a property over it. All three defects above close at once. ⚠️ **`None`, not `0`, and not `len(blocks_in(month))`.** `blocks_in()` returns `[]` on a read error — safe there only because no caller reads its emptiness as a peak — so counting its length would rebuild the disarming zero one call further out. The row renders the unanswerable case as `an unreadable number of quarter(s) banked`; `f"{None} quarter(s) banked"` was rejected too, because **"None quarter(s) banked"** reads as that 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. ⚠️ **Not `monthly_peak`**, which holds one row per *month* and would answer `1` for every month ever measured — wrong by exactly the amount that looks plausible. ⚠️ **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*. Counting rows cannot see a re-bank at all (PRIMARY KEY no-op), so that check now grades `sync()`'s **return**. ## The tie decision: EARLIEST wins Recorded in `DECISIONS/TARIFF-13-the-coverage-of-a-peak.md` §2. The wattage is what is billed, so this moves no bill and no guard — `peak_w` is still only ever raised, the `>` term is untouched, SAFETY-07 reads the same watts. The row answers *"when did my monthly peak happen"*, an operator goes and investigates one event, and a stable answer is the one that can be investigated. ⚠️ **And the two readers genuinely diverged**, which is why this is a code change and not only a note. The upsert's strict `>` kept whichever tied block was **written** first; `recompute_month()`'s `ORDER BY ... quarter_start ASC` has always kept the **earliest**. The same only while blocks arrive in time order: ``` 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 ``` — a divergence in the derived record part 8 of `test_tariff.py` exists to prove cannot happen. The upsert now carries the tie term explicitly and both readers agree unconditionally. ⚠️ **THE SOAK'S DRIFTING QUARTER WAS NOT A TIE, AND THE TICKET'S DIAGNOSIS IS FALSE.** Six exactly equal 365.0 W quarters re-attributed **zero** times on the shipped tree — `>` is strict, so an exact tie never wrote. What moved the timestamp was a real new maximum **below 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 ``` **Left alone, deliberately** — the instrument is right, and printing more decimals to explain it would put noise in the one row an operator reads at the moment the guard engages. Both cases are pinned so neither changes by accident. ## Mutations Purged `__pycache__` before every run; tree mounted `:ro`, so no `.pyc` could be written into it either; each backup at a `mktemp` path inside this card's own harness directory (habit 18 — two harnesses sharing one fixed path is how SAFETY-37 lost a source file); **fingerprinted after the revert**, not only before the run, and every revert verified. | # | mutation (`app/tariff.py`) | Δ | result | |---|---|---|---| | M1 | `recorded` property → `return 0` — the shipped defect restored | −17 B | **RED** ×3 | | M2 | `banked_count()`'s `sqlite3.Error` arm → `return 0` | −3 B | ⚠️ **SURVIVOR**, then **RED** ×2 | | M3 | count `monthly_peak` instead of `quarter_blocks` | −2 B | **RED** ×4 | | M4 | `recorded_in()` ignores `when`, always reads `now()` | −8 B | **RED** ×3 | | M5 | tie term `<` → `>` (most recent wins) | **0 B** | **RED** ×3 | | M6 | tie term deleted (back to `>` alone) | −155 B | **RED** ×2 | | M7 | `recompute_month()`'s `quarter_start ASC` → `DESC` | +1 B | **RED** ×1 | | M8 | `banked_count()`'s `_db is None` arm → `return 0` | −3 B | **RED** ×3 | | M9 | `recorded_in()` renders `0` instead of the sentinel | −15 B | **RED** ×2 | | M10 | drop `self._seen.add(key)` — the dedupe | −36 B | **RED** ×1 | | M11 | `WHERE month=?` → `WHERE month=? OR 1=1` | +7 B | **RED** ×4 | ⚠️ **M2 was a real survivor on the first battery, and it is the point of running one.** `banked_count()` has two `None` paths; the checks reached only the `self._db is None` one, because a handle that never opened short-circuits before any SQL runs — so the `except sqlite3.Error` arm was executed by nothing and returning `0` from it survived the whole suite at **−3 B**. That is MUTATION.md's third family exactly: *a check whose data cannot reach the failure it names.* Closed with an open store whose table is dropped under it, behind a **precondition** that the same call answered `4` first, so "it returns nothing now" is a change and not the store's resting state. ⚠️ **M5 is a ZERO-BYTE edit.** `<` → `>` in the tie term costs nothing, changes no watt figure, and moves only the timestamp on the row — the family habit 14 and 15 are about. It is red on three named checks. ⚠️ **M11 also takes part 10's known-gap pin red**, which is how that pin is shown to be live rather than decoration. Every mutation is red on a **named check**, not merely on a red suite. ## Fixture notes * The restart check drives the month being billed **today** and reads `checks()` with **no argument** — the live status page, the surface that misinformed, and stable on every day of the year (TEST-17's lesson). * It asserts the second `Controller` **has banked nothing itself** (`len(_seen) == 0`), so the number it prints cannot have come from memory. Without that precondition the whole part is satisfied by a counter that was simply never reset. * The count is graded against a **row count from the other tier**, never against the property under test. * The month-scoping fixture uses **two months at different counts** (3 and 5) — equal counts would be satisfied by a total, by a constant, and by reading either month for the other. * The tie fixture is fed **newest-first**, which is the only arrival order that separates *"the earliest quarter"* from *"the first row written"*. ## Not in scope, and not touched `peak_soc_floor`, the clamp and SAFETY-07's guard are untouched; the peak value was durable and correct throughout and nothing here changes what the guard does with it. ⚠️ **`main.py` is NOT touched — TARIFF-11 holds its status rows.** The row picks this up without editing, because `self.peak.recorded` is an attribute access and is now a property. **The residual, stated rather than rounded off** (DECISIONS §3): `recorded` answers for `datetime.now()` while the peak beside it follows `checks(when=...)`. The shipped install has **exactly one** call site — `self.checks()` with no argument, `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. `PeakTracker.recorded_in(when)` is shipped; moving the row onto it is one token on one line, inside the block TARIFF-11 owns. Part 10 **pins the present behaviour** — 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. Trivial `CHANGELOG.md` conflict with TARIFF-11 expected. **No version number** — owner's decision. ## Checks **29/29 test files green.** 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
admin added 1 commit 2026-09-13 12:55:21 +02:00
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
admin merged commit b4a3801f68 into release/1.0 2026-09-13 13:03:31 +02:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: admin/goodwe-addon#83