TARIFF-01 measured the quarter and named this card as the consumer. One new
module, app/peakguard.py, and one new claim. Base is 0dbd485.
The mechanism, and why it lives where it does
Every cycle the guard projects the open 900 s block to its end-of-interval
average - from P1 telegrams only, never from the command - and compares it to a
ceiling. Over the ceiling it files one arbiter claim: a lower bound on the
setpoint at P_PEAK.
That single shape is both behaviours §10.1 row 2 asks for ("pre-emptive discharge
as the projected quarter average approaches the soft limit; hard clamp on
charging above it"), and it is incapable of capping discharge or export,
because a floor cannot. §9.1's asymmetry becomes a property of the object rather
than a rule someone has to remember.
excess = 900 x (projected - ceiling) / seconds remaining
floor = MEASURED battery power + excess
The 900/remaining gain is 1.0 at the top of the quarter and grows, so the guard
is gentlest where its projection rests on the least evidence and only becomes
aggressive once the quarter is nearly measured. No minimum-evidence threshold was
invented; the formula supplies it.
The floor is sized from delivered battery power, not the standing command.
Both were available. §9.1 says the utility bills what flowed - and concretely, a
floor built on intent re-adds its own unmet ask every cycle and winds to the rail
whenever the inverter fails to follow. test_peakguard.py part 1 runs five
cycles of a non-following inverter and shows the two forms diverging.
It is a LIMIT, not a SET: P_MAINTENANCE and every P_SAFETY stop still outrank
it. And OPT-04 needs no change here - arbitrage will arrive as a SET below P_PEAK and clause 2 will bind it with this floor on the day it lands.
Trip vs notify, argued from the data
SAFETY-18 shipped notification-first because "a false trip parks the battery".
That is right for a detector whose negative case had to be established on a rig
run. It does not carry here:
Measured false-engagement rate on the healthy day: zero - 0 of 95 quarters
on 2026-08-23, 0 Wh of modelled discharge. test_peakguard.py re-measures it
from the committed capture every run.
The action is level-triggered and unlatched, re-derived from a fresh
projection every cycle and released the instant the projection drops. A false
engagement costs one cycle of discharge, not a parked battery - the cost
SAFETY-18 could not bound and this one can.
It is a LIMIT, so the worst a bug here can do is fail to relax a floor, and the
floor is bounded by achievable_w.
So it trips.
What it does to 2026-08-20's billed quarter
Replayed closed-loop through the real ingest, tracker and guard:
uncontrolled
guarded
2026-08-20 peak quarter (15:45 UTC)
5029.8 W
3485.3 W
quarters under 3500 W, 2026-08-20
94/95
95/95
quarters under 3500 W, 2026-08-23
95/95
95/95
both days
189/190 (99.5 %)
190/190 (100 %)
5029.8 W is a billed quarter above the live install's own 5000 W rating. The
guard removes 1480 W of it without ever saturating, at a modelled cost of 878 Wh
of discharge on that day and 0 Wh on the healthy day.
The replay model is stated in the check's own docstring rather than hidden: the
captures carry only net meter power, so the guard's battery is modelled as an
additional discharge and the idle baseline is 0 W, which is deliberately
pessimistic - the real loop would have been discharging too. Two days is not
thirty; AC 3 stays TARIFF-01's open item and this does not claim it.
Blindness comes first, and it is a different action
A projection is evidence only if the quantity under it was measured. Three ways
it is not, all checked before the projection is graded:
no open block - project() answers a fresh averager with a confident
0 W, which is under every ceiling there is;
coverage - a gap longer than meter_max_age_s leaves seconds the averager
deliberately does not accumulate, so the projection reads LOW;
frozen - SAFETY-18's conjunction, through freeze.looks_frozen() itself,
evaluated on this cycle's standing command. A meter stuck on export projects a
confident zero at full coverage while the real meter runs at the rail.
All three become a floor of 0 W: no importing while the billed quantity is
unobservable. One-directional, so it cannot cost pack energy - and it is the
backstop the freeze alarm could not be, which fires at 300 s of a 900 s quarter
and commands nothing.
The ceiling, and a failed read
ceiling = max(peak_cap_w, the month's banked peak) - once a month's bill stands
at 5013 W, holding later quarters to 3500 W buys nothing and costs real charging.
That relaxation is granted only by an OBSERVED reading.NO_RECORD and NOT_OBSERVED both leave the ceiling at the cap, so an unreadable store makes
the guard stricter. Graded against a real PeakStore on a missing volume, and
the acceptance half is that the guard still engages on it. headroom_w() is
deliberately not consumed - cap - peak turns a zero peak into FULL freedom, and
that is the disarming default this whole chain exists to remove.
The gate is the reading's state, not peak_w is not None. Today the two
behave identically, and depending on that coincidence is exactly how a future
last-known-peak field would raise the ceiling off a read that failed.
project()'s defaulted read - fixed, not inherited
getattr(averager, "_last_net", 0.0) failed quiet and in the disarming
direction: a rename credits 0 W held, the projection reads low, the guard does
not engage. QuarterAverager.held_net_w is now a documented public property read
without a default, and Projection carries it. On the check's own fixture the
old form reads 3200 W low - the difference between engaging and not. TEST-13
item 4.
Two defects found building this, both in the interaction
A LIMIT does not out-vote another LIMIT.resolve() applies every limit at
or above the winner's priority and takes the most restrictive, with no
priority between them - which is what makes the safety bounds unconditional.
So the peak floor (lo >= 0) and the maintenance charge-only shaping
(hi <= 0) resolved to a CONTRADICTION and 0 W: the battery parked in the
exact quarter it should shave. Fixed by suppression, not priority -
main.py drops the maintenance shaping while the guard is engaged, which is
what that branch already did for peak_forecast_entity, an optional key that
is empty on a default install and so could never fire on the site that needed
it.
Thresholding excess > 0 oscillates.excess falls to zero as soon as the
battery is already taking the watts, so the guard released at the instant it
was working: seventeen engage/release cycles in one 900 s quarter, and that
quarter still finished 49 W over the limit it had spent fifteen minutes
holding. §10.2 names the cost. The threshold is now the floor itself - the
same arithmetic read as a level rather than a switch - and there is still no
hysteresis constant, because the quantity being thresholded is the one that
does not oscillate.
A third, caught by test_clamp.py: on meter_source: off there are no telegrams,
so the guard would have been blind forever - and blind means no charging. It now
does not run there at all and the status page says NOT COVERED, the same
answer the freeze detector already gives on a transport that cannot see it.
Alarm surface
An engagement is an ordinary control action - action log and status page, no
notification. §10.3's "clamp AND alarm" is SAFETY-03's rule about a bug signal;
a 5900 W house load on a 5000 W inverter is not a bug, and alarming on the
ordinary case is how an operator learns to dismiss the one that matters. What
alarms is saturation (goodwe_peak_saturated, its own id, once per episode):
the guard asking for everything the inverter has while the quarter is still
projected over, i.e. the one state where the money will be spent and shedding
load is all that is left.
CFG-03
No new option, no fourth defaults site. The ceiling is peak_cap_w, already
shipped and already validated; the coverage threshold is meter_max_age_s; the
freeze predicate is meter_freeze_s; the achievable discharge comes from max_w
and the device rating. config.yaml and validate.py are untouched. §14's capacity_peak_limit_kw stays declined as the kW/W mismatch trap it is. The
check grades this on parsed syntax - peakguard.py makes zero .get() calls and
takes cap_w as an argument - and additionally asserts every literal peak_cap_w fallback in main.py equals config.yaml's shipped value, which is
the divergence test_validate.py records for maintenance_charge_w.
Per-phase
Nothing here indexes a phase. Everything consumed - PeakReading.peak_w, Projection.projected_w - is already reduced by TARIFF-01's single billed_w(),
so whichever way the owner answers sum/max/net, this file does not change. That
also keeps it clear of TEST-13's finding that the per-phase plumbing is less
tested than the passing suite implies.
Checks
test_peakguard.py, eight parts, house style, assert-free check() lines with
every threshold read out of the shipped config.yaml rather than typed. Part 8
runs seven one-line defeats of the guard and reports which check bites:
caught blindness() returns None - the degenerate 0 W projection is graded
caught frozen=False - SAFETY-18's term dropped
caught the `is_observed` gate dropped from quarter_ceiling_w
caught the 900/remaining gain replaced by a plain difference
caught the achievable_w clamp removed
caught filed as a SET at P_PEAK instead of a LIMIT
NOT CAUGHT NOT_OBSERVED silently replaced by a peak of 0.0 W
The last is reported as not caught rather than papered over: max(cap, peak)
makes a zero peak the strictest ceiling there is, so there is nothing left to
disarm. That is also why the ceiling is a max and not a subtraction.
Eight source-level mutations were also applied to the shipped modules and each
confirmed to turn the file red before being reverted. Full suite green: test_arbiter test_clamp test_control test_freeze test_loop test_maintenance test_p1 test_peakguard test_tariff test_validate test_watchdog. test_peakguard
was run five times consecutively to confirm it is not wall-clock flaky - part 7
drives the realController.cycle(), so its fixture is derived from where in
the live quarter the clock lands.
Not done / for the reviewer
AC 3 is still not met and this does not claim it. Thirty days of capture do
not exist; test_tariff.py part 9 carries that argument and this file defers
to it.
No SoC guard on the forced discharge. The BMS remains the only energy
backstop for the grid-following loop, unchanged by this card. Worth a ticket.
Nothing was run against live hardware, no add-on restart, no flash. The
live install is regulating a real battery; the surfaces used were the committed
captures and the real modules.
Collision surface with TARIFF-02 (#16) is CHANGELOG.md only - one hunk at the
top of Unreleased. validate.py and config.yaml are untouched here.
The Priority-field / 2026-08-27-comment mismatch on the card is unresolved and
with the product owner; nothing here treats it as settled either way.
Card: 6a8b93421701cd74d40ca586
## SAFETY-07 - the quarter-peak projection guard
TARIFF-01 measured the quarter and named this card as the consumer. One new
module, `app/peakguard.py`, and one new claim. Base is `0dbd485`.
### The mechanism, and why it lives where it does
Every cycle the guard projects the open 900 s block to its end-of-interval
average - from P1 telegrams only, never from the command - and compares it to a
ceiling. Over the ceiling it files **one arbiter claim: a lower bound on the
setpoint at `P_PEAK`**.
That single shape is both behaviours §10.1 row 2 asks for ("pre-emptive discharge
as the projected quarter average approaches the soft limit; hard clamp on
charging above it"), and it is *incapable* of capping discharge or export,
because a floor cannot. §9.1's asymmetry becomes a property of the object rather
than a rule someone has to remember.
```
excess = 900 x (projected - ceiling) / seconds remaining
floor = MEASURED battery power + excess
```
The `900/remaining` gain is 1.0 at the top of the quarter and grows, so the guard
is **gentlest where its projection rests on the least evidence** and only becomes
aggressive once the quarter is nearly measured. No minimum-evidence threshold was
invented; the formula supplies it.
The floor is sized from **delivered** battery power, not the standing command.
Both were available. §9.1 says the utility bills what flowed - and concretely, a
floor built on intent re-adds its own unmet ask every cycle and winds to the rail
whenever the inverter fails to follow. `test_peakguard.py` part 1 runs five
cycles of a non-following inverter and shows the two forms diverging.
It is a LIMIT, not a SET: `P_MAINTENANCE` and every `P_SAFETY` stop still outrank
it. And OPT-04 needs no change here - arbitrage will arrive as a SET below
`P_PEAK` and clause 2 will bind it with this floor on the day it lands.
### Trip vs notify, argued from the data
SAFETY-18 shipped notification-first because "a false trip parks the battery".
That is right for a detector whose negative case had to be established on a rig
run. It does not carry here:
- **Measured false-engagement rate on the healthy day: zero** - 0 of 95 quarters
on 2026-08-23, 0 Wh of modelled discharge. `test_peakguard.py` re-measures it
from the committed capture every run.
- The action is **level-triggered and unlatched**, re-derived from a fresh
projection every cycle and released the instant the projection drops. A false
engagement costs one cycle of discharge, not a parked battery - the cost
SAFETY-18 could not bound and this one can.
- It is a LIMIT, so the worst a bug here can do is fail to relax a floor, and the
floor is bounded by `achievable_w`.
So it trips.
### What it does to 2026-08-20's billed quarter
Replayed closed-loop through the real ingest, tracker and guard:
| | uncontrolled | guarded |
|---|---|---|
| 2026-08-20 peak quarter (15:45 UTC) | **5029.8 W** | **3485.3 W** |
| quarters under 3500 W, 2026-08-20 | 94/95 | 95/95 |
| quarters under 3500 W, 2026-08-23 | 95/95 | 95/95 |
| **both days** | **189/190 (99.5 %)** | **190/190 (100 %)** |
5029.8 W is a billed quarter *above the live install's own 5000 W rating*. The
guard removes 1480 W of it without ever saturating, at a modelled cost of 878 Wh
of discharge on that day and **0 Wh on the healthy day**.
The replay model is stated in the check's own docstring rather than hidden: the
captures carry only net meter power, so the guard's battery is modelled as an
additional discharge and **the idle baseline is 0 W**, which is deliberately
pessimistic - the real loop would have been discharging too. Two days is not
thirty; AC 3 stays TARIFF-01's open item and this does not claim it.
### Blindness comes first, and it is a different action
A projection is evidence only if the quantity under it was measured. Three ways
it is not, all checked **before** the projection is graded:
- **no open block** - `project()` answers a fresh averager with a *confident
0 W*, which is under every ceiling there is;
- **coverage** - a gap longer than `meter_max_age_s` leaves seconds the averager
deliberately does not accumulate, so the projection reads LOW;
- **frozen** - SAFETY-18's conjunction, through `freeze.looks_frozen()` itself,
evaluated on this cycle's standing command. A meter stuck on export projects a
confident **zero** at full coverage while the real meter runs at the rail.
All three become a floor of **0 W**: no importing while the billed quantity is
unobservable. One-directional, so it cannot cost pack energy - and it is the
backstop the freeze alarm could not be, which fires at 300 s of a 900 s quarter
and commands nothing.
### The ceiling, and a failed read
`ceiling = max(peak_cap_w, the month's banked peak)` - once a month's bill stands
at 5013 W, holding later quarters to 3500 W buys nothing and costs real charging.
**That relaxation is granted only by an `OBSERVED` reading.** `NO_RECORD` and
`NOT_OBSERVED` both leave the ceiling at the cap, so an unreadable store makes
the guard *stricter*. Graded against a real `PeakStore` on a missing volume, and
the acceptance half is that the guard **still engages** on it. `headroom_w()` is
deliberately not consumed - `cap - peak` turns a zero peak into FULL freedom, and
that is the disarming default this whole chain exists to remove.
The gate is the reading's **state**, not `peak_w is not None`. Today the two
behave identically, and depending on that coincidence is exactly how a future
last-known-peak field would raise the ceiling off a read that failed.
### `project()`'s defaulted read - fixed, not inherited
`getattr(averager, "_last_net", 0.0)` failed quiet **and in the disarming
direction**: a rename credits 0 W held, the projection reads low, the guard does
not engage. `QuarterAverager.held_net_w` is now a documented public property read
without a default, and `Projection` carries it. On the check's own fixture the
old form reads 3200 W low - the difference between engaging and not. TEST-13
item 4.
### Two defects found building this, both in the interaction
1. **A LIMIT does not out-vote another LIMIT.** `resolve()` applies every limit at
or above the winner's priority and takes the most restrictive, with no
priority between them - which is what makes the safety bounds unconditional.
So the peak floor (`lo >= 0`) and the maintenance charge-only shaping
(`hi <= 0`) resolved to a **CONTRADICTION and 0 W**: the battery parked in the
exact quarter it should shave. Fixed by **suppression, not priority** -
main.py drops the maintenance shaping while the guard is engaged, which is
what that branch already did for `peak_forecast_entity`, an optional key that
is empty on a default install and so could never fire on the site that needed
it.
2. **Thresholding `excess > 0` oscillates.** `excess` falls to zero as soon as the
battery is already taking the watts, so the guard released at the instant it
was working: seventeen engage/release cycles in one 900 s quarter, and that
quarter still finished 49 W over the limit it had spent fifteen minutes
holding. §10.2 names the cost. The threshold is now the floor itself - the
same arithmetic read as a level rather than a switch - and there is still no
hysteresis constant, because the quantity being thresholded is the one that
does not oscillate.
A third, caught by `test_clamp.py`: on `meter_source: off` there are no telegrams,
so the guard would have been blind forever - and blind means no charging. It now
does not run there at all and the status page says **NOT COVERED**, the same
answer the freeze detector already gives on a transport that cannot see it.
### Alarm surface
An engagement is an **ordinary control action** - action log and status page, no
notification. §10.3's "clamp AND alarm" is SAFETY-03's rule about a *bug signal*;
a 5900 W house load on a 5000 W inverter is not a bug, and alarming on the
ordinary case is how an operator learns to dismiss the one that matters. What
alarms is **saturation** (`goodwe_peak_saturated`, its own id, once per episode):
the guard asking for everything the inverter has while the quarter is still
projected over, i.e. the one state where the money will be spent and shedding
load is all that is left.
### CFG-03
No new option, no fourth defaults site. The ceiling is `peak_cap_w`, already
shipped and already validated; the coverage threshold is `meter_max_age_s`; the
freeze predicate is `meter_freeze_s`; the achievable discharge comes from `max_w`
and the device rating. **`config.yaml` and `validate.py` are untouched.** §14's
`capacity_peak_limit_kw` stays declined as the kW/W mismatch trap it is. The
check grades this on parsed syntax - `peakguard.py` makes zero `.get()` calls and
takes `cap_w` as an argument - and additionally asserts every literal
`peak_cap_w` fallback in `main.py` equals config.yaml's shipped value, which is
the divergence `test_validate.py` records for `maintenance_charge_w`.
### Per-phase
Nothing here indexes a phase. Everything consumed - `PeakReading.peak_w`,
`Projection.projected_w` - is already reduced by TARIFF-01's single `billed_w()`,
so whichever way the owner answers sum/max/net, this file does not change. That
also keeps it clear of TEST-13's finding that the per-phase *plumbing* is less
tested than the passing suite implies.
### Checks
`test_peakguard.py`, eight parts, house style, `assert`-free `check()` lines with
every threshold read out of the shipped `config.yaml` rather than typed. Part 8
runs **seven one-line defeats of the guard** and reports which check bites:
```
caught blindness() returns None - the degenerate 0 W projection is graded
caught frozen=False - SAFETY-18's term dropped
caught the `is_observed` gate dropped from quarter_ceiling_w
caught the 900/remaining gain replaced by a plain difference
caught the achievable_w clamp removed
caught filed as a SET at P_PEAK instead of a LIMIT
NOT CAUGHT NOT_OBSERVED silently replaced by a peak of 0.0 W
```
The last is reported as not caught rather than papered over: `max(cap, peak)`
makes a zero peak the **strictest** ceiling there is, so there is nothing left to
disarm. That is also why the ceiling is a max and not a subtraction.
Eight source-level mutations were also applied to the shipped modules and each
confirmed to turn the file red before being reverted. Full suite green:
`test_arbiter test_clamp test_control test_freeze test_loop test_maintenance
test_p1 test_peakguard test_tariff test_validate test_watchdog`. `test_peakguard`
was run five times consecutively to confirm it is not wall-clock flaky - part 7
drives the **real** `Controller.cycle()`, so its fixture is derived from where in
the live quarter the clock lands.
### Not done / for the reviewer
- **AC 3 is still not met** and this does not claim it. Thirty days of capture do
not exist; `test_tariff.py` part 9 carries that argument and this file defers
to it.
- **No SoC guard on the forced discharge.** The BMS remains the only energy
backstop for the grid-following loop, unchanged by this card. Worth a ticket.
- **Nothing was run against live hardware**, no add-on restart, no flash. The
live install is regulating a real battery; the surfaces used were the committed
captures and the real modules.
- Collision surface with TARIFF-02 (#16) is `CHANGELOG.md` only - one hunk at the
top of Unreleased. `validate.py` and `config.yaml` are untouched here.
- The Priority-field / 2026-08-27-comment mismatch on the card is **unresolved and
with the product owner**; nothing here treats it as settled either way.
Card: `6a8b93421701cd74d40ca586`
TARIFF-01 measured the quarter and said the consumer would be this card. It is
one new module, app/peakguard.py, and one new claim.
THE MECHANISM. Every cycle the guard projects the open 900 s block to its
end-of-interval average - from P1 telegrams only, never from the command - and
compares it to a ceiling. Over the ceiling it files ONE arbiter claim: a lower
bound on the setpoint at P_PEAK. That single shape is both behaviours 10.1 asks
for, a hard clamp on charging and pre-emptive discharge, and it cannot cap
discharge or export, because a floor cannot. 9.1's asymmetry becomes a property
of the object instead of a rule to remember.
excess = 900 x (projected - ceiling) / seconds remaining
floor = MEASURED battery power + excess
The 900/remaining gain is 1.0 at the top of the quarter and grows, so the guard
is gentlest where its projection rests on the least evidence. The floor is sized
from delivered battery power, not the standing command: a floor built on intent
re-adds its own unmet ask every cycle and winds to the rail whenever the inverter
fails to follow, and test_peakguard.py part 1 shows the two diverging.
IT TRIPS, AND THE CAPTURES DECIDED THAT. On the healthy reference day it engages
on zero of 95 quarters. The action is level-triggered, unlatched and re-derived
every cycle, so a false engagement costs one cycle of discharge rather than a
parked battery - the cost SAFETY-18 could not bound and this one can. Replayed on
2026-08-20 the billed quarter goes from 5029.8 W, above the inverter's own 5000 W
rating, to 3485 W, under the soft limit and without saturating. Quarters under
the limit across both committed days: 189/190 -> 190/190. Two days is not thirty
and AC 3 remains TARIFF-01's open item.
BLINDNESS IS CHECKED BEFORE THE PROJECTION IS GRADED, and it is a different
action. A fresh averager projects a confident 0 W, which is under every ceiling
there is; a meter gap longer than meter_max_age_s makes the projection read LOW;
a frozen meter reading export projects 0 W at full coverage. All three become a
floor of 0 W - no importing while the billed quantity is unobservable - which is
one-directional and cannot cost pack energy. That is the backstop SAFETY-18 could
not be: its alarm arrives at 300 s of a 900 s quarter and commands nothing. The
frozen term is freeze.looks_frozen() itself, on this cycle's standing command.
THE CEILING IS peak_cap_w, RAISED ONLY BY AN OBSERVED READING. NO_RECORD and
NOT_OBSERVED both leave it at the cap, so an unreadable peak store makes the
guard stricter and never looser. The gate is the reading's STATE, not whether a
number happens to be attached: today a `peak_w is not None` test behaves
identically, and depending on that coincidence is how a future last-known-peak
field would raise the ceiling off a read that failed. headroom_w() is
deliberately not consumed - cap minus peak turns a zero peak into FULL freedom.
TWO DEFECTS FOUND BUILDING IT, both in the interaction rather than the
arithmetic, and both caught by running the thing rather than by reading it:
* A LIMIT does not out-vote another LIMIT. arbiter.resolve() applies every
limit at or above the winner's priority and takes the most restrictive, so
the peak floor (lo >= 0) and the maintenance charge-only shaping (hi <= 0)
resolved to a CONTRADICTION and 0 W - the battery parked in the exact quarter
it should shave. Fixed by suppression, not priority: main.py drops the
maintenance shaping while the guard is engaged, which is what that branch
already did for the optional peak_forecast_entity - a key empty on a default
install, so until now the suspension could not fire on the site that needed
it.
* Thresholding `excess > 0` released the claim the instant it began working,
producing seventeen engage/release cycles in one 900 s quarter and leaving
that quarter 49 W over the limit it had spent fifteen minutes holding. That
is 10.2's oscillation. The threshold is now the floor itself, which is the
same arithmetic read as a level rather than as a switch.
PeakTracker.project() no longer reads a private name with a default.
getattr(averager, "_last_net", 0.0) failed quiet AND disarming - a rename credits
0 W held, the projection reads low, the money guard does not engage.
QuarterAverager.held_net_w is a documented public property now, read without a
default, and Projection carries it. TEST-13 item 4.
NO NEW OPTION AND NO FOURTH DEFAULTS SITE. The ceiling is peak_cap_w, the
coverage threshold is meter_max_age_s, the freeze predicate is meter_freeze_s,
and the achievable discharge comes from max_w and the device rating. config.yaml
and validate.py are untouched; 14's capacity_peak_limit_kw stays declined. On
meter_source: off there are no telegrams, so the guard does not run at all and
the status page says NOT COVERED - running it there blinded it permanently and
stopped every legacy install from charging, which is what the first cut did until
test_clamp.py's export case caught it.
test_peakguard.py: eight parts, all figures derived from the shipped config.yaml
and the committed captures. Part 8 runs seven one-line defeats of the guard and
reports which check bites; six do, and the seventh is reported as NOT caught,
because max(cap, peak) makes a zero peak the strictest ceiling there is and there
is nothing left to catch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
Conflict was the one anticipated: both cards prepend to CHANGELOG.md's
Unreleased section. Both entries kept, SAFETY-07 first. Nothing else of
TARIFF-02's surface (app/price.py, validate.py rule 7, config.yaml, DOCS.md)
overlaps this card - config.yaml and validate.py are untouched here, so the
CFG-03 check still reads a config.yaml that only TARIFF-02 has widened.
Full suite green on the merge result, test_price.py included.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
Not merging. The mechanism is right and the money result reproduces exactly - but the fix for the most expensive defect this PR found is not pinned by anything, and I reproduced that defect by reintroducing it while the whole suite stayed green.
Own detached worktree at 9f8b721, own replay, own harness. All twelve suites green as shipped. CI verified by me: checks / unit (pull_request) -> success, Successful in 3m9s, run 542 / job 1537, combined state success.
Almost everything here is very good. Three things are not covered, all of them in main.py's wiring rather than in peakguard.py, and one of them re-creates the exact bug your own comment explains.
MUST FIX - three live mutations, whole suite green on all three
I drove the real Controller.cycle() on a default install (no peak_forecast_entity) with the maintenance charge-only shaping active and the guard genuinely engaged on the measurement (projected 4972 W, excess 2008 W, blind=None).
shaping suspended : False
resolved reason : CONTRADICTION between limits - commanding 0 W (loop)
RESOLVED TARGET : +0.0 W wrote: [0.0]
>>> PARKED AT 0 W IN A PEAK QUARTER: True
test_peakguard.py and all eleven other suites stay GREEN.
B2 - the suppression re-gated on peak_forecast_entity: identical output, CONTRADICTION, 0.0 W, suite GREEN.
B2 is the one that matters most, because it is not hypothetical - it is verbatim the bug your own comment describes:
"which is what this branch already did for peak_forecast_entity. That entity is optional and empty on a default install, so until now the suspension could not fire on the site that needed it."
The comment explains the historical defect. Nothing tests that it stays fixed. Re-adding the optional-key gate restores battery-parked-at-0-W-in-the-shaving-quarter on exactly the default install, and no check anywhere notices.
B3 - the floor sized from the standing command instead of measured battery power. On a cycle where the inverter is not following (measured 0 W, standing +3000 W):
shipped : wanted 2007.6 W -> floor +2007.6 W
B3 : wanted 5007.6 W -> floor +5000.0 W SATURATED, at the rail
That is precisely the winding-to-the-rail failure part 1 demonstrates on decide() - and the wiring that prevents it (batt_w=self.batt) is not asserted anywhere. Part 1 grades the function with a hand-passed batt_w; nothing grades which value main.py hands it. Suite GREEN.
What closes all three is one scenario you already have the scaffolding for in part 7 - built() plus a forced charge_only result:
with the guard engaged and nopeak_forecast_entity in the options, assert _maint_shape_suspended is True, the resolution is not a contradiction, and the target is > 0;
keep the "no peak_forecast_entity" part explicit, so re-adding the optional-key gate fails rather than passing;
one cycle with self.batt and the standing command deliberately different, asserting the floor tracks the measured one.
I built it in about forty lines against your own _Hass/_Store stubs; it is not a large ask.
Everything else, verified rather than accepted
The two-day result - reproduced exactly, on my own replay
I wrote my own replay loop from the model stated in your docstring (the capture's w is the load; the guard's battery is an additional discharge subtracted from it, delivered on the next telegram; idle => 0 W baseline), driving the shipped P1Ingest / PeakTracker / decide(). Nothing of yours but the modules under test:
p1-net-2026-08-20: uncontrolled 95 quarters, peak 5029.8 W, under 3500: 94/95
GUARDED 95 quarters, peak 3485.3 W, under 3500: 95/95
4802 cycles, engaged 115 (blind 5, saturated 0), 878 Wh
p1-net-2026-08-23: 507.0 W both ways, engaged 0, 0 Wh
TOTAL under 3500 W: 189/190 -> 190/190
5029.8 -> 3485.3, 190/190, 878 Wh, 0 Wh on the healthy day - every figure lands. The 5029.8 W also matches what I computed independently while reviewing TARIFF-01, from a different harness.
A lo limit cannot cap discharge or export - it is a property, not a rule
Not one witness; a search:
claim() -> kind='limit' prio=50 lo=1234.0 hi=inf
over 54 (loop wish, floor) pairs, cases where the claim LOWERED the target : 0
loop wants +5000 W, floors 0..5000 W in 10 W steps, first value that reduces it: NEVER
decide() over 630 input combinations, negative floors produced : 0
hi is inf, decide() cannot emit a negative floor, and no floor value anywhere reduces a full-rate discharge. Section 9.1's asymmetry really is structural here.
The 900/remaining gain, at the boundary
elapsed 0.0 s -> gain 1.00 excess 1000 W <- gentlest where evidence is weakest
elapsed 450.0 s -> gain 2.00 excess 2000 W
elapsed 899.0 s -> gain 900.00 floor clamped to 5000 W, saturated=True
elapsed 900.0 s -> excess 0.0, decide() engaged=False
Gain is exactly 1.0 at the top - a real value, not a coincidence of two formulas - and the blow-up near the end is bounded by the achievable_w clamp, so it can never file lo > hi. No degeneracy hiding at this boundary.
The ceiling gate, and the mutation that is open on TEST-13
A2 ceiling gated on `peak_w is not None` instead of the reading's state ... RED
A3 threshold back to `excess > 0` ......................................... RED
A4 `frozen` not checked in blindness() .................................... RED
A1 `is_observed` returns True for NO_RECORD ............................... GREEN
A1 is the TEST-13 item, and SAFETY-07 does not inherit its harm - I checked rather than assumed. Under the broken predicate:
state=no_record peak_w=None -> ceiling 3500 W (peak_cap_w) is_observed=True
state=not_observed peak_w=9000.0 -> ceiling 3500 W (peak_cap_w)
state=observed peak_w=9000.0 -> ceiling 9000 W (the month's banked peak)
The peak_w is not None conjunct is a second, independent gate, so a broken predicate cannot move the ceiling on its own. That is a good defensive shape and worth saying out loud: this guard stays correct even if the predicate it depends on regresses. Fixing A1 belongs to TEST-13, not here.
project()'s defaulted read - properly fixed
held = max(averager.held_net_w, 0.0), a bare read. I deleted the property outright, the way a rename would:
after DELETING the property, project() RAISES AttributeError on the first cycle
TEST-13 item 4 is closed, not inherited.
Part 8's honesty
Confirmed rather than assumed: the 7th defeat really is not caught, and the reason - max(cap, peak) making a zero peak the strictest ceiling there is - is correct. Reporting NOT CAUGHT rather than quietly dropping it is why I trusted the rest of the file enough to go hunting in main.py instead.
Rulings you asked for
AC 3 - not met, not a blocker, and I am ruling the same way I ruled on TARIFF-01. No 30-day capture exists, nothing here asserts the 95 % figure, and deferring to test_tariff.py part 9 rather than re-litigating it is right. The card must not close as AC 3 met, and the 30-day recording needs to start, since it cannot be produced retroactively.
The missing SoC guard - correct not to block here, and I would not have blocked on it.peakguard.py contains no reference to SoC at all (I grepped: zero), so the guard can force discharge to min(max_w, dev_max) with no floor but the BMS. But that is not a new class of exposure: freeze.py already records that "there is no SoC guard on the grid-following loop at all... the BMS is the sole energy backstop". What SAFETY-07 changes is the frequency - it will force discharge where the loop would not have, and 878 Wh on one modelled day against a pack that has been sitting near 9 % is not nothing. So: right to file separately, wrong to ship to a client install without it, and peakguard.py should carry a one-line pointer to that ticket where a reader will meet it.
The suppression fix is the right shape. Suppression rather than priority is correct, and for the reason given: arbiter clause 2 has no notion of a higher-priority limit overriding a lower one and must not gain one - "most restrictive" is what makes the safety bounds unconditional. self.peak_guard is assigned at line 464 and read at line 520, in the same cycle, so there is no one-cycle lag on the engaging edge; I checked, because that ordering is where this project keeps finding bugs. The design is right. It is only undefended.
Coordination
#19 (TARIFF-03) genuinely does not overlap.git merge-tree between the two heads reports Auto-merging goodwe_controller/app/main.py with no conflict - only CHANGELOG.md conflicts, and DOCS.md auto-merges. Your main.py hunks are in __init__, write_setpoint, cycle(), the arbiter path and checks(); theirs are in read_inputs()'s else-branch and one status row. Either order works. I would land #19 first, since it is smaller and this PR needs another commit regardless.
Merges clean into the current release/1.0 otherwise.
I ran nothing against the rig, the live add-on, the inverter or firmware.
Add the three assertions and I will merge it. The mechanism, the arithmetic and the money result are all sound - this is about defending the fix you already made.
## Not merging. The mechanism is right and the money result reproduces exactly - but the fix for the most expensive defect this PR found is not pinned by anything, and I reproduced that defect by reintroducing it while the whole suite stayed green.
Own detached worktree at `9f8b721`, own replay, own harness. All twelve suites green as shipped. CI verified by me: `checks / unit (pull_request) -> success, Successful in 3m9s`, run 542 / job 1537, combined state `success`.
Almost everything here is very good. Three things are not covered, all of them in `main.py`'s wiring rather than in `peakguard.py`, and one of them re-creates the exact bug your own comment explains.
---
## MUST FIX - three live mutations, whole suite green on all three
I drove the **real `Controller.cycle()`** on a **default install (no `peak_forecast_entity`)** with the maintenance charge-only shaping active and the guard genuinely engaged on the measurement (`projected 4972 W`, `excess 2008 W`, `blind=None`).
**Shipped - correct:**
```
shaping suspended : True
resolved reason : loop -> 2008 W, limited by peak(quarter peak)
RESOLVED TARGET : +2007.6 W wrote: [2007.6]
```
**`B1` - the suppression removed (`if False:`):**
```
shaping suspended : False
resolved reason : CONTRADICTION between limits - commanding 0 W (loop)
RESOLVED TARGET : +0.0 W wrote: [0.0]
>>> PARKED AT 0 W IN A PEAK QUARTER: True
```
**`test_peakguard.py` and all eleven other suites stay GREEN.**
**`B2` - the suppression re-gated on `peak_forecast_entity`:** identical output, `CONTRADICTION`, `0.0 W`, **suite GREEN**.
`B2` is the one that matters most, because it is not hypothetical - it is **verbatim the bug your own comment describes**:
> *"which is what this branch already did for `peak_forecast_entity`. That entity is optional and empty on a default install, so until now the suspension could not fire on the site that needed it."*
The comment explains the historical defect. Nothing tests that it stays fixed. Re-adding the optional-key gate restores battery-parked-at-0-W-in-the-shaving-quarter on exactly the default install, and no check anywhere notices.
**`B3` - the floor sized from the standing command instead of measured battery power.** On a cycle where the inverter is not following (measured `0 W`, standing `+3000 W`):
```
shipped : wanted 2007.6 W -> floor +2007.6 W
B3 : wanted 5007.6 W -> floor +5000.0 W SATURATED, at the rail
```
That is precisely the winding-to-the-rail failure part 1 demonstrates on `decide()` - and **the wiring that prevents it (`batt_w=self.batt`) is not asserted anywhere.** Part 1 grades the function with a hand-passed `batt_w`; nothing grades which value `main.py` hands it. Suite GREEN.
**What closes all three** is one scenario you already have the scaffolding for in part 7 - `built()` plus a forced `charge_only` result:
- with the guard engaged and **no** `peak_forecast_entity` in the options, assert `_maint_shape_suspended` is True, the resolution is **not** a contradiction, and the target is **> 0**;
- keep the "no `peak_forecast_entity`" part explicit, so re-adding the optional-key gate fails rather than passing;
- one cycle with `self.batt` and the standing command deliberately different, asserting the floor tracks the measured one.
I built it in about forty lines against your own `_Hass`/`_Store` stubs; it is not a large ask.
---
## Everything else, verified rather than accepted
### The two-day result - reproduced exactly, on my own replay
I wrote my own replay loop from the model stated in your docstring (the capture's `w` is the load; the guard's battery is an additional discharge subtracted from it, delivered on the next telegram; idle => 0 W baseline), driving the shipped `P1Ingest` / `PeakTracker` / `decide()`. Nothing of yours but the modules under test:
```
p1-net-2026-08-20: uncontrolled 95 quarters, peak 5029.8 W, under 3500: 94/95
GUARDED 95 quarters, peak 3485.3 W, under 3500: 95/95
4802 cycles, engaged 115 (blind 5, saturated 0), 878 Wh
p1-net-2026-08-23: 507.0 W both ways, engaged 0, 0 Wh
TOTAL under 3500 W: 189/190 -> 190/190
```
**5029.8 -> 3485.3, 190/190, 878 Wh, 0 Wh on the healthy day - every figure lands.** The 5029.8 W also matches what I computed independently while reviewing TARIFF-01, from a different harness.
### A `lo` limit cannot cap discharge or export - it is a property, not a rule
Not one witness; a search:
```
claim() -> kind='limit' prio=50 lo=1234.0 hi=inf
over 54 (loop wish, floor) pairs, cases where the claim LOWERED the target : 0
loop wants +5000 W, floors 0..5000 W in 10 W steps, first value that reduces it: NEVER
decide() over 630 input combinations, negative floors produced : 0
```
`hi` is `inf`, `decide()` cannot emit a negative floor, and no floor value anywhere reduces a full-rate discharge. Section 9.1's asymmetry really is structural here.
### The `900/remaining` gain, at the boundary
```
elapsed 0.0 s -> gain 1.00 excess 1000 W <- gentlest where evidence is weakest
elapsed 450.0 s -> gain 2.00 excess 2000 W
elapsed 899.0 s -> gain 900.00 floor clamped to 5000 W, saturated=True
elapsed 900.0 s -> excess 0.0, decide() engaged=False
```
Gain is exactly 1.0 at the top - a real value, not a coincidence of two formulas - and the blow-up near the end is bounded by the `achievable_w` clamp, so it can never file `lo > hi`. No degeneracy hiding at this boundary.
### The ceiling gate, and the mutation that is open on TEST-13
```
A2 ceiling gated on `peak_w is not None` instead of the reading's state ... RED
A3 threshold back to `excess > 0` ......................................... RED
A4 `frozen` not checked in blindness() .................................... RED
A1 `is_observed` returns True for NO_RECORD ............................... GREEN
```
`A1` is the TEST-13 item, and **SAFETY-07 does not inherit its harm** - I checked rather than assumed. Under the broken predicate:
```
state=no_record peak_w=None -> ceiling 3500 W (peak_cap_w) is_observed=True
state=not_observed peak_w=9000.0 -> ceiling 3500 W (peak_cap_w)
state=observed peak_w=9000.0 -> ceiling 9000 W (the month's banked peak)
```
The `peak_w is not None` conjunct is a second, independent gate, so a broken predicate cannot move the ceiling on its own. That is a good defensive shape and worth saying out loud: this guard stays correct **even if** the predicate it depends on regresses. Fixing `A1` belongs to TEST-13, not here.
### `project()`'s defaulted read - properly fixed
`held = max(averager.held_net_w, 0.0)`, a bare read. I deleted the property outright, the way a rename would:
```
after DELETING the property, project() RAISES AttributeError on the first cycle
```
TEST-13 item 4 is closed, not inherited.
### Part 8's honesty
Confirmed rather than assumed: the 7th defeat really is not caught, and the reason - `max(cap, peak)` making a zero peak the strictest ceiling there is - is correct. Reporting `NOT CAUGHT` rather than quietly dropping it is why I trusted the rest of the file enough to go hunting in `main.py` instead.
---
## Rulings you asked for
**AC 3 - not met, not a blocker, and I am ruling the same way I ruled on TARIFF-01.** No 30-day capture exists, nothing here asserts the 95 % figure, and deferring to `test_tariff.py` part 9 rather than re-litigating it is right. **The card must not close as AC 3 met**, and the 30-day recording needs to start, since it cannot be produced retroactively.
**The missing SoC guard - correct not to block here, and I would not have blocked on it.** `peakguard.py` contains no reference to SoC at all (I grepped: zero), so the guard can force discharge to `min(max_w, dev_max)` with no floor but the BMS. But that is not a *new* class of exposure: `freeze.py` already records that "there is no SoC guard on the grid-following loop at all... the BMS is the sole energy backstop". What SAFETY-07 changes is the *frequency* - it will force discharge where the loop would not have, and 878 Wh on one modelled day against a pack that has been sitting near 9 % is not nothing. So: right to file separately, wrong to ship to a client install without it, and `peakguard.py` should carry a one-line pointer to that ticket where a reader will meet it.
**The suppression fix is the right shape.** Suppression rather than priority is correct, and for the reason given: arbiter clause 2 has no notion of a higher-priority limit overriding a lower one and must not gain one - "most restrictive" is what makes the safety bounds unconditional. `self.peak_guard` is assigned at line 464 and read at line 520, **in the same cycle**, so there is no one-cycle lag on the engaging edge; I checked, because that ordering is where this project keeps finding bugs. The design is right. It is only undefended.
---
## Coordination
- **#19 (TARIFF-03) genuinely does not overlap.** `git merge-tree` between the two heads reports `Auto-merging goodwe_controller/app/main.py` with **no conflict** - only `CHANGELOG.md` conflicts, and `DOCS.md` auto-merges. Your `main.py` hunks are in `__init__`, `write_setpoint`, `cycle()`, the arbiter path and `checks()`; theirs are in `read_inputs()`'s else-branch and one status row. Either order works. **I would land #19 first**, since it is smaller and this PR needs another commit regardless.
- Merges clean into the current `release/1.0` otherwise.
- I ran nothing against the rig, the live add-on, the inverter or firmware.
Add the three assertions and I will merge it. The mechanism, the arithmetic and the money result are all sound - this is about defending the fix you already made.
The reviewer's finding was correct and the shape of it matters: everything the
first cut graded was in peakguard.py, and grading the module is not the same as
grading the guard. Three one-line changes to main.py's CALL SITE left all twelve
suites green while a default install parked at 0 W in the quarter that sets the
month's bill.
B2 was the serious one. My own comment described that defect - "that entity is
optional and empty on a default install, so until now the suspension could not
fire on the site that needed it" - and nothing could regress it. A comment that
documents a fix nobody can test is the same shape as a corrected comment nobody
tests, which this project has already been caught by once.
WHAT CHANGED
The suppression conjunction moved out of main.py and into
peakguard.shaping_suspended(), for the reason watchdog.loop_is_following() and
freeze.looks_frozen() live where they do: the check that grades a safety
predicate must assert on THE SHIPPED EXPRESSION, not on a transcription that can
drift apart from it in silence. Its docstring carries both terms and why neither
is sufficient alone. main.py's call site now says READ THAT FUNCTION FIRST, and
records that a reviewer mutated it back to its historical broken form with every
suite staying green.
test_peakguard.py part 7 gains a section that EXECUTES the three mutations
rather than describing them. Each one is put in place on the shipped module and
the REAL Controller.cycle() is driven with it, on the dangerous fixture: a
default install (no peak_forecast_entity, so peak_fc is None and peak_at_risk is
False forever) with maintenance in its CHARGE phase, so a P_MAINT_SHAPE `hi <= 0`
limit is live, and the guard genuinely engaged on the measurement. That the
mutations are monkeypatched onto the shipped module is itself load-bearing: if
main.py inlined either expression again the patch would do nothing and these
checks would pass vacuously.
SHIPPED suspended=True loop -> 1498 W, limited by peak +1497.8 W
B1 suspended=False CONTRADICTION between limits 0.0 W
B2 suspended=False CONTRADICTION between limits 0.0 W
B2b (forecast term dropped instead) - caught by its own check
B3 is graded three ways, because the single-cycle form is only INFLATED and the
failure is what happens on the next cycle:
* the value main.py actually hands decide() is recorded off the real call, with
the measured battery at 0 W and the command standing at +3000 W;
* the one-cycle difference is asserted to be exactly the standing command - the
whole defect in one line, watts that were asked for and never arrived counted
as if they had;
* both forms are then run forward against the REAL controller's own projection
with an inverter that never follows:
intent-sized floor 4482 5000 5000 5000 ... SATURATED at cycle 2
delivered-power floor 1482 1482 1482 1482 ... stationary
* and the call site is graded on parsed syntax, so a rename cannot quietly
reintroduce it: `decide(batt_w=...)` must unparse to `self.batt`.
SAFETY-24 is now named in peakguard.py, in its own section, with the number that
makes it real - 878 Wh of forced discharge on the 2026-08-20 replay - and the
statement that this must not go to a client install before that ticket lands. A
ticket nobody can find from the code is a ticket nobody reads before shipping.
RED AND GREEN
Fifteen mutations run against the shipped modules, each reverted after: the six
new wiring ones (B1, B2, B2b, B3, B3b, and deleting the SAFETY-24 pointer) and
the eight from the first round, re-run to confirm they still bite. All fifteen
turn test_peakguard.py red. Full suite green, test_price.py and TARIFF-03's
included.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
The anticipated conflict again, and only it: both cards prepend to CHANGELOG.md's
Unreleased section. Both entries kept, SAFETY-07 first. app/main.py and DOCS.md
auto-merged, so the overlap claim on #18 still holds - config.yaml and
validate.py remain untouched by this card even after TARIFF-02 and TARIFF-03
widened both.
Full suite green on the merge result, test_price.py and test_static_tariff.py
included.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
All three defended, pushed as 2d570d8 (+ c2ed509, the TARIFF-03 changelog
rebase). Head is c2ed509, checks / unit (pull_request) green, mergeable: true.
You were right about the shape of it, and that is the part worth saying back:
everything I graded was in peakguard.py, and grading the module is not the same
as grading the guard. The call site was the untested surface and I did not see it.
B2 is the one I should have written a check for and did not. My own comment
described the defect - "that entity is optional and empty on a default install,
so until now the suspension could not fire on the site that needed it" - and
nothing could regress it. That is exactly the corrected-comment-nobody-tests
shape, and having named it in this very PR makes it worse rather than better.
What changed
The conjunction moved out of main.py into peakguard.shaping_suspended(),
for the reason watchdog.loop_is_following() and freeze.looks_frozen() live
where they do: a check that grades a safety predicate has to assert on the shipped expression. Its docstring carries both terms and why neither is
sufficient alone; the call site now says read that function first, and records
that a reviewer mutated it back to its historical form with every suite green.
Part 7 gains a section that executes the three mutations rather than
describing them. Each is applied to the shipped module and the real Controller.cycle() is driven with it, on your fixture: default install, no peak_forecast_entity, maintenance in CHARGE so a P_MAINT_SHAPEhi <= 0
limit is live, guard genuinely engaged on the measurement (blind=None,
asserted). Monkeypatching the shipped module is itself load-bearing - if main.py ever inlines either expression again the patch does nothing and these
checks would pass vacuously, so re-inlining is caught too.
SHIPPED suspended=True loop -> 1498 W, limited by peak +1497.8 W
B1 suspended=False CONTRADICTION between limits 0.0 W
B2 suspended=False CONTRADICTION between limits 0.0 W
Your numbers reproduce; mine differ only because the fixture load is derived from
where in the live quarter the wall clock lands (part 7 drives the real cycle, so
a fixed load asks 1700 W with ten minutes left and 300 kW with five seconds).
I added a fourth, B2b: the forecast term dropped instead of the measured
one. It has its own check, so neither half can be deleted as redundant - the
measured term is what saves a default install, and the forecast term still
suspends the shaping on a configured site before the guard engages.
B3, graded three ways
One cycle of the intent form is only inflated; the failure is the cycle after,
when its own unmet ask has become the standing command. So:
the value main.py actually hands decide() is recorded off the real call,
measured battery 0 W against a command standing at +3000 W;
the one-cycle difference is asserted to be exactly the standing command -
the defect in one line, watts asked for and never delivered counted as if they
had;
both forms are run forward against the real controller's own projection with an
inverter that never follows:
and the call site is graded on parsed syntax - decide(batt_w=...) must
unparse to self.batt - so a rename cannot quietly reintroduce it. B3b
(sizing from self.target) is run as a separate mutation and also goes red.
SAFETY-24
Taken, and pointed at from peakguard.py in its own section, with the number
that makes it real (878 Wh of forced discharge on the 2026-08-20 replay) and the
statement that this must not go to a client install before it lands. A ticket
nobody can find from the code is a ticket nobody reads before shipping. There is
a check that the pointer is still there.
Red and green
Fifteen mutations run against the shipped modules, each reverted after: the
six new wiring ones (B1, B2, B2b, B3, B3b, and deleting the SAFETY-24 pointer)
and the eight from the first round re-run to confirm they still bite. All
fifteen turn test_peakguard.py red. Full suite green including test_price.py and TARIFF-03's test_static_tariff.py.
On the rest of your review
Thank you for re-deriving the two-day numbers from your own harness rather than
taking mine - that is the check I could not do for myself. Noted and not
re-litigated: AC 3 stays TARIFF-01's, the is_observed-true-for-NO_RECORD
hole stays with TEST-13 (agreed I do not inherit the harm - the peak_w is not None conjunct is a second independent gate), and part 8's seventh defeat stays
honestly reported as not caught.
Rebase: only CHANGELOG.md conflicted, as you said. app/main.py and DOCS.md
auto-merged, so the overlap claim holds - config.yaml and validate.py are
still untouched by this card after TARIFF-02 and TARIFF-03 widened both.
All three defended, pushed as `2d570d8` (+ `c2ed509`, the TARIFF-03 changelog
rebase). Head is `c2ed509`, `checks / unit (pull_request)` green, `mergeable: true`.
You were right about the shape of it, and that is the part worth saying back:
everything I graded was in `peakguard.py`, and grading the module is not the same
as grading the guard. The call site was the untested surface and I did not see it.
**B2 is the one I should have written a check for and did not.** My own comment
described the defect - *"that entity is optional and empty on a default install,
so until now the suspension could not fire on the site that needed it"* - and
nothing could regress it. That is exactly the corrected-comment-nobody-tests
shape, and having named it in this very PR makes it worse rather than better.
### What changed
The conjunction moved out of `main.py` into **`peakguard.shaping_suspended()`**,
for the reason `watchdog.loop_is_following()` and `freeze.looks_frozen()` live
where they do: a check that grades a safety predicate has to assert on the
*shipped expression*. Its docstring carries both terms and why neither is
sufficient alone; the call site now says read that function first, and records
that a reviewer mutated it back to its historical form with every suite green.
Part 7 gains a section that **executes** the three mutations rather than
describing them. Each is applied to the shipped module and the **real
`Controller.cycle()`** is driven with it, on your fixture: default install, no
`peak_forecast_entity`, maintenance in `CHARGE` so a `P_MAINT_SHAPE` `hi <= 0`
limit is live, guard genuinely engaged on the measurement (`blind=None`,
asserted). Monkeypatching the shipped module is itself load-bearing - if
`main.py` ever inlines either expression again the patch does nothing and these
checks would pass vacuously, so re-inlining is caught too.
```
SHIPPED suspended=True loop -> 1498 W, limited by peak +1497.8 W
B1 suspended=False CONTRADICTION between limits 0.0 W
B2 suspended=False CONTRADICTION between limits 0.0 W
```
Your numbers reproduce; mine differ only because the fixture load is derived from
where in the live quarter the wall clock lands (part 7 drives the real cycle, so
a fixed load asks 1700 W with ten minutes left and 300 kW with five seconds).
I added a fourth, **B2b**: the *forecast* term dropped instead of the measured
one. It has its own check, so neither half can be deleted as redundant - the
measured term is what saves a default install, and the forecast term still
suspends the shaping on a configured site before the guard engages.
### B3, graded three ways
One cycle of the intent form is only *inflated*; the failure is the cycle after,
when its own unmet ask has become the standing command. So:
- the value `main.py` actually hands `decide()` is **recorded off the real call**,
measured battery `0 W` against a command standing at `+3000 W`;
- the one-cycle difference is asserted to be **exactly the standing command** -
the defect in one line, watts asked for and never delivered counted as if they
had;
- both forms are run forward against the real controller's own projection with an
inverter that never follows:
```
intent-sized floor 4482 5000 5000 5000 ... SATURATED at cycle 2
delivered-power floor 1482 1482 1482 1482 ... stationary
```
- and the call site is graded on **parsed syntax** - `decide(batt_w=...)` must
unparse to `self.batt` - so a rename cannot quietly reintroduce it. `B3b`
(sizing from `self.target`) is run as a separate mutation and also goes red.
### SAFETY-24
Taken, and pointed at from `peakguard.py` in its own section, with the number
that makes it real (878 Wh of forced discharge on the 2026-08-20 replay) and the
statement that this must not go to a client install before it lands. A ticket
nobody can find from the code is a ticket nobody reads before shipping. There is
a check that the pointer is still there.
### Red and green
**Fifteen mutations** run against the shipped modules, each reverted after: the
six new wiring ones (B1, B2, B2b, B3, B3b, and deleting the SAFETY-24 pointer)
and the eight from the first round re-run to confirm they still bite. **All
fifteen turn `test_peakguard.py` red.** Full suite green including
`test_price.py` and TARIFF-03's `test_static_tariff.py`.
### On the rest of your review
Thank you for re-deriving the two-day numbers from your own harness rather than
taking mine - that is the check I could not do for myself. Noted and not
re-litigated: AC 3 stays TARIFF-01's, the `is_observed`-true-for-`NO_RECORD`
hole stays with TEST-13 (agreed I do not inherit the harm - the `peak_w is not
None` conjunct is a second independent gate), and part 8's seventh defeat stays
honestly reported as not caught.
Rebase: only `CHANGELOG.md` conflicted, as you said. `app/main.py` and `DOCS.md`
auto-merged, so the overlap claim holds - `config.yaml` and `validate.py` are
still untouched by this card after TARIFF-02 and TARIFF-03 widened both.
All three closed, and the structural fix is real rather than tidy. Merging.
Fresh detached worktree at c2ed509. Whole suite green: 13 files, all exit 0. CI verified by me, not taken on report: checks / unit (pull_request) -> success, Successful in 3m12s, run 543 / job 1538, combined state success.
The move into peakguard.shaping_suspended() is genuine
main.py calls it and keeps no copy of either term — I read the call site rather than the comment. It is the only place the conjunction is written, and it is public for the same reason loop_is_following() and looks_frozen() are. That is the correct fix, not a cosmetic relocation: my B1/B2 were only possible because the expression was inline and nothing could reach it.
The monkeypatch cannot go vacuous — I tested it rather than accepting the argument
This was the thing worth checking hardest, and your reasoning holds for a sharper reason than the comment gives. I re-inlined the conjunction in main.py with behaviour-identical code, so the monkeypatch would silently do nothing:
V1 - RE-INLINED in main.py, behaviour identical
test_peakguard exit=1 FAILs=2
FAIL B1: ... CONTRADICTION and 0 W ...
FAIL B2: ... parks a default install at 0 W ...
Re-inlining makes B1 and B2 fail, not pass. The reason is that those checks assert the mutation takes effect ("CONTRADICTION" in reason and near(target, 0.0)) rather than asserting an absence — so a patch that stops reaching its target turns them red. That is the self-guarding shape, and it is the answer to "a mutation harness that silently stops reaching its target", which is exactly how this session's other defects hid. Worth keeping that property in mind if these checks are ever rewritten to assert a negative.
All four wiring mutations now bite, at source level
I ran them against the shipped modules rather than through your harness:
B1 shaping_suspended() -> False RED (3)
B2 re-gated on the forecast alone RED (2)
B2b forecast term dropped instead RED (1)
B3 batt_w from the standing command RED (3)
B2b confirms what you added it for: neither half of the conjunction can be deleted as redundant. And the B3 iteration is the right framing — one cycle of the intent form is only inflated; the failure is cycle 2 onward, and running both forms forward against a frozen projection is what makes that visible instead of arguable.
The syntax grader discriminates only partly, and the behavioural checks are what save it
You asked; here is the honest answer, from three attacks:
S1 batt_w=(self.batt) - parenthesised GREEN (correct: ast.unparse, not text)
S2 a decoy `.decide()` earlier + real call changed to self.target
RED, but by the BEHAVIOURAL checks only
S3 syntax kept, `self.batt` overwritten just before the call
RED, but by the BEHAVIOURAL checks only
S1 is the good news: it is graded on the parsed AST, so formatting cannot break it. But in S2 and S3 the syntax check itself passed and only the two behavioural checks bit. next(n for n in ast.walk(...) if ... n.func.attr == "decide") takes the first.decide( in the file — there is exactly one today, which is why it works — and it grades a name, not a value, so overwriting self.batt on the line above walks straight past it.
Not blocking, because the behavioural pair is the real gate and it held in both attacks. But the check's sentence — "the call site says so in the source" — claims more than the check delivers. Two small things when you are next in the file: collect the .decide( calls into a list and assert there is exactly one before unparsing it, and reword the line so it reads as the cheap corroboration it is rather than as the guarantee.
Four more of mine, all red, no survivors
C1 the SAFETY-24 pointer removed RED (1) - the addendum check works
C2 the achievable_w clamp removed RED (6)
C3 the blind floor is not 0 W RED (4)
C4 the guard runs on meter_source: off RED - test_peakguard (1) AND test_clamp (1)
C4 going red in two suites is the cross-suite catch you described as how the legacy-path regression was originally found; it still works.
Coordination
FCST-03: no overlap. SAFETY-07 touches none of app/price.py, app/static_tariff.py, app/validate.py or config.yaml — I checked the diff against the current tip rather than trusting the claim. config.yaml and validate.py really are still untouched by this card even after TARIFF-02 and TARIFF-03 widened both.
Merges into cf5094c with no conflict at all now — the rebase cleared even the CHANGELOG.
Nothing re-litigated, as you said: AC 3 stays TARIFF-01's and the card must not close as AC 3 met; the is_observed/NO_RECORD hole stays with TEST-13 (and SAFETY-07 still does not inherit its harm — the peak_w is not None conjunct is a second, independent gate); part 8's seventh defeat stays honestly reported.
SAFETY-24 must land before this reaches a client install. It is named in peakguard.py with the 878 Wh figure and a check that the pointer survives, which is the right place for it.
Everything I raised is closed and nothing new survives. Merging.
## All three closed, and the structural fix is real rather than tidy. Merging.
Fresh detached worktree at `c2ed509`. Whole suite green: **13 files, all exit 0**. CI verified by me, not taken on report: `checks / unit (pull_request) -> success, Successful in 3m12s`, run 543 / job 1538, combined state `success`.
### The move into `peakguard.shaping_suspended()` is genuine
`main.py` calls it and keeps no copy of either term — I read the call site rather than the comment. It is the only place the conjunction is written, and it is public for the same reason `loop_is_following()` and `looks_frozen()` are. That is the correct fix, not a cosmetic relocation: my B1/B2 were only possible *because* the expression was inline and nothing could reach it.
### The monkeypatch cannot go vacuous — I tested it rather than accepting the argument
This was the thing worth checking hardest, and your reasoning holds for a sharper reason than the comment gives. I **re-inlined the conjunction in `main.py` with behaviour-identical code**, so the monkeypatch would silently do nothing:
```
V1 - RE-INLINED in main.py, behaviour identical
test_peakguard exit=1 FAILs=2
FAIL B1: ... CONTRADICTION and 0 W ...
FAIL B2: ... parks a default install at 0 W ...
```
**Re-inlining makes B1 and B2 fail, not pass.** The reason is that those checks assert the mutation *takes effect* (`"CONTRADICTION" in reason and near(target, 0.0)`) rather than asserting an absence — so a patch that stops reaching its target turns them red. That is the self-guarding shape, and it is the answer to "a mutation harness that silently stops reaching its target", which is exactly how this session's other defects hid. Worth keeping that property in mind if these checks are ever rewritten to assert a negative.
### All four wiring mutations now bite, at source level
I ran them against the shipped modules rather than through your harness:
```
B1 shaping_suspended() -> False RED (3)
B2 re-gated on the forecast alone RED (2)
B2b forecast term dropped instead RED (1)
B3 batt_w from the standing command RED (3)
```
B2b confirms what you added it for: **neither half of the conjunction can be deleted as redundant.** And the B3 iteration is the right framing — one cycle of the intent form is only inflated; the failure is cycle 2 onward, and running both forms forward against a frozen projection is what makes that visible instead of arguable.
### The syntax grader discriminates only partly, and the behavioural checks are what save it
You asked; here is the honest answer, from three attacks:
```
S1 batt_w=(self.batt) - parenthesised GREEN (correct: ast.unparse, not text)
S2 a decoy `.decide()` earlier + real call changed to self.target
RED, but by the BEHAVIOURAL checks only
S3 syntax kept, `self.batt` overwritten just before the call
RED, but by the BEHAVIOURAL checks only
```
S1 is the good news: it is graded on the parsed AST, so formatting cannot break it. But in S2 and S3 **the syntax check itself passed** and only the two behavioural checks bit. `next(n for n in ast.walk(...) if ... n.func.attr == "decide")` takes the **first** `.decide(` in the file — there is exactly one today, which is why it works — and it grades a *name*, not a value, so overwriting `self.batt` on the line above walks straight past it.
Not blocking, because the behavioural pair is the real gate and it held in both attacks. But the check's sentence — *"the call site says so in the source"* — claims more than the check delivers. Two small things when you are next in the file: collect the `.decide(` calls into a list and assert there is exactly one before unparsing it, and reword the line so it reads as the cheap corroboration it is rather than as the guarantee.
### Four more of mine, all red, no survivors
```
C1 the SAFETY-24 pointer removed RED (1) - the addendum check works
C2 the achievable_w clamp removed RED (6)
C3 the blind floor is not 0 W RED (4)
C4 the guard runs on meter_source: off RED - test_peakguard (1) AND test_clamp (1)
```
C4 going red in **two** suites is the cross-suite catch you described as how the legacy-path regression was originally found; it still works.
### Coordination
- **FCST-03: no overlap.** SAFETY-07 touches none of `app/price.py`, `app/static_tariff.py`, `app/validate.py` or `config.yaml` — I checked the diff against the current tip rather than trusting the claim. `config.yaml` and `validate.py` really are still untouched by this card even after TARIFF-02 and TARIFF-03 widened both.
- Merges into `cf5094c` with **no conflict at all** now — the rebase cleared even the CHANGELOG.
- Nothing re-litigated, as you said: AC 3 stays TARIFF-01's and the card must not close as AC 3 met; the `is_observed`/`NO_RECORD` hole stays with TEST-13 (and SAFETY-07 still does not inherit its harm — the `peak_w is not None` conjunct is a second, independent gate); part 8's seventh defeat stays honestly reported.
- SAFETY-24 must land before this reaches a client install. It is named in `peakguard.py` with the 878 Wh figure and a check that the pointer survives, which is the right place for it.
Everything I raised is closed and nothing new survives. Merging.
admin
merged commit e7c95bfd8a into release/1.02026-08-30 23:56:21 +02:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
SAFETY-07 - the quarter-peak projection guard
TARIFF-01 measured the quarter and named this card as the consumer. One new
module,
app/peakguard.py, and one new claim. Base is0dbd485.The mechanism, and why it lives where it does
Every cycle the guard projects the open 900 s block to its end-of-interval
average - from P1 telegrams only, never from the command - and compares it to a
ceiling. Over the ceiling it files one arbiter claim: a lower bound on the
setpoint at
P_PEAK.That single shape is both behaviours §10.1 row 2 asks for ("pre-emptive discharge
as the projected quarter average approaches the soft limit; hard clamp on
charging above it"), and it is incapable of capping discharge or export,
because a floor cannot. §9.1's asymmetry becomes a property of the object rather
than a rule someone has to remember.
The
900/remaininggain is 1.0 at the top of the quarter and grows, so the guardis gentlest where its projection rests on the least evidence and only becomes
aggressive once the quarter is nearly measured. No minimum-evidence threshold was
invented; the formula supplies it.
The floor is sized from delivered battery power, not the standing command.
Both were available. §9.1 says the utility bills what flowed - and concretely, a
floor built on intent re-adds its own unmet ask every cycle and winds to the rail
whenever the inverter fails to follow.
test_peakguard.pypart 1 runs fivecycles of a non-following inverter and shows the two forms diverging.
It is a LIMIT, not a SET:
P_MAINTENANCEand everyP_SAFETYstop still outrankit. And OPT-04 needs no change here - arbitrage will arrive as a SET below
P_PEAKand clause 2 will bind it with this floor on the day it lands.Trip vs notify, argued from the data
SAFETY-18 shipped notification-first because "a false trip parks the battery".
That is right for a detector whose negative case had to be established on a rig
run. It does not carry here:
on 2026-08-23, 0 Wh of modelled discharge.
test_peakguard.pyre-measures itfrom the committed capture every run.
projection every cycle and released the instant the projection drops. A false
engagement costs one cycle of discharge, not a parked battery - the cost
SAFETY-18 could not bound and this one can.
floor is bounded by
achievable_w.So it trips.
What it does to 2026-08-20's billed quarter
Replayed closed-loop through the real ingest, tracker and guard:
5029.8 W is a billed quarter above the live install's own 5000 W rating. The
guard removes 1480 W of it without ever saturating, at a modelled cost of 878 Wh
of discharge on that day and 0 Wh on the healthy day.
The replay model is stated in the check's own docstring rather than hidden: the
captures carry only net meter power, so the guard's battery is modelled as an
additional discharge and the idle baseline is 0 W, which is deliberately
pessimistic - the real loop would have been discharging too. Two days is not
thirty; AC 3 stays TARIFF-01's open item and this does not claim it.
Blindness comes first, and it is a different action
A projection is evidence only if the quantity under it was measured. Three ways
it is not, all checked before the projection is graded:
project()answers a fresh averager with a confident0 W, which is under every ceiling there is;
meter_max_age_sleaves seconds the averagerdeliberately does not accumulate, so the projection reads LOW;
freeze.looks_frozen()itself,evaluated on this cycle's standing command. A meter stuck on export projects a
confident zero at full coverage while the real meter runs at the rail.
All three become a floor of 0 W: no importing while the billed quantity is
unobservable. One-directional, so it cannot cost pack energy - and it is the
backstop the freeze alarm could not be, which fires at 300 s of a 900 s quarter
and commands nothing.
The ceiling, and a failed read
ceiling = max(peak_cap_w, the month's banked peak)- once a month's bill standsat 5013 W, holding later quarters to 3500 W buys nothing and costs real charging.
That relaxation is granted only by an
OBSERVEDreading.NO_RECORDandNOT_OBSERVEDboth leave the ceiling at the cap, so an unreadable store makesthe guard stricter. Graded against a real
PeakStoreon a missing volume, andthe acceptance half is that the guard still engages on it.
headroom_w()isdeliberately not consumed -
cap - peakturns a zero peak into FULL freedom, andthat is the disarming default this whole chain exists to remove.
The gate is the reading's state, not
peak_w is not None. Today the twobehave identically, and depending on that coincidence is exactly how a future
last-known-peak field would raise the ceiling off a read that failed.
project()'s defaulted read - fixed, not inheritedgetattr(averager, "_last_net", 0.0)failed quiet and in the disarmingdirection: a rename credits 0 W held, the projection reads low, the guard does
not engage.
QuarterAverager.held_net_wis now a documented public property readwithout a default, and
Projectioncarries it. On the check's own fixture theold form reads 3200 W low - the difference between engaging and not. TEST-13
item 4.
Two defects found building this, both in the interaction
resolve()applies every limit ator above the winner's priority and takes the most restrictive, with no
priority between them - which is what makes the safety bounds unconditional.
So the peak floor (
lo >= 0) and the maintenance charge-only shaping(
hi <= 0) resolved to a CONTRADICTION and 0 W: the battery parked in theexact quarter it should shave. Fixed by suppression, not priority -
main.py drops the maintenance shaping while the guard is engaged, which is
what that branch already did for
peak_forecast_entity, an optional key thatis empty on a default install and so could never fire on the site that needed
it.
excess > 0oscillates.excessfalls to zero as soon as thebattery is already taking the watts, so the guard released at the instant it
was working: seventeen engage/release cycles in one 900 s quarter, and that
quarter still finished 49 W over the limit it had spent fifteen minutes
holding. §10.2 names the cost. The threshold is now the floor itself - the
same arithmetic read as a level rather than a switch - and there is still no
hysteresis constant, because the quantity being thresholded is the one that
does not oscillate.
A third, caught by
test_clamp.py: onmeter_source: offthere are no telegrams,so the guard would have been blind forever - and blind means no charging. It now
does not run there at all and the status page says NOT COVERED, the same
answer the freeze detector already gives on a transport that cannot see it.
Alarm surface
An engagement is an ordinary control action - action log and status page, no
notification. §10.3's "clamp AND alarm" is SAFETY-03's rule about a bug signal;
a 5900 W house load on a 5000 W inverter is not a bug, and alarming on the
ordinary case is how an operator learns to dismiss the one that matters. What
alarms is saturation (
goodwe_peak_saturated, its own id, once per episode):the guard asking for everything the inverter has while the quarter is still
projected over, i.e. the one state where the money will be spent and shedding
load is all that is left.
CFG-03
No new option, no fourth defaults site. The ceiling is
peak_cap_w, alreadyshipped and already validated; the coverage threshold is
meter_max_age_s; thefreeze predicate is
meter_freeze_s; the achievable discharge comes frommax_wand the device rating.
config.yamlandvalidate.pyare untouched. §14'scapacity_peak_limit_kwstays declined as the kW/W mismatch trap it is. Thecheck grades this on parsed syntax -
peakguard.pymakes zero.get()calls andtakes
cap_was an argument - and additionally asserts every literalpeak_cap_wfallback inmain.pyequals config.yaml's shipped value, which isthe divergence
test_validate.pyrecords formaintenance_charge_w.Per-phase
Nothing here indexes a phase. Everything consumed -
PeakReading.peak_w,Projection.projected_w- is already reduced by TARIFF-01's singlebilled_w(),so whichever way the owner answers sum/max/net, this file does not change. That
also keeps it clear of TEST-13's finding that the per-phase plumbing is less
tested than the passing suite implies.
Checks
test_peakguard.py, eight parts, house style,assert-freecheck()lines withevery threshold read out of the shipped
config.yamlrather than typed. Part 8runs seven one-line defeats of the guard and reports which check bites:
The last is reported as not caught rather than papered over:
max(cap, peak)makes a zero peak the strictest ceiling there is, so there is nothing left to
disarm. That is also why the ceiling is a max and not a subtraction.
Eight source-level mutations were also applied to the shipped modules and each
confirmed to turn the file red before being reverted. Full suite green:
test_arbiter test_clamp test_control test_freeze test_loop test_maintenance test_p1 test_peakguard test_tariff test_validate test_watchdog.test_peakguardwas run five times consecutively to confirm it is not wall-clock flaky - part 7
drives the real
Controller.cycle(), so its fixture is derived from where inthe live quarter the clock lands.
Not done / for the reviewer
not exist;
test_tariff.pypart 9 carries that argument and this file defersto it.
backstop for the grid-following loop, unchanged by this card. Worth a ticket.
live install is regulating a real battery; the surfaces used were the committed
captures and the real modules.
CHANGELOG.mdonly - one hunk at thetop of Unreleased.
validate.pyandconfig.yamlare untouched here.with the product owner; nothing here treats it as settled either way.
Card:
6a8b93421701cd74d40ca586TARIFF-01 measured the quarter and said the consumer would be this card. It is one new module, app/peakguard.py, and one new claim. THE MECHANISM. Every cycle the guard projects the open 900 s block to its end-of-interval average - from P1 telegrams only, never from the command - and compares it to a ceiling. Over the ceiling it files ONE arbiter claim: a lower bound on the setpoint at P_PEAK. That single shape is both behaviours 10.1 asks for, a hard clamp on charging and pre-emptive discharge, and it cannot cap discharge or export, because a floor cannot. 9.1's asymmetry becomes a property of the object instead of a rule to remember. excess = 900 x (projected - ceiling) / seconds remaining floor = MEASURED battery power + excess The 900/remaining gain is 1.0 at the top of the quarter and grows, so the guard is gentlest where its projection rests on the least evidence. The floor is sized from delivered battery power, not the standing command: a floor built on intent re-adds its own unmet ask every cycle and winds to the rail whenever the inverter fails to follow, and test_peakguard.py part 1 shows the two diverging. IT TRIPS, AND THE CAPTURES DECIDED THAT. On the healthy reference day it engages on zero of 95 quarters. The action is level-triggered, unlatched and re-derived every cycle, so a false engagement costs one cycle of discharge rather than a parked battery - the cost SAFETY-18 could not bound and this one can. Replayed on 2026-08-20 the billed quarter goes from 5029.8 W, above the inverter's own 5000 W rating, to 3485 W, under the soft limit and without saturating. Quarters under the limit across both committed days: 189/190 -> 190/190. Two days is not thirty and AC 3 remains TARIFF-01's open item. BLINDNESS IS CHECKED BEFORE THE PROJECTION IS GRADED, and it is a different action. A fresh averager projects a confident 0 W, which is under every ceiling there is; a meter gap longer than meter_max_age_s makes the projection read LOW; a frozen meter reading export projects 0 W at full coverage. All three become a floor of 0 W - no importing while the billed quantity is unobservable - which is one-directional and cannot cost pack energy. That is the backstop SAFETY-18 could not be: its alarm arrives at 300 s of a 900 s quarter and commands nothing. The frozen term is freeze.looks_frozen() itself, on this cycle's standing command. THE CEILING IS peak_cap_w, RAISED ONLY BY AN OBSERVED READING. NO_RECORD and NOT_OBSERVED both leave it at the cap, so an unreadable peak store makes the guard stricter and never looser. The gate is the reading's STATE, not whether a number happens to be attached: today a `peak_w is not None` test behaves identically, and depending on that coincidence is how a future last-known-peak field would raise the ceiling off a read that failed. headroom_w() is deliberately not consumed - cap minus peak turns a zero peak into FULL freedom. TWO DEFECTS FOUND BUILDING IT, both in the interaction rather than the arithmetic, and both caught by running the thing rather than by reading it: * A LIMIT does not out-vote another LIMIT. arbiter.resolve() applies every limit at or above the winner's priority and takes the most restrictive, so the peak floor (lo >= 0) and the maintenance charge-only shaping (hi <= 0) resolved to a CONTRADICTION and 0 W - the battery parked in the exact quarter it should shave. Fixed by suppression, not priority: main.py drops the maintenance shaping while the guard is engaged, which is what that branch already did for the optional peak_forecast_entity - a key empty on a default install, so until now the suspension could not fire on the site that needed it. * Thresholding `excess > 0` released the claim the instant it began working, producing seventeen engage/release cycles in one 900 s quarter and leaving that quarter 49 W over the limit it had spent fifteen minutes holding. That is 10.2's oscillation. The threshold is now the floor itself, which is the same arithmetic read as a level rather than as a switch. PeakTracker.project() no longer reads a private name with a default. getattr(averager, "_last_net", 0.0) failed quiet AND disarming - a rename credits 0 W held, the projection reads low, the money guard does not engage. QuarterAverager.held_net_w is a documented public property now, read without a default, and Projection carries it. TEST-13 item 4. NO NEW OPTION AND NO FOURTH DEFAULTS SITE. The ceiling is peak_cap_w, the coverage threshold is meter_max_age_s, the freeze predicate is meter_freeze_s, and the achievable discharge comes from max_w and the device rating. config.yaml and validate.py are untouched; 14's capacity_peak_limit_kw stays declined. On meter_source: off there are no telegrams, so the guard does not run at all and the status page says NOT COVERED - running it there blinded it permanently and stopped every legacy install from charging, which is what the first cut did until test_clamp.py's export case caught it. test_peakguard.py: eight parts, all figures derived from the shipped config.yaml and the committed captures. Part 8 runs seven one-line defeats of the guard and reports which check bites; six do, and the seventh is reported as NOT caught, because max(cap, peak) makes a zero peak the strictest ceiling there is and there is nothing left to catch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPCNot merging. The mechanism is right and the money result reproduces exactly - but the fix for the most expensive defect this PR found is not pinned by anything, and I reproduced that defect by reintroducing it while the whole suite stayed green.
Own detached worktree at
9f8b721, own replay, own harness. All twelve suites green as shipped. CI verified by me:checks / unit (pull_request) -> success, Successful in 3m9s, run 542 / job 1537, combined statesuccess.Almost everything here is very good. Three things are not covered, all of them in
main.py's wiring rather than inpeakguard.py, and one of them re-creates the exact bug your own comment explains.MUST FIX - three live mutations, whole suite green on all three
I drove the real
Controller.cycle()on a default install (nopeak_forecast_entity) with the maintenance charge-only shaping active and the guard genuinely engaged on the measurement (projected 4972 W,excess 2008 W,blind=None).Shipped - correct:
B1- the suppression removed (if False:):test_peakguard.pyand all eleven other suites stay GREEN.B2- the suppression re-gated onpeak_forecast_entity: identical output,CONTRADICTION,0.0 W, suite GREEN.B2is the one that matters most, because it is not hypothetical - it is verbatim the bug your own comment describes:The comment explains the historical defect. Nothing tests that it stays fixed. Re-adding the optional-key gate restores battery-parked-at-0-W-in-the-shaving-quarter on exactly the default install, and no check anywhere notices.
B3- the floor sized from the standing command instead of measured battery power. On a cycle where the inverter is not following (measured0 W, standing+3000 W):That is precisely the winding-to-the-rail failure part 1 demonstrates on
decide()- and the wiring that prevents it (batt_w=self.batt) is not asserted anywhere. Part 1 grades the function with a hand-passedbatt_w; nothing grades which valuemain.pyhands it. Suite GREEN.What closes all three is one scenario you already have the scaffolding for in part 7 -
built()plus a forcedcharge_onlyresult:peak_forecast_entityin the options, assert_maint_shape_suspendedis True, the resolution is not a contradiction, and the target is > 0;peak_forecast_entity" part explicit, so re-adding the optional-key gate fails rather than passing;self.battand the standing command deliberately different, asserting the floor tracks the measured one.I built it in about forty lines against your own
_Hass/_Storestubs; it is not a large ask.Everything else, verified rather than accepted
The two-day result - reproduced exactly, on my own replay
I wrote my own replay loop from the model stated in your docstring (the capture's
wis the load; the guard's battery is an additional discharge subtracted from it, delivered on the next telegram; idle => 0 W baseline), driving the shippedP1Ingest/PeakTracker/decide(). Nothing of yours but the modules under test:5029.8 -> 3485.3, 190/190, 878 Wh, 0 Wh on the healthy day - every figure lands. The 5029.8 W also matches what I computed independently while reviewing TARIFF-01, from a different harness.
A
lolimit cannot cap discharge or export - it is a property, not a ruleNot one witness; a search:
hiisinf,decide()cannot emit a negative floor, and no floor value anywhere reduces a full-rate discharge. Section 9.1's asymmetry really is structural here.The
900/remaininggain, at the boundaryGain is exactly 1.0 at the top - a real value, not a coincidence of two formulas - and the blow-up near the end is bounded by the
achievable_wclamp, so it can never filelo > hi. No degeneracy hiding at this boundary.The ceiling gate, and the mutation that is open on TEST-13
A1is the TEST-13 item, and SAFETY-07 does not inherit its harm - I checked rather than assumed. Under the broken predicate:The
peak_w is not Noneconjunct is a second, independent gate, so a broken predicate cannot move the ceiling on its own. That is a good defensive shape and worth saying out loud: this guard stays correct even if the predicate it depends on regresses. FixingA1belongs to TEST-13, not here.project()'s defaulted read - properly fixedheld = max(averager.held_net_w, 0.0), a bare read. I deleted the property outright, the way a rename would:TEST-13 item 4 is closed, not inherited.
Part 8's honesty
Confirmed rather than assumed: the 7th defeat really is not caught, and the reason -
max(cap, peak)making a zero peak the strictest ceiling there is - is correct. ReportingNOT CAUGHTrather than quietly dropping it is why I trusted the rest of the file enough to go hunting inmain.pyinstead.Rulings you asked for
AC 3 - not met, not a blocker, and I am ruling the same way I ruled on TARIFF-01. No 30-day capture exists, nothing here asserts the 95 % figure, and deferring to
test_tariff.pypart 9 rather than re-litigating it is right. The card must not close as AC 3 met, and the 30-day recording needs to start, since it cannot be produced retroactively.The missing SoC guard - correct not to block here, and I would not have blocked on it.
peakguard.pycontains no reference to SoC at all (I grepped: zero), so the guard can force discharge tomin(max_w, dev_max)with no floor but the BMS. But that is not a new class of exposure:freeze.pyalready records that "there is no SoC guard on the grid-following loop at all... the BMS is the sole energy backstop". What SAFETY-07 changes is the frequency - it will force discharge where the loop would not have, and 878 Wh on one modelled day against a pack that has been sitting near 9 % is not nothing. So: right to file separately, wrong to ship to a client install without it, andpeakguard.pyshould carry a one-line pointer to that ticket where a reader will meet it.The suppression fix is the right shape. Suppression rather than priority is correct, and for the reason given: arbiter clause 2 has no notion of a higher-priority limit overriding a lower one and must not gain one - "most restrictive" is what makes the safety bounds unconditional.
self.peak_guardis assigned at line 464 and read at line 520, in the same cycle, so there is no one-cycle lag on the engaging edge; I checked, because that ordering is where this project keeps finding bugs. The design is right. It is only undefended.Coordination
git merge-treebetween the two heads reportsAuto-merging goodwe_controller/app/main.pywith no conflict - onlyCHANGELOG.mdconflicts, andDOCS.mdauto-merges. Yourmain.pyhunks are in__init__,write_setpoint,cycle(), the arbiter path andchecks(); theirs are inread_inputs()'s else-branch and one status row. Either order works. I would land #19 first, since it is smaller and this PR needs another commit regardless.release/1.0otherwise.Add the three assertions and I will merge it. The mechanism, the arithmetic and the money result are all sound - this is about defending the fix you already made.
The reviewer's finding was correct and the shape of it matters: everything the first cut graded was in peakguard.py, and grading the module is not the same as grading the guard. Three one-line changes to main.py's CALL SITE left all twelve suites green while a default install parked at 0 W in the quarter that sets the month's bill. B2 was the serious one. My own comment described that defect - "that entity is optional and empty on a default install, so until now the suspension could not fire on the site that needed it" - and nothing could regress it. A comment that documents a fix nobody can test is the same shape as a corrected comment nobody tests, which this project has already been caught by once. WHAT CHANGED The suppression conjunction moved out of main.py and into peakguard.shaping_suspended(), for the reason watchdog.loop_is_following() and freeze.looks_frozen() live where they do: the check that grades a safety predicate must assert on THE SHIPPED EXPRESSION, not on a transcription that can drift apart from it in silence. Its docstring carries both terms and why neither is sufficient alone. main.py's call site now says READ THAT FUNCTION FIRST, and records that a reviewer mutated it back to its historical broken form with every suite staying green. test_peakguard.py part 7 gains a section that EXECUTES the three mutations rather than describing them. Each one is put in place on the shipped module and the REAL Controller.cycle() is driven with it, on the dangerous fixture: a default install (no peak_forecast_entity, so peak_fc is None and peak_at_risk is False forever) with maintenance in its CHARGE phase, so a P_MAINT_SHAPE `hi <= 0` limit is live, and the guard genuinely engaged on the measurement. That the mutations are monkeypatched onto the shipped module is itself load-bearing: if main.py inlined either expression again the patch would do nothing and these checks would pass vacuously. SHIPPED suspended=True loop -> 1498 W, limited by peak +1497.8 W B1 suspended=False CONTRADICTION between limits 0.0 W B2 suspended=False CONTRADICTION between limits 0.0 W B2b (forecast term dropped instead) - caught by its own check B3 is graded three ways, because the single-cycle form is only INFLATED and the failure is what happens on the next cycle: * the value main.py actually hands decide() is recorded off the real call, with the measured battery at 0 W and the command standing at +3000 W; * the one-cycle difference is asserted to be exactly the standing command - the whole defect in one line, watts that were asked for and never arrived counted as if they had; * both forms are then run forward against the REAL controller's own projection with an inverter that never follows: intent-sized floor 4482 5000 5000 5000 ... SATURATED at cycle 2 delivered-power floor 1482 1482 1482 1482 ... stationary * and the call site is graded on parsed syntax, so a rename cannot quietly reintroduce it: `decide(batt_w=...)` must unparse to `self.batt`. SAFETY-24 is now named in peakguard.py, in its own section, with the number that makes it real - 878 Wh of forced discharge on the 2026-08-20 replay - and the statement that this must not go to a client install before that ticket lands. A ticket nobody can find from the code is a ticket nobody reads before shipping. RED AND GREEN Fifteen mutations run against the shipped modules, each reverted after: the six new wiring ones (B1, B2, B2b, B3, B3b, and deleting the SAFETY-24 pointer) and the eight from the first round, re-run to confirm they still bite. All fifteen turn test_peakguard.py red. Full suite green, test_price.py and TARIFF-03's included. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPCAll three defended, pushed as
2d570d8(+c2ed509, the TARIFF-03 changelogrebase). Head is
c2ed509,checks / unit (pull_request)green,mergeable: true.You were right about the shape of it, and that is the part worth saying back:
everything I graded was in
peakguard.py, and grading the module is not the sameas grading the guard. The call site was the untested surface and I did not see it.
B2 is the one I should have written a check for and did not. My own comment
described the defect - "that entity is optional and empty on a default install,
so until now the suspension could not fire on the site that needed it" - and
nothing could regress it. That is exactly the corrected-comment-nobody-tests
shape, and having named it in this very PR makes it worse rather than better.
What changed
The conjunction moved out of
main.pyintopeakguard.shaping_suspended(),for the reason
watchdog.loop_is_following()andfreeze.looks_frozen()livewhere they do: a check that grades a safety predicate has to assert on the
shipped expression. Its docstring carries both terms and why neither is
sufficient alone; the call site now says read that function first, and records
that a reviewer mutated it back to its historical form with every suite green.
Part 7 gains a section that executes the three mutations rather than
describing them. Each is applied to the shipped module and the real
Controller.cycle()is driven with it, on your fixture: default install, nopeak_forecast_entity, maintenance inCHARGEso aP_MAINT_SHAPEhi <= 0limit is live, guard genuinely engaged on the measurement (
blind=None,asserted). Monkeypatching the shipped module is itself load-bearing - if
main.pyever inlines either expression again the patch does nothing and thesechecks would pass vacuously, so re-inlining is caught too.
Your numbers reproduce; mine differ only because the fixture load is derived from
where in the live quarter the wall clock lands (part 7 drives the real cycle, so
a fixed load asks 1700 W with ten minutes left and 300 kW with five seconds).
I added a fourth, B2b: the forecast term dropped instead of the measured
one. It has its own check, so neither half can be deleted as redundant - the
measured term is what saves a default install, and the forecast term still
suspends the shaping on a configured site before the guard engages.
B3, graded three ways
One cycle of the intent form is only inflated; the failure is the cycle after,
when its own unmet ask has become the standing command. So:
main.pyactually handsdecide()is recorded off the real call,measured battery
0 Wagainst a command standing at+3000 W;the defect in one line, watts asked for and never delivered counted as if they
had;
inverter that never follows:
decide(batt_w=...)mustunparse to
self.batt- so a rename cannot quietly reintroduce it.B3b(sizing from
self.target) is run as a separate mutation and also goes red.SAFETY-24
Taken, and pointed at from
peakguard.pyin its own section, with the numberthat makes it real (878 Wh of forced discharge on the 2026-08-20 replay) and the
statement that this must not go to a client install before it lands. A ticket
nobody can find from the code is a ticket nobody reads before shipping. There is
a check that the pointer is still there.
Red and green
Fifteen mutations run against the shipped modules, each reverted after: the
six new wiring ones (B1, B2, B2b, B3, B3b, and deleting the SAFETY-24 pointer)
and the eight from the first round re-run to confirm they still bite. All
fifteen turn
test_peakguard.pyred. Full suite green includingtest_price.pyand TARIFF-03'stest_static_tariff.py.On the rest of your review
Thank you for re-deriving the two-day numbers from your own harness rather than
taking mine - that is the check I could not do for myself. Noted and not
re-litigated: AC 3 stays TARIFF-01's, the
is_observed-true-for-NO_RECORDhole stays with TEST-13 (agreed I do not inherit the harm - the
peak_w is not Noneconjunct is a second independent gate), and part 8's seventh defeat stayshonestly reported as not caught.
Rebase: only
CHANGELOG.mdconflicted, as you said.app/main.pyandDOCS.mdauto-merged, so the overlap claim holds -
config.yamlandvalidate.pyarestill untouched by this card after TARIFF-02 and TARIFF-03 widened both.
All three closed, and the structural fix is real rather than tidy. Merging.
Fresh detached worktree at
c2ed509. Whole suite green: 13 files, all exit 0. CI verified by me, not taken on report:checks / unit (pull_request) -> success, Successful in 3m12s, run 543 / job 1538, combined statesuccess.The move into
peakguard.shaping_suspended()is genuinemain.pycalls it and keeps no copy of either term — I read the call site rather than the comment. It is the only place the conjunction is written, and it is public for the same reasonloop_is_following()andlooks_frozen()are. That is the correct fix, not a cosmetic relocation: my B1/B2 were only possible because the expression was inline and nothing could reach it.The monkeypatch cannot go vacuous — I tested it rather than accepting the argument
This was the thing worth checking hardest, and your reasoning holds for a sharper reason than the comment gives. I re-inlined the conjunction in
main.pywith behaviour-identical code, so the monkeypatch would silently do nothing:Re-inlining makes B1 and B2 fail, not pass. The reason is that those checks assert the mutation takes effect (
"CONTRADICTION" in reason and near(target, 0.0)) rather than asserting an absence — so a patch that stops reaching its target turns them red. That is the self-guarding shape, and it is the answer to "a mutation harness that silently stops reaching its target", which is exactly how this session's other defects hid. Worth keeping that property in mind if these checks are ever rewritten to assert a negative.All four wiring mutations now bite, at source level
I ran them against the shipped modules rather than through your harness:
B2b confirms what you added it for: neither half of the conjunction can be deleted as redundant. And the B3 iteration is the right framing — one cycle of the intent form is only inflated; the failure is cycle 2 onward, and running both forms forward against a frozen projection is what makes that visible instead of arguable.
The syntax grader discriminates only partly, and the behavioural checks are what save it
You asked; here is the honest answer, from three attacks:
S1 is the good news: it is graded on the parsed AST, so formatting cannot break it. But in S2 and S3 the syntax check itself passed and only the two behavioural checks bit.
next(n for n in ast.walk(...) if ... n.func.attr == "decide")takes the first.decide(in the file — there is exactly one today, which is why it works — and it grades a name, not a value, so overwritingself.batton the line above walks straight past it.Not blocking, because the behavioural pair is the real gate and it held in both attacks. But the check's sentence — "the call site says so in the source" — claims more than the check delivers. Two small things when you are next in the file: collect the
.decide(calls into a list and assert there is exactly one before unparsing it, and reword the line so it reads as the cheap corroboration it is rather than as the guarantee.Four more of mine, all red, no survivors
C4 going red in two suites is the cross-suite catch you described as how the legacy-path regression was originally found; it still works.
Coordination
app/price.py,app/static_tariff.py,app/validate.pyorconfig.yaml— I checked the diff against the current tip rather than trusting the claim.config.yamlandvalidate.pyreally are still untouched by this card even after TARIFF-02 and TARIFF-03 widened both.cf5094cwith no conflict at all now — the rebase cleared even the CHANGELOG.is_observed/NO_RECORDhole stays with TEST-13 (and SAFETY-07 still does not inherit its harm — thepeak_w is not Noneconjunct is a second, independent gate); part 8's seventh defeat stays honestly reported.peakguard.pywith the 878 Wh figure and a check that the pointer survives, which is the right place for it.Everything I raised is closed and nothing new survives. Merging.