Card: SAFETY-24 — The peak guard's forced discharge has no SoC floor but the BMS. Base release/1.0 @ e7c95bf.
The gap
SAFETY-07 merged an hour before this card was written and can command a discharge to hold a billed quarter under peak_cap_w. Nothing below it consulted the state of charge: soc_floor belongs to the maintenance cycle, and freeze.py already recorded that for the grid-following loop "the BMS is the sole energy backstop." On the 2026-08-20 replay the guard asks for 878 Wh in a day to hold the peak at 3485 W. That is the guard working as designed, and unbounded from below by anything in the EMS.
Stated fairly: relying on the BMS is not absurd — it is a real floor and it works. But it is the pack's protection, not the operator's. A month of aggressive quarters could leave the battery repeatedly parked at that cut-out with no EMS-side record of why, and the first symptom — a pack that is never available when it is wanted — is indistinguishable from the SoC drift MAINT-01 exists to correct.
What landed
decide() now takes soc and soc_floor, and discharge_permitted() is the single place the question is answered — public, for the reason looks_frozen() and shaping_suspended() are: the check has to grade the shipped expression, not a copy.
It is a LIMIT, not a trip. §11.2's whole table still outranks the claim. This bounds energy, not last-resort safety, so the action when it binds is to stop asking, never to command.
The action is floor_w = 0.0 — the blind branch's action, for the blind branch's reason. Zero forbids charging (one-directional, can only lower delivered offtake) and forbids nothing else. The lo-limit property survives: swept 1296 combinations of load × elapsed × battery × SoC × floor and 0 negative floors; hi stays +inf on every claim; and the replay adds 0 negative floors on real captured data.
Graded only where it is not degenerate. The SoC is consulted afterwanted > 0 is established. A gate one line earlier reports "the peak will be exceeded" on every quiet afternoon — mutation 10 counts three such quarters.
A failed read is not an observation.soc=None is floor-limited, not permitted. A full battery is exactly the value that would disarm this floor, and NOTES.md §4.4b records this project shipping | float(100) once already. Both new arguments are required keyword-only with no default — TEST-14 as a rule rather than a story.
What it does not bound: the self-consumption loop can still take the pack past this floor. SAFETY-18's finding stands; a lo limit is structurally incapable of closing it. That is stated in the module, in DOCS.md, and as the honestly-uncaught 13th mutation.
AC1 — stops at a configured floor, and reports it
PeakDecision gains floor_limited, soc, soc_floor, soc_floor_reason. The reason line is the shipped one:
PEAK GUARD FLOOR-LIMITED: the quarter projects 4400 W against a 3500 W ceiling
(peak_cap_w) and +3100 W of discharge would hold it, but state of charge is at or
below the peak guard's SoC floor (20 % vs 20 %) - commanding no forced discharge,
so THIS QUARTER WILL GO OVER THE CEILING. Charging stays blocked (floor 0 W)
It still carries wanted_w, so the under-shave is a number, not a silence. The arbiter reason is SoC floor — a third distinct label, because lo = 0 W now reaches the resolver from three different states and an operator reads one line.
AC2 — when the floor binds, the operator is told the peak will be exceeded
One notification per episode, notification_id: goodwe_peak_soc_floor, plus a not-ok status row and an action-log line. Deliberately separate from goodwe_peak_saturated, because the two ask for opposite things: saturation says shed load, the inverter is already flat out; this says the pack is at its floor and this quarter is not being shaved at all. They are mutually exclusive by construction (the branch returns before the achievable_w clamp that sets saturated) and 0 of the 1296 swept combinations produce both.
AC3 — the startup refusal
validate.pyrule 8: peak_soc_floor <= bms_soc_floor is refused, CFG-01 case (a) — the operator states a bound, the BMS enforces a different one, and the status page would report an energy backstop incapable of binding. <=, not <: at equality the EMS floor buys zero margin, so it can never prevent the pack reaching the BMS limit, which is the entire thing the option exists to do. (Rule 6 permits equality because equality there leaves one working point; here it leaves none.) The message names both numbers and the shipped default. Not scoped to any enable flag, for rule 6's reason.
AC4 — verified by replaying a quarter that would exhaust the pack
replay() gains a stated pack model: SoC falls by the delivered discharge over the real telegram timestamps, and the BMS is modelled as a hard cut — without that the "before" world is unphysical. The pack is sized from the day's own measured appetite, re-derived every run, so exhaustion is true by construction of the measurement rather than by a number typed into the file: 2928 Wh usable, starting 30 %, i.e. 586 Wh above the BMS against 878 Wh of appetite.
BEFORE (BMS the only backstop)
AFTER (peak_soc_floor 20 %)
ends at
9.6 %
20.0 %
low-water
9.6 %
20.0 %
delivered
597 Wh
293 Wh
guard cycles at the BMS limit
1227
0
Energy withheld 304.2 Wh against 292.8 Wh between the two floors — within one cycle of overshoot at the rail in each run, so the floor is doing the arithmetic it claims and is not merely correlated with doing less.
The honest half is in the checks, not only here: the worst quarter goes 4338 W → 5030 W. A floor-limited guard shaves less, on purpose, and the operator is told so.
One real Controller.cycle() on a pack one point under the floor confirms the wiring, the write, the single notification, the status row and the log line.
CFG-03
Two options — peak_soc_floor (20), bms_soc_floor (10) — because the floor is a number only the site knows and refusing one below the pack's cut-out means knowing that cut-out too. Defaults are mirrored once, in peakguard.PEAK_OPTION_DEFAULTS, TARIFF-02's PRICE_OPTION_DEFAULTS pattern: imported by validate.py and main.py instead of literals, and pinned to config.yaml by reading the file (options block and schema block). CFG-03 still tracks three places. peakguard.py still makes 0.get() calls — both values arrive as arguments.
20 is derived, not picked: 13.8 kWh usable × 10 pp = 1.57× the guard's worst measured day.
⚠️maintenance_soc_floor is not reused and not renamed. It is the monthly recalibration's drain target; this is a spend limit. The shipped values differ (11 vs 20), which is the argument. The rename is recorded as a decision in peakguard.py and DOCS.md — keep it, revisit if a third floor appears (a loop-side guard would be the third), when the migration would buy enough to be worth its cost.
Known holes, not inherited
TEST-14 — ported as a scan, not a case: inspect.signature(decide)'s required keyword-only set and dataclasses.fields(PeakDecision)'s no-default set are both pinned, so any future field or argument moving between required and defaulted goes red.
TEST-13 — nothing new reads the peak store; the ceiling gate is untouched.
The .decide( grader — no second call site was added. The existing first-.decide( grader is extended to also read soc= and soc_floor= off the parsed call, plus a new check that main.py has exactly one.decide( so grading the first grades the shipped one. That syntax grader is what caught the one survivor below.
Checks — red and green
test_peakguard.py grows two parts (8: the floor; 9: the exhausting replay + the refusal + the mirror) and six mutations. All 13 suites pass locally; test_peakguard.py is 6 s.
Eleven one-line mutations of the shipped source were executed against the checks:
mutation
the SoC gate deleted from decide()
RED (20)
an unreadable SoC read as a full battery
RED (4)
the boundary written < instead of <=
RED (10)
floor-limited reported but wanted filed anyway
RED (10)
a negative floor filed when limited
RED (6)
the gate moved above the wanted > 0 test
RED (5)
rule 8 written < instead of <=
RED (2)
rule 8 deleted
RED (2)
main.py hands decide() a defaulted SoC
RED (1)
the operator notification never sent
RED (2)
main.py reads maintenance_soc_floor instead
RED (6)
Survivors: none — but only after a fix. The last row survived every behavioural check on the first pass, because the two floors' defaults were close enough that no fixture separated them: exactly the coupling this card forbids, hiding from a value-based check. It is now caught twice — by the parsed call syntax, and by a wiring fixture that sets maintenance_soc_floor to 5 so the pack at 19 % is under one floor and over the other.
Not done
Nothing energy-moving, no add-on restart, no flash.
The grid-following loop still has no SoC guard (SAFETY-18's open finding). Out of scope here and said out loud rather than implied.
No version bump — this rides the same Unreleased block as SAFETY-07.
**Card:** SAFETY-24 — *The peak guard's forced discharge has no SoC floor but the BMS*. Base `release/1.0` @ `e7c95bf`.
## The gap
SAFETY-07 merged an hour before this card was written and can **command a discharge** to hold a billed quarter under `peak_cap_w`. Nothing below it consulted the state of charge: `soc_floor` belongs to the maintenance cycle, and `freeze.py` already recorded that for the grid-following loop *"the BMS is the sole energy backstop."* On the 2026-08-20 replay the guard asks for **878 Wh in a day** to hold the peak at 3485 W. That is the guard working as designed, and unbounded from below by anything in the EMS.
**Stated fairly:** relying on the BMS is not absurd — it is a real floor and it works. But it is *the pack's* protection, not *the operator's*. A month of aggressive quarters could leave the battery repeatedly parked at that cut-out with no EMS-side record of why, and the first symptom — a pack that is never available when it is wanted — is indistinguishable from the SoC drift MAINT-01 exists to correct.
## What landed
`decide()` now takes `soc` and `soc_floor`, and `discharge_permitted()` is the single place the question is answered — public, for the reason `looks_frozen()` and `shaping_suspended()` are: the check has to grade the shipped expression, not a copy.
* **It is a LIMIT, not a trip.** §11.2's whole table still outranks the claim. This bounds *energy*, not last-resort safety, so the action when it binds is to stop **asking**, never to command.
* **The action is `floor_w = 0.0`** — the blind branch's action, for the blind branch's reason. Zero forbids charging (one-directional, can only lower delivered offtake) and forbids nothing else. **The `lo`-limit property survives:** swept 1296 combinations of load × elapsed × battery × SoC × floor and **0 negative floors**; `hi` stays `+inf` on every claim; and the replay adds 0 negative floors on real captured data.
* **Graded only where it is not degenerate.** The SoC is consulted *after* `wanted > 0` is established. A gate one line earlier reports "the peak will be exceeded" on every quiet afternoon — mutation 10 counts three such quarters.
* **A failed read is not an observation.** `soc=None` is floor-limited, not permitted. A full battery is exactly the value that would disarm this floor, and NOTES.md §4.4b records this project shipping `| float(100)` once already. Both new arguments are **required keyword-only with no default** — TEST-14 as a rule rather than a story.
* **What it does *not* bound:** the self-consumption loop can still take the pack past this floor. SAFETY-18's finding stands; a `lo` limit is structurally incapable of closing it. That is stated in the module, in DOCS.md, and as the honestly-uncaught 13th mutation.
## AC1 — stops at a configured floor, and reports it
`PeakDecision` gains `floor_limited`, `soc`, `soc_floor`, `soc_floor_reason`. The reason line is the shipped one:
```
PEAK GUARD FLOOR-LIMITED: the quarter projects 4400 W against a 3500 W ceiling
(peak_cap_w) and +3100 W of discharge would hold it, but state of charge is at or
below the peak guard's SoC floor (20 % vs 20 %) - commanding no forced discharge,
so THIS QUARTER WILL GO OVER THE CEILING. Charging stays blocked (floor 0 W)
```
It still carries `wanted_w`, so the under-shave is a **number**, not a silence. The arbiter reason is `SoC floor` — a third distinct label, because `lo = 0 W` now reaches the resolver from three different states and an operator reads one line.
## AC2 — when the floor binds, the operator is told the peak will be exceeded
One notification per episode, `notification_id: goodwe_peak_soc_floor`, plus a not-ok status row and an action-log line. Deliberately **separate from `goodwe_peak_saturated`**, because the two ask for opposite things: saturation says *shed load, the inverter is already flat out*; this says *the pack is at its floor and this quarter is not being shaved at all*. They are mutually exclusive by construction (the branch returns before the `achievable_w` clamp that sets `saturated`) and 0 of the 1296 swept combinations produce both.
## AC3 — the startup refusal
`validate.py` **rule 8**: `peak_soc_floor <= bms_soc_floor` is refused, CFG-01 case (a) — the operator states a bound, the BMS enforces a different one, and the status page would report an energy backstop incapable of binding. **`<=`, not `<`:** at equality the EMS floor buys zero margin, so it can never prevent the pack reaching the BMS limit, which is the entire thing the option exists to do. (Rule 6 permits equality because equality *there* leaves one working point; here it leaves none.) The message names both numbers and the shipped default. Not scoped to any enable flag, for rule 6's reason.
## AC4 — verified by replaying a quarter that would exhaust the pack
`replay()` gains a **stated** pack model: SoC falls by the delivered discharge over the real telegram timestamps, and the **BMS is modelled as a hard cut** — without that the "before" world is unphysical. The pack is sized from the day's own measured appetite, re-derived every run, so exhaustion is true by construction of the measurement rather than by a number typed into the file: 2928 Wh usable, starting 30 %, i.e. 586 Wh above the BMS against 878 Wh of appetite.
| | BEFORE (BMS the only backstop) | AFTER (`peak_soc_floor` 20 %) |
|---|---|---|
| ends at | **9.6 %** | **20.0 %** |
| low-water | 9.6 % | 20.0 % |
| delivered | 597 Wh | 293 Wh |
| guard cycles **at the BMS limit** | **1227** | **0** |
* Energy withheld **304.2 Wh** against **292.8 Wh** between the two floors — within one cycle of overshoot at the rail in each run, so the floor is doing the arithmetic it claims and is not merely correlated with doing less.
* **The honest half is in the checks, not only here:** the worst quarter goes **4338 W → 5030 W**. A floor-limited guard shaves *less*, on purpose, and the operator is told so.
* One real `Controller.cycle()` on a pack one point under the floor confirms the wiring, the write, the single notification, the status row and the log line.
## CFG-03
Two options — `peak_soc_floor` (20), `bms_soc_floor` (10) — because the floor is a number only the site knows and refusing one below the pack's cut-out means knowing that cut-out too. **Defaults are mirrored once**, in `peakguard.PEAK_OPTION_DEFAULTS`, TARIFF-02's `PRICE_OPTION_DEFAULTS` pattern: imported by `validate.py` and `main.py` instead of literals, and **pinned to `config.yaml` by reading the file** (options block *and* schema block). CFG-03 still tracks three places. `peakguard.py` still makes **0** `.get()` calls — both values arrive as arguments.
`20` is derived, not picked: 13.8 kWh usable × 10 pp = 1.57× the guard's worst measured day.
**⚠️ `maintenance_soc_floor` is not reused and not renamed.** It is the monthly recalibration's *drain target*; this is a *spend limit*. The shipped values differ (11 vs 20), which is the argument. The rename is recorded as a **decision** in `peakguard.py` and DOCS.md — keep it, revisit if a third floor appears (a loop-side guard would be the third), when the migration would buy enough to be worth its cost.
## Known holes, not inherited
* **TEST-14** — ported as a **scan**, not a case: `inspect.signature(decide)`'s required keyword-only set and `dataclasses.fields(PeakDecision)`'s no-default set are both pinned, so *any* future field or argument moving between required and defaulted goes red.
* **TEST-13** — nothing new reads the peak store; the ceiling gate is untouched.
* **The `.decide(` grader** — **no second call site was added.** The existing first-`.decide(` grader is *extended* to also read `soc=` and `soc_floor=` off the parsed call, plus a new check that main.py has exactly **one** `.decide(` so grading the first grades the shipped one. That syntax grader is what caught the one survivor below.
## Checks — red and green
`test_peakguard.py` grows two parts (8: the floor; 9: the exhausting replay + the refusal + the mirror) and six mutations. All 13 suites pass locally; `test_peakguard.py` is 6 s.
Eleven one-line mutations of the **shipped** source were executed against the checks:
| mutation | |
|---|---|
| the SoC gate deleted from `decide()` | RED (20) |
| an unreadable SoC read as a full battery | RED (4) |
| the boundary written `<` instead of `<=` | RED (10) |
| floor-limited reported but `wanted` filed anyway | RED (10) |
| a **negative** floor filed when limited | RED (6) |
| the gate moved above the `wanted > 0` test | RED (5) |
| rule 8 written `<` instead of `<=` | RED (2) |
| rule 8 deleted | RED (2) |
| main.py hands `decide()` a defaulted SoC | RED (1) |
| the operator notification never sent | RED (2) |
| main.py reads `maintenance_soc_floor` instead | RED (6) |
**Survivors: none** — but only after a fix. The last row **survived every behavioural check** on the first pass, because the two floors' defaults were close enough that no fixture separated them: exactly the coupling this card forbids, hiding from a value-based check. It is now caught twice — by the parsed call syntax, and by a wiring fixture that sets `maintenance_soc_floor` to 5 so the pack at 19 % is under one floor and over the other.
## Not done
* Nothing energy-moving, no add-on restart, no flash.
* The grid-following loop still has no SoC guard (SAFETY-18's open finding). Out of scope here and said out loud rather than implied.
* No version bump — this rides the same Unreleased block as SAFETY-07.
SAFETY-07 can command a discharge to hold a billed quarter down, and
nothing below it consulted the state of charge. On the 2026-08-20 replay
it asks for 878 Wh in a day. Relying on the BMS is not absurd - it is a
real floor and it works - but it is the pack's protection rather than the
operator's, and a month of aggressive quarters leaves the battery
repeatedly at that cut-out with no EMS-side record of why. The first
symptom is a pack never available when wanted, which is indistinguishable
from the SoC drift MAINT-01 exists to correct.
decide() now takes soc and soc_floor, both required and neither
defaulted, and discharge_permitted() is the one place the question is
answered. Above the floor nothing changes. At or below it the guard files
a 0 W floor instead of a sized one - one-directional, so charging stays
blocked and no discharge is capped - and reports floor_limited with the
reason it bound. It is not a trip: every stop in 11.2's table still
outranks the claim, and the floor is never negative, so 9.1's asymmetry
survives as a property of the object (0 negative floors over 1296 swept
combinations, SoC included).
The SoC is graded only after `wanted > 0` is established, so it is never
evaluated where it governs nothing. An unreadable SoC is floor-limited,
not a full battery - a full battery is exactly the value that would
disarm the floor, and this project shipped that defaulting once already.
Failing to shave is not silent: one notification per episode
(goodwe_peak_soc_floor), distinct from the saturation alarm because the
two ask for opposite operator actions, plus its own status row and an
action-log line.
Verified by replay. The 2026-08-20 capture is driven against a modelled
pack sized from the day's own measured appetite, so it genuinely
exhausts: without the floor it reaches the BMS cut-out and holds there
for 1227 guard cycles; with it the pack stops at 20.0 % and never touches
the BMS. The energy withheld equals the energy between the two floors to
within one cycle at the rail. The honest half is reported too - the worst
quarter goes 4338 W -> 5030 W, because a floor-limited guard shaves less
on purpose.
Two new options, mirrored once in peakguard.PEAK_OPTION_DEFAULTS
(TARIFF-02's pattern), imported by validate.py and main.py rather than
typed as literals and pinned to config.yaml by a check that reads the
file - so CFG-03 still tracks three places. validate.py rule 8 refuses
peak_soc_floor <= bms_soc_floor at startup, in CFG-01/MAINT-04's manner.
maintenance_soc_floor is deliberately not reused and not renamed: it is a
drain target, not a spend limit, and the rename is recorded as a decision
to revisit if a third floor appears.
Eleven mutations of the shipped source were run against the checks; all
eleven go red and there are no survivors.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
FCST-03 landed while this was in review, so both branches had prepended an
Unreleased stanza. Only CHANGELOG.md conflicted, exactly as predicted;
app/, config.yaml, validate.py and DOCS.md all auto-merged, and this card
touches neither price_forecast.py nor static_tariff.py.
Both stanzas kept verbatim, SAFETY-24's above FCST-03's - no text from
either author edited. Full suite green on the merged tree: 14 files.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
Reviewed. The trade-off is honest, the lo property survives a much larger sweep, and one surviving mutation turns out to have no reachable consequence. Merging once CI is green on the rebased head.
Own detached worktree, own replay, own harness. Mutations run under python -B with a recursive __pycache__ purge and a printed size delta, per TEST-15 — and that discipline caught a defect in my own runner before it could produce a fake survivor, which I mention below because it is exactly the failure mode TEST-15 is about.
Full suite green: 14 files on the merged tree.
1. The trade-off — reproduced exactly, and the framing is honest
My own replay loop, written from the model stated in the PR (pack sized from the day's own measured appetite; SoC falls by DELIVERED discharge over the real telegram timestamps; BMS a hard cut; nothing recharges), driving the shipped P1Ingest / PeakTracker / decide():
the guard's measured appetite on 2026-08-20 : 878 Wh
modelled pack: 2928 Wh usable, start 30 %, BMS cut 10 %, EMS floor 20 %
before (BMS sole backstop) after
worst billed quarter 4338 W 5030 W
end SoC 9.6 % 20.0 %
low-water SoC 9.6 % 20.0 %
cycles at the BMS limit 1227 0
energy withheld from the meter 597.2 Wh 293.1 Wh
negative floors 0 0
difference in energy withheld : 304.2 Wh
gap between the two floors : 292.8 Wh
one cycle at the rail : 20.8 Wh
Every figure lands. The 11.4 Wh between 304.2 and 292.8 is half of one cycle at the rail — so "the energy this card withholds IS the energy between the two floors" is arithmetic, not correlation.
Ruling on 4338 → 5030 W: the trade-off is correct, and making it a check rather than a footnote is the right call. Two things make me comfortable:
The "before" world does not merely shave harder — it shaves harder by running the pack to 9.6 % and then sitting at the BMS cut-out for 1227 guard cycles. That is not a pack that spent its energy usefully; it is a pack pinned at its own protection for most of the day, unavailable for anything else, with no EMS-side record of why. Which is precisely the finding this card exists to answer, and precisely what I said must not reach a client install.
The pack is deliberately sized so the floor must bind — one third of the day's own appetite above the floor. On the real 13.8 kWh install, 878 Wh is about 6 % of capacity and this floor will rarely bind at all. So the regression is a property of the stress fixture, not a forecast of the live site.
What the product owner should see, and what I would not let a summary swallow: on a pack small enough for the floor to bind, SAFETY-24 gives back most of SAFETY-07's shaving on the day SAFETY-07 was built for.peak_soc_floor is the knob that trades those, and it is now an option rather than a constant, which is the right place for that decision to live.
2. The lo property survives — over 19,800 combinations, not 1296
I re-derived the guard's central guarantee independently, with SoC now in the input space:
swept 19800 combinations (load x elapsed x battery x SoC x floor)
claims filed : 7260
NEGATIVE floors : 0
claims whose hi is not +inf : 0
decisions BOTH floor-limited AND saturated: 0
arbiter: resolutions LOWERED or contradicted : 0
plus 0 negative floors across both real-data replay runs. And the SoC-denied path is one-directional exactly as claimed:
soc 10 %, floor 20 %, quarter projecting 9000 W -> floor_w=0.0, claim lo=0.0 hi=inf
a loop wanting +4000 W discharge still resolves to +4000 W
a loop wanting -4000 W CHARGE is cut to +0 W
Forbids charging, structurally cannot cap discharge. The new input did not break it.
3. Ordering, the boundary, and the action — all confirmed in source and by mutation
discharge_permitted() is consulted after the wanted <= 0.0 return, so SoC is never graded on a cycle that was never going to discharge; the denied action is floor_w = 0.0, not wanted and not negative. Both are load-bearing:
D6 SoC graded BEFORE the wanted check .......... RED (3)
D5 denied action is `wanted` instead of 0.0 .... RED (10)
Rule 8's <= is right. At equality the EMS floor and the BMS cut act at the same percent, so the EMS bound can never be the thing that stops the discharge — the add-on would carry a backstop incapable of binding while its status page reports one. That is rule 5's "a disarmed guard that advertises itself as armed", and the module says so. D2 (< in the predicate) and D8 (< in rule 8) both go red.
One observation, not a defect: D8 is caught by test_peakguard.py and not by test_validate.py, though rule 8 lives in validate.py. The coverage sits one file away from the code.
4. The mutation that survived first time is now caught twice — confirmed
D1, main.py reading maintenance_soc_floor, goes red on 6 assertions: the parsed-call grader and the wiring fixture that separates the two defaults behaviourally. Both catches work, and extending the existing grader rather than adding a second call site was the right shape.
And the decoy hole I flagged on SAFETY-07 is closed. I re-ran that attack — add a second .decide( earlier in main.py so next(...) grades the wrong call, then change the real one:
D9 decoy .decide() + real call changed .......... RED (7)
first failure: "main.py still has exactly ONE `.decide(` call site"
The exactly-one check fires before the name check can be fooled. That is my finding from last round, fixed properly.
5. One survivor — and it has no reachable consequence
D7 `self.soc = 100.0 if self.soc is None else self.soc`, inserted
immediately before the decide() call .......... GREEN
This is the value-poisoning half of the weakness you already name in the comment ("reads a NAME rather than a value"): the syntax grader still sees soc=self.soc, and no fixture separates them. It is the exact defaulting discharge_permitted()'s docstring says must never exist, and which NOTES.md 4.4b records this project shipping once.
But I could not make it matter, and the reason exonerates the design. I instrumented peakguard.decide and drove the real Controller:
With a missing SoC, inputs_ok is False and cycle() returns before decide() is ever called. The defaulting line can only matter on a cycle that does not happen. So the property is enforced one layer up, by inputs_ok, and it is enforced more strongly than the guard could enforce it itself.
What that does expose is a wording defect in one assertion, and it is worth a line because it is the fourth instance this week of a check whose sentence is wider than its verification:
"and a Controller whose SoC entity does not answer hands decide() None"
No Controller is driven there — the line calls decide(soc=None, ...) directly, which part 3 already grades. Your own comment two lines above concedes the reason (inputs_ok stops the loop at 0 W in that state), so the comment and the assertion's wording disagree. The true property is stronger than the one claimed and worth asserting instead: a Controller whose SoC entity does not answer never reaches decide() at all. One spy on peakguard.decide and an is False proves it, and it would kill D7 as a side effect.
Not blocking: the shipped behaviour is right, the harm is unreachable, and the fix strengthens a claim rather than repairing a defect.
6. A note on my own harness, since TEST-15 is the point
My first mutation run reported size deltas of +1523 and +724 bytes for one-word edits. That was my runner rewriting LF to CRLF through io.open(..., "w") on Windows — semantically harmless to Python, but it destroys the size delta as a did-it-apply signal, which is the one thing TEST-15 asks it for. Normalised and re-ran; D1 then reported the correct +7 bytes (maintenance_soc_floor minus peak_soc_floor) and the same 6 failures. Recording it because a reviewer's harness is subject to the same rule as yours.
7. The honest limits
The grid-following loop can still take the pack past this floor — sufficient as handled, and I would not hold the card for it. A lo limit is structurally incapable of capping a discharge, so this guard cannot bound the loop's; that is the same asymmetry that makes it safe, and closing it needs a different mechanism, not a bigger version of this one. It is stated in the module, in DOCS, in the PR body and as an honestly-uncaught 13th mutation, which is the right way to carry a known gap: named where a reader meets it, and visible in the check output rather than only in a review thread. It should become a ticket now that it has been written down three times.
maintenance_soc_floor kept and not renamed — recorded, not deferred: the decision, its reason (a drain target is not a spend limit), and the trigger to revisit (a third floor, which a loop-side guard would be) are all in the module and in DOCS. That is what the card asked for.
CFG-03: confirmed. peakguard.py makes 0 executable .get() calls — the three textual matches are all in comments, and the check greps the parsed AST rather than the text, so it is not fooled by its own prose. PEAK_OPTION_DEFAULTS is imported by both validate.py and main.py rather than typed, and pinned to config.yaml's options: and schema: blocks.
8. Housekeeping
CI on the original head e709554 was a real execution, not a cancelled run reporting nothing: run 1149, Successful in 3m29s, and the run record shows status=success with a genuine start time. I checked the run, not just the status line.
OPT-04: no overlap. SAFETY-24's own commit touches CHANGELOG.md, DOCS.md, main.py, peakguard.py, validate.py, config.yaml and test_peakguard.py — notstatic_tariff.py and notprice_forecast.py. (A plain diff release/1.0..SAFETY-24 lists those two, but that is release/1.0 having moved ahead with FCST-03, not this card touching them.)
The rebase was mine. FCST-03 had landed, so I merged release/1.0 into the branch as a1376e4 — CHANGELOG only, both stanzas verbatim with SAFETY-24's above FCST-03's, no text from either author edited. Everything else auto-merged. Full suite green on the merged tree (14 files) before pushing. I will merge on the new head's green check, not the old one's.
I ran nothing against the rig, the live add-on, the inverter or firmware.
## Reviewed. The trade-off is honest, the `lo` property survives a much larger sweep, and one surviving mutation turns out to have no reachable consequence. Merging once CI is green on the rebased head.
Own detached worktree, own replay, own harness. Mutations run under **`python -B` with a recursive `__pycache__` purge and a printed size delta**, per TEST-15 — and that discipline caught a defect in *my own* runner before it could produce a fake survivor, which I mention below because it is exactly the failure mode TEST-15 is about.
Full suite green: **14 files** on the merged tree.
---
## 1. The trade-off — reproduced exactly, and the framing is honest
My own replay loop, written from the model stated in the PR (pack sized from the day's own measured appetite; SoC falls by DELIVERED discharge over the real telegram timestamps; BMS a hard cut; nothing recharges), driving the shipped `P1Ingest` / `PeakTracker` / `decide()`:
```
the guard's measured appetite on 2026-08-20 : 878 Wh
modelled pack: 2928 Wh usable, start 30 %, BMS cut 10 %, EMS floor 20 %
before (BMS sole backstop) after
worst billed quarter 4338 W 5030 W
end SoC 9.6 % 20.0 %
low-water SoC 9.6 % 20.0 %
cycles at the BMS limit 1227 0
energy withheld from the meter 597.2 Wh 293.1 Wh
negative floors 0 0
difference in energy withheld : 304.2 Wh
gap between the two floors : 292.8 Wh
one cycle at the rail : 20.8 Wh
```
**Every figure lands.** The 11.4 Wh between 304.2 and 292.8 is **half of one cycle at the rail** — so "the energy this card withholds IS the energy between the two floors" is arithmetic, not correlation.
**Ruling on 4338 → 5030 W: the trade-off is correct, and making it a check rather than a footnote is the right call.** Two things make me comfortable:
- The "before" world does not merely shave harder — it shaves harder by running the pack to 9.6 % and then **sitting at the BMS cut-out for 1227 guard cycles**. That is not a pack that spent its energy usefully; it is a pack pinned at its own protection for most of the day, unavailable for anything else, with no EMS-side record of why. Which is precisely the finding this card exists to answer, and precisely what I said must not reach a client install.
- **The pack is deliberately sized so the floor must bind** — one third of the day's own appetite above the floor. On the real 13.8 kWh install, 878 Wh is about 6 % of capacity and this floor will rarely bind at all. So the regression is a property of the *stress fixture*, not a forecast of the live site.
What the product owner should see, and what I would not let a summary swallow: **on a pack small enough for the floor to bind, SAFETY-24 gives back most of SAFETY-07's shaving on the day SAFETY-07 was built for.** `peak_soc_floor` is the knob that trades those, and it is now an option rather than a constant, which is the right place for that decision to live.
## 2. The `lo` property survives — over 19,800 combinations, not 1296
I re-derived the guard's central guarantee independently, with SoC now in the input space:
```
swept 19800 combinations (load x elapsed x battery x SoC x floor)
claims filed : 7260
NEGATIVE floors : 0
claims whose hi is not +inf : 0
decisions BOTH floor-limited AND saturated: 0
arbiter: resolutions LOWERED or contradicted : 0
```
plus 0 negative floors across both real-data replay runs. And the SoC-denied path is one-directional exactly as claimed:
```
soc 10 %, floor 20 %, quarter projecting 9000 W -> floor_w=0.0, claim lo=0.0 hi=inf
a loop wanting +4000 W discharge still resolves to +4000 W
a loop wanting -4000 W CHARGE is cut to +0 W
```
Forbids charging, structurally cannot cap discharge. The new input did not break it.
## 3. Ordering, the boundary, and the action — all confirmed in source and by mutation
`discharge_permitted()` is consulted **after** the `wanted <= 0.0` return, so SoC is never graded on a cycle that was never going to discharge; the denied action is `floor_w = 0.0`, not `wanted` and not negative. Both are load-bearing:
```
D6 SoC graded BEFORE the wanted check .......... RED (3)
D5 denied action is `wanted` instead of 0.0 .... RED (10)
```
**Rule 8's `<=` is right.** At equality the EMS floor and the BMS cut act at the same percent, so the EMS bound can never be the thing that stops the discharge — the add-on would carry a backstop incapable of binding while its status page reports one. That is rule 5's "a disarmed guard that advertises itself as armed", and the module says so. `D2` (`<` in the predicate) and `D8` (`<` in rule 8) both go red.
One observation, not a defect: **`D8` is caught by `test_peakguard.py` and *not* by `test_validate.py`**, though rule 8 lives in `validate.py`. The coverage sits one file away from the code.
## 4. The mutation that survived first time is now caught twice — confirmed
`D1`, main.py reading `maintenance_soc_floor`, goes red on **6** assertions: the parsed-call grader *and* the wiring fixture that separates the two defaults behaviourally. Both catches work, and extending the existing grader rather than adding a second call site was the right shape.
**And the decoy hole I flagged on SAFETY-07 is closed.** I re-ran that attack — add a second `.decide(` earlier in `main.py` so `next(...)` grades the wrong call, then change the real one:
```
D9 decoy .decide() + real call changed .......... RED (7)
first failure: "main.py still has exactly ONE `.decide(` call site"
```
The exactly-one check fires before the name check can be fooled. That is my finding from last round, fixed properly.
## 5. One survivor — and it has no reachable consequence
```
D7 `self.soc = 100.0 if self.soc is None else self.soc`, inserted
immediately before the decide() call .......... GREEN
```
This is the value-poisoning half of the weakness you already name in the comment ("reads a NAME rather than a value"): the syntax grader still sees `soc=self.soc`, and no fixture separates them. It is the *exact* defaulting `discharge_permitted()`'s docstring says must never exist, and which NOTES.md 4.4b records this project shipping once.
**But I could not make it matter, and the reason exonerates the design.** I instrumented `peakguard.decide` and drove the real `Controller`:
```
soc sensor answers 50.0 : inputs_ok=True decide() called=True soc handed over=50.0
soc sensor answers None : inputs_ok=False decide() called=FALSE target=+0 W guard=None
```
With a missing SoC, `inputs_ok` is False and `cycle()` returns **before `decide()` is ever called**. The defaulting line can only matter on a cycle that does not happen. So the property is enforced one layer up, by `inputs_ok`, and it is enforced more strongly than the guard could enforce it itself.
**What that does expose is a wording defect in one assertion**, and it is worth a line because it is the fourth instance this week of a check whose sentence is wider than its verification:
> *"and a **Controller** whose SoC entity does not answer hands `decide()` None"*
No Controller is driven there — the line calls `decide(soc=None, ...)` directly, which part 3 already grades. Your own comment two lines above concedes the reason (`inputs_ok` stops the loop at 0 W in that state), so the comment and the assertion's wording disagree. The true property is **stronger** than the one claimed and worth asserting instead: *a Controller whose SoC entity does not answer never reaches `decide()` at all*. One `spy` on `peakguard.decide` and an `is False` proves it, and it would kill D7 as a side effect.
Not blocking: the shipped behaviour is right, the harm is unreachable, and the fix strengthens a claim rather than repairing a defect.
## 6. A note on my own harness, since TEST-15 is the point
My first mutation run reported size deltas of +1523 and +724 bytes for one-word edits. That was **my runner rewriting LF to CRLF** through `io.open(..., "w")` on Windows — semantically harmless to Python, but it destroys the size delta as a did-it-apply signal, which is the one thing TEST-15 asks it for. Normalised and re-ran; `D1` then reported the correct **+7 bytes** (`maintenance_soc_floor` minus `peak_soc_floor`) and the same 6 failures. Recording it because a reviewer's harness is subject to the same rule as yours.
## 7. The honest limits
**The grid-following loop can still take the pack past this floor — sufficient as handled, and I would not hold the card for it.** A `lo` limit is structurally incapable of capping a discharge, so this guard cannot bound the loop's; that is the same asymmetry that makes it safe, and closing it needs a different mechanism, not a bigger version of this one. It is stated in the module, in DOCS, in the PR body **and as an honestly-uncaught 13th mutation**, which is the right way to carry a known gap: named where a reader meets it, and visible in the check output rather than only in a review thread. It should become a ticket now that it has been written down three times.
**`maintenance_soc_floor` kept and not renamed** — recorded, not deferred: the decision, its reason (a drain target is not a spend limit), and the trigger to revisit (a third floor, which a loop-side guard would be) are all in the module and in DOCS. That is what the card asked for.
**CFG-03**: confirmed. `peakguard.py` makes **0** executable `.get()` calls — the three textual matches are all in comments, and the check greps the parsed AST rather than the text, so it is not fooled by its own prose. `PEAK_OPTION_DEFAULTS` is imported by both `validate.py` and `main.py` rather than typed, and pinned to `config.yaml`'s `options:` and `schema:` blocks.
## 8. Housekeeping
- **CI on the original head `e709554` was a real execution**, not a cancelled run reporting nothing: run 1149, `Successful in 3m29s`, and the run record shows `status=success` with a genuine start time. I checked the run, not just the status line.
- **OPT-04: no overlap.** SAFETY-24's own commit touches `CHANGELOG.md`, `DOCS.md`, `main.py`, `peakguard.py`, `validate.py`, `config.yaml` and `test_peakguard.py` — **not** `static_tariff.py` and **not** `price_forecast.py`. (A plain `diff release/1.0..SAFETY-24` lists those two, but that is release/1.0 having moved ahead with FCST-03, not this card touching them.)
- **The rebase was mine.** FCST-03 had landed, so I merged `release/1.0` into the branch as `a1376e4` — **CHANGELOG only**, both stanzas verbatim with SAFETY-24's above FCST-03's, no text from either author edited. Everything else auto-merged. Full suite green on the merged tree (14 files) before pushing. I will merge on the new head's green check, not the old one's.
- I ran nothing against the rig, the live add-on, the inverter or firmware.
app/validate.py is a real code conflict rather than a textual one: both cards add refusals to the startup gate, and both blocks want keeping. Nothing else of yours collides — peakguard.py and test_peakguard.py are untouched by OPT-04, and the three files that overlap merge on their own.
OPT-04 went first only because it was reviewed and mergeable at the time; there was no judgement about relative importance in it. Integrate rather than rebase, keep both refusal blocks, and let CI report on the integrated head before merging — a cancelled run posts failure having executed nothing, so do not re-trigger casually if you see one.
One thing worth knowing while you resolve: OPT-04 added arbitrage_enabled, cycle_cost_threshold_eur, arbitrage_soc_floor and arbitrage_soc_ceiling to PRICE_OPTION_DEFAULTS and config.yaml, and validate.py now refuses some arbitrage configurations. If SAFETY-24's new option interacts with any of those, that is the place it would show up.
**Heads-up from the OPT-04 review: your conflict is not only CHANGELOG.**
I merged OPT-04 (#22) as `8e589fe` while this was still open, so `release/1.0` has moved under you. `git merge-tree` between the two branches gives:
```
CONFLICT (content): goodwe_controller/CHANGELOG.md
CONFLICT (content): goodwe_controller/app/validate.py
DOCS.md, app/main.py, config.yaml auto-merge cleanly
```
`app/validate.py` is a real code conflict rather than a textual one: both cards add refusals to the startup gate, and **both blocks want keeping**. Nothing else of yours collides — `peakguard.py` and `test_peakguard.py` are untouched by OPT-04, and the three files that overlap merge on their own.
OPT-04 went first only because it was reviewed and mergeable at the time; there was no judgement about relative importance in it. Integrate rather than rebase, keep both refusal blocks, and let CI report on the integrated head before merging — a cancelled run posts `failure` having executed nothing, so do not re-trigger casually if you see one.
One thing worth knowing while you resolve: OPT-04 added `arbitrage_enabled`, `cycle_cost_threshold_eur`, `arbitrage_soc_floor` and `arbitrage_soc_ceiling` to `PRICE_OPTION_DEFAULTS` and `config.yaml`, and `validate.py` now refuses some arbitrage configurations. If SAFETY-24's new option interacts with any of those, that is the place it would show up.
OPT-04 landed after the previous rebase. Conflicts were CHANGELOG.md and one
IMPORT LINE in validate.py; both refusal blocks and both option sets survive
untouched, and DOCS.md, main.py and config.yaml auto-merged.
⚠️ AND THE MERGE BROKE A CHECK THAT NEITHER CARD COULD HAVE SEEN ALONE.
test_peakguard.py graded "the first `.decide(` call in main.py" and asserted
there was exactly one. That was true when SAFETY-24 was written; OPT-04 added
`arbitrage.decide(...)`, so the merged tree had two and the assertion failed.
It failed loudly rather than grading the wrong call - the guard did its job -
but it cannot ship red.
Fixed by selecting the call by QUALIFIED NAME (`peakguard.decide`) instead of
by position, and requiring exactly one of THAT. Other modules may now call
their own decide(); this grader can no longer be moved by one of them. Verified
still discriminating: a decoy `peakguard.decide` added to main.py turns it red
on 7 assertions, and the `maintenance_soc_floor` swap still turns it red on 6.
Also folded in the review's wording finding. An assertion read "a CONTROLLER
whose SoC entity does not answer hands decide() None" while driving no
Controller. The wiring property is stronger than the one claimed, and is now
asserted on the SERVICE CALL: a Controller with no SoC never reaches the guard
at all, because `inputs_ok` is False and cycle() stops at the safety claim
first - so a `float(soc or 100)` defaulting line above the call site cannot
disarm the floor. The original decide()-level check is kept, reworded to say
what it actually grades.
Full suite green on the merged tree: 15 files.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
Second rebase done, and the merge broke a check that neither card could have seen alone. Pushed as 9234016; merging on that head's own green check.
Everything from my review at #issuecomment-965 stands. Three things happened in this rebase that are worth reading rather than skipping.
1. The conflicts were what I predicted, plus one import line
CHANGELOG.md and one import line in validate.py — nothing more. Both refusal blocks survive untouched:
validate.py rule 8 : elif soc_floor <= bms_floor: present
validate.py OPT-04 : arbitrage_soc_floor / _ceiling band present
resolution : both imports kept, both stanzas kept, no author text edited
DOCS.md, main.py, config.yaml : auto-merged
2. ⚠️ The merge broke SAFETY-24's own grader, and I fixed it rather than merging red
This is the interaction you told me not to assume away, and it was not in config.yaml:
.decide( call sites in the merged main.py:
line 542 -> peakguard.decide (SAFETY-24's)
line 618 -> arbitrage.decide (OPT-04's)
test_peakguard.py selected "the first .decide( in main.py" and asserted there was exactly one. True when SAFETY-24 was written; false the moment OPT-04 landed. The merged tree failed on:
...and main.py still has exactly ONE .decide( call site
I confirmed this on the clean merge before my own edits, so it is the merge's doing and not my patch's.
Two things worth saying about it. First, it failed loudly — the guard refused to grade an ambiguous call site rather than silently grading the wrong one, which is the behaviour you want from a positional check when its assumption dies. Second, next() still happened to pick the right call, so nothing was mis-graded today; it was one source-order accident away from being.
Fixed the way I flagged on SAFETY-07: select by qualified name, not by position.
and the count assertion now requires exactly one peakguard.decide, explicitly permitting other modules their own. Verified still discriminating, not just green:
D9b a decoy `peakguard.decide` added to main.py ...... RED (7), count check first
D1c main.py reads maintenance_soc_floor .............. RED (6), unchanged
3. The wording defect, folded in
The assertion claiming "a Controller whose SoC entity does not answer hands decide() None" drove no Controller. I kept the original decide()-level check (reworded to say what it grades) and added the stronger property, graded on the service call rather than its arguments:
a Controller with no SoC: inputs_ok=False, decide() called 0 time(s), target +0 W
ok ...never reaches the guard AT ALL - `inputs_ok` is False, cycle() stops at
the safety claim, and decide() is not called
That is a stronger guarantee than handing decide() a None would be, and it is why my D7 survivor is an equivalent mutant: self.soc = 100.0 if self.soc is None inserted above the call site is dead code, because the only state that would trigger it is the state in which the line is never reached. I re-ran it against the patched tree (D7b) and it is still green — correctly, because it cannot change behaviour.
4. The SoC-option interaction, checked rather than assumed
The priority relationship is correct and documented, not accidental.arbitrage.py states it in its own header: P_PEAK 50 > P_ARBITRAGE 30, so peakguard's lo floor CLAMPS an arbitrage charge. SAFETY-07's floor forbidding a charge outranks arbitrage's cheap-hour charge, which is the right way round.
SAFETY-24's floor does not bound arbitrage's discharge, and should not — it bounds the peak guard's forced discharge, which is its stated scope. Arbitrage carries its own floor.
Two follow-ups for the coordinator, neither blocking and neither this card's to fix:
Rule 8's defect has an unguarded twin in OPT-04.arbitrage_soc_floor is int(5,95) and bms_soc_floor is int(0,50), so arbitrage_soc_floor: 5 under a bms_soc_floor: 10 is a startable configuration — an EMS reserve that can never bind, advertised as one. That is exactly what rule 8 refuses for peak_soc_floor. OPT-04 checks its floor against its own ceiling but not against the BMS.
SAFETY-24's own revisit trigger has fired.peakguard.py records: keep maintenance_soc_floor, "revisit if a THIRD floor appears". OPT-04 added the third (fourth counting the BMS) between the card being written and it merging. Also worth noting peak_soc_floor and arbitrage_soc_floor now ship the same default of 20, which is the "two defaults close enough that no fixture separates them" trap D1 was about, one card over.
5. Housekeeping
Full suite green on the merged tree: 15 files, all exit 0, run with python -B under a purged __pycache__.
My harness's byte deltas are real this time — the LF→CRLF rewrite is fixed and normalised before measuring. D1c reports +7 bytes (maintenance_soc_floor minus peak_soc_floor), D9b+97, D7b+59. Each one is the size of the edit, so a mutation that failed to apply cannot masquerade as a survivor.
I will merge on 9234016's own check, not on a1376e4's green one, and I will confirm the run executed rather than reading the status line alone.
I ran nothing against the rig, the live add-on, the inverter or firmware.
## Second rebase done, and the merge broke a check that neither card could have seen alone. Pushed as `9234016`; merging on that head's own green check.
Everything from my review at `#issuecomment-965` stands. Three things happened in this rebase that are worth reading rather than skipping.
### 1. The conflicts were what I predicted, plus one import line
`CHANGELOG.md` and **one import line** in `validate.py` — nothing more. Both refusal blocks survive untouched:
```
validate.py rule 8 : elif soc_floor <= bms_floor: present
validate.py OPT-04 : arbitrage_soc_floor / _ceiling band present
resolution : both imports kept, both stanzas kept, no author text edited
DOCS.md, main.py, config.yaml : auto-merged
```
### 2. ⚠️ The merge broke SAFETY-24's own grader, and I fixed it rather than merging red
This is the interaction you told me not to assume away, and it was not in `config.yaml`:
```
.decide( call sites in the merged main.py:
line 542 -> peakguard.decide (SAFETY-24's)
line 618 -> arbitrage.decide (OPT-04's)
```
`test_peakguard.py` selected **"the first `.decide(` in main.py"** and asserted there was exactly one. True when SAFETY-24 was written; false the moment OPT-04 landed. The merged tree failed on:
> `...and main.py still has exactly ONE `.decide(` call site`
I confirmed this on the clean merge *before* my own edits, so it is the merge's doing and not my patch's.
**Two things worth saying about it.** First, it failed **loudly** — the guard refused to grade an ambiguous call site rather than silently grading the wrong one, which is the behaviour you want from a positional check when its assumption dies. Second, `next()` still happened to pick the right call, so nothing was mis-graded today; it was one source-order accident away from being.
Fixed the way I flagged on SAFETY-07: **select by qualified name, not by position.**
```python
_peak_calls = [... n.func.attr == "decide"
and isinstance(n.func.value, ast.Name)
and n.func.value.id == "peakguard"]
```
and the count assertion now requires exactly one *`peakguard.decide`*, explicitly permitting other modules their own. **Verified still discriminating**, not just green:
```
D9b a decoy `peakguard.decide` added to main.py ...... RED (7), count check first
D1c main.py reads maintenance_soc_floor .............. RED (6), unchanged
```
### 3. The wording defect, folded in
The assertion claiming *"a **Controller** whose SoC entity does not answer hands `decide()` None"* drove no Controller. I kept the original `decide()`-level check (reworded to say what it grades) and added the stronger property, graded on the **service call** rather than its arguments:
```
a Controller with no SoC: inputs_ok=False, decide() called 0 time(s), target +0 W
ok ...never reaches the guard AT ALL - `inputs_ok` is False, cycle() stops at
the safety claim, and decide() is not called
```
That is a stronger guarantee than handing `decide()` a `None` would be, and it is why my `D7` survivor is an **equivalent mutant**: `self.soc = 100.0 if self.soc is None` inserted above the call site is dead code, because the only state that would trigger it is the state in which the line is never reached. I re-ran it against the patched tree (`D7b`) and it is still green — correctly, because it cannot change behaviour.
### 4. The SoC-option interaction, checked rather than assumed
There are now **four** SoC floors in `config.yaml`:
```
maintenance_soc_floor 11 peak_soc_floor 20
bms_soc_floor 10 arbitrage_soc_floor 20 (+ ceiling 95)
```
- **The priority relationship is correct and documented, not accidental.** `arbitrage.py` states it in its own header: `P_PEAK 50 > P_ARBITRAGE 30, so peakguard's lo floor CLAMPS an arbitrage charge`. SAFETY-07's floor forbidding a charge outranks arbitrage's cheap-hour charge, which is the right way round.
- **SAFETY-24's floor does not bound arbitrage's discharge**, and should not — it bounds the peak guard's forced discharge, which is its stated scope. Arbitrage carries its own floor.
Two follow-ups for the coordinator, neither blocking and neither this card's to fix:
- **Rule 8's defect has an unguarded twin in OPT-04.** `arbitrage_soc_floor` is `int(5,95)` and `bms_soc_floor` is `int(0,50)`, so `arbitrage_soc_floor: 5` under a `bms_soc_floor: 10` is a startable configuration — an EMS reserve that can never bind, advertised as one. That is exactly what rule 8 refuses for `peak_soc_floor`. OPT-04 checks its floor against its own ceiling but not against the BMS.
- **SAFETY-24's own revisit trigger has fired.** `peakguard.py` records: keep `maintenance_soc_floor`, *"revisit if a THIRD floor appears"*. OPT-04 added the third (fourth counting the BMS) between the card being written and it merging. Also worth noting `peak_soc_floor` and `arbitrage_soc_floor` now ship the **same default of 20**, which is the "two defaults close enough that no fixture separates them" trap `D1` was about, one card over.
### 5. Housekeeping
- **Full suite green on the merged tree: 15 files**, all exit 0, run with `python -B` under a purged `__pycache__`.
- **My harness's byte deltas are real this time** — the LF→CRLF rewrite is fixed and normalised before measuring. `D1c` reports **+7 bytes** (`maintenance_soc_floor` minus `peak_soc_floor`), `D9b` **+97**, `D7b` **+59**. Each one is the size of the edit, so a mutation that failed to apply cannot masquerade as a survivor.
- I will merge on `9234016`'s **own** check, not on `a1376e4`'s green one, and I will confirm the run executed rather than reading the status line alone.
- I ran nothing against the rig, the live add-on, the inverter or firmware.
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.
Card: SAFETY-24 — The peak guard's forced discharge has no SoC floor but the BMS. Base
release/1.0@e7c95bf.The gap
SAFETY-07 merged an hour before this card was written and can command a discharge to hold a billed quarter under
peak_cap_w. Nothing below it consulted the state of charge:soc_floorbelongs to the maintenance cycle, andfreeze.pyalready recorded that for the grid-following loop "the BMS is the sole energy backstop." On the 2026-08-20 replay the guard asks for 878 Wh in a day to hold the peak at 3485 W. That is the guard working as designed, and unbounded from below by anything in the EMS.Stated fairly: relying on the BMS is not absurd — it is a real floor and it works. But it is the pack's protection, not the operator's. A month of aggressive quarters could leave the battery repeatedly parked at that cut-out with no EMS-side record of why, and the first symptom — a pack that is never available when it is wanted — is indistinguishable from the SoC drift MAINT-01 exists to correct.
What landed
decide()now takessocandsoc_floor, anddischarge_permitted()is the single place the question is answered — public, for the reasonlooks_frozen()andshaping_suspended()are: the check has to grade the shipped expression, not a copy.floor_w = 0.0— the blind branch's action, for the blind branch's reason. Zero forbids charging (one-directional, can only lower delivered offtake) and forbids nothing else. Thelo-limit property survives: swept 1296 combinations of load × elapsed × battery × SoC × floor and 0 negative floors;histays+infon every claim; and the replay adds 0 negative floors on real captured data.wanted > 0is established. A gate one line earlier reports "the peak will be exceeded" on every quiet afternoon — mutation 10 counts three such quarters.soc=Noneis floor-limited, not permitted. A full battery is exactly the value that would disarm this floor, and NOTES.md §4.4b records this project shipping| float(100)once already. Both new arguments are required keyword-only with no default — TEST-14 as a rule rather than a story.lolimit is structurally incapable of closing it. That is stated in the module, in DOCS.md, and as the honestly-uncaught 13th mutation.AC1 — stops at a configured floor, and reports it
PeakDecisiongainsfloor_limited,soc,soc_floor,soc_floor_reason. The reason line is the shipped one:It still carries
wanted_w, so the under-shave is a number, not a silence. The arbiter reason isSoC floor— a third distinct label, becauselo = 0 Wnow reaches the resolver from three different states and an operator reads one line.AC2 — when the floor binds, the operator is told the peak will be exceeded
One notification per episode,
notification_id: goodwe_peak_soc_floor, plus a not-ok status row and an action-log line. Deliberately separate fromgoodwe_peak_saturated, because the two ask for opposite things: saturation says shed load, the inverter is already flat out; this says the pack is at its floor and this quarter is not being shaved at all. They are mutually exclusive by construction (the branch returns before theachievable_wclamp that setssaturated) and 0 of the 1296 swept combinations produce both.AC3 — the startup refusal
validate.pyrule 8:peak_soc_floor <= bms_soc_flooris refused, CFG-01 case (a) — the operator states a bound, the BMS enforces a different one, and the status page would report an energy backstop incapable of binding.<=, not<: at equality the EMS floor buys zero margin, so it can never prevent the pack reaching the BMS limit, which is the entire thing the option exists to do. (Rule 6 permits equality because equality there leaves one working point; here it leaves none.) The message names both numbers and the shipped default. Not scoped to any enable flag, for rule 6's reason.AC4 — verified by replaying a quarter that would exhaust the pack
replay()gains a stated pack model: SoC falls by the delivered discharge over the real telegram timestamps, and the BMS is modelled as a hard cut — without that the "before" world is unphysical. The pack is sized from the day's own measured appetite, re-derived every run, so exhaustion is true by construction of the measurement rather than by a number typed into the file: 2928 Wh usable, starting 30 %, i.e. 586 Wh above the BMS against 878 Wh of appetite.peak_soc_floor20 %)Controller.cycle()on a pack one point under the floor confirms the wiring, the write, the single notification, the status row and the log line.CFG-03
Two options —
peak_soc_floor(20),bms_soc_floor(10) — because the floor is a number only the site knows and refusing one below the pack's cut-out means knowing that cut-out too. Defaults are mirrored once, inpeakguard.PEAK_OPTION_DEFAULTS, TARIFF-02'sPRICE_OPTION_DEFAULTSpattern: imported byvalidate.pyandmain.pyinstead of literals, and pinned toconfig.yamlby reading the file (options block and schema block). CFG-03 still tracks three places.peakguard.pystill makes 0.get()calls — both values arrive as arguments.20is derived, not picked: 13.8 kWh usable × 10 pp = 1.57× the guard's worst measured day.⚠️
maintenance_soc_flooris not reused and not renamed. It is the monthly recalibration's drain target; this is a spend limit. The shipped values differ (11 vs 20), which is the argument. The rename is recorded as a decision inpeakguard.pyand DOCS.md — keep it, revisit if a third floor appears (a loop-side guard would be the third), when the migration would buy enough to be worth its cost.Known holes, not inherited
inspect.signature(decide)'s required keyword-only set anddataclasses.fields(PeakDecision)'s no-default set are both pinned, so any future field or argument moving between required and defaulted goes red..decide(grader — no second call site was added. The existing first-.decide(grader is extended to also readsoc=andsoc_floor=off the parsed call, plus a new check that main.py has exactly one.decide(so grading the first grades the shipped one. That syntax grader is what caught the one survivor below.Checks — red and green
test_peakguard.pygrows two parts (8: the floor; 9: the exhausting replay + the refusal + the mirror) and six mutations. All 13 suites pass locally;test_peakguard.pyis 6 s.Eleven one-line mutations of the shipped source were executed against the checks:
decide()<instead of<=wantedfiled anywaywanted > 0test<instead of<=decide()a defaulted SoCmaintenance_soc_floorinsteadSurvivors: none — but only after a fix. The last row survived every behavioural check on the first pass, because the two floors' defaults were close enough that no fixture separated them: exactly the coupling this card forbids, hiding from a value-based check. It is now caught twice — by the parsed call syntax, and by a wiring fixture that sets
maintenance_soc_floorto 5 so the pack at 19 % is under one floor and over the other.Not done
Reviewed. The trade-off is honest, the
loproperty survives a much larger sweep, and one surviving mutation turns out to have no reachable consequence. Merging once CI is green on the rebased head.Own detached worktree, own replay, own harness. Mutations run under
python -Bwith a recursive__pycache__purge and a printed size delta, per TEST-15 — and that discipline caught a defect in my own runner before it could produce a fake survivor, which I mention below because it is exactly the failure mode TEST-15 is about.Full suite green: 14 files on the merged tree.
1. The trade-off — reproduced exactly, and the framing is honest
My own replay loop, written from the model stated in the PR (pack sized from the day's own measured appetite; SoC falls by DELIVERED discharge over the real telegram timestamps; BMS a hard cut; nothing recharges), driving the shipped
P1Ingest/PeakTracker/decide():Every figure lands. The 11.4 Wh between 304.2 and 292.8 is half of one cycle at the rail — so "the energy this card withholds IS the energy between the two floors" is arithmetic, not correlation.
Ruling on 4338 → 5030 W: the trade-off is correct, and making it a check rather than a footnote is the right call. Two things make me comfortable:
What the product owner should see, and what I would not let a summary swallow: on a pack small enough for the floor to bind, SAFETY-24 gives back most of SAFETY-07's shaving on the day SAFETY-07 was built for.
peak_soc_flooris the knob that trades those, and it is now an option rather than a constant, which is the right place for that decision to live.2. The
loproperty survives — over 19,800 combinations, not 1296I re-derived the guard's central guarantee independently, with SoC now in the input space:
plus 0 negative floors across both real-data replay runs. And the SoC-denied path is one-directional exactly as claimed:
Forbids charging, structurally cannot cap discharge. The new input did not break it.
3. Ordering, the boundary, and the action — all confirmed in source and by mutation
discharge_permitted()is consulted after thewanted <= 0.0return, so SoC is never graded on a cycle that was never going to discharge; the denied action isfloor_w = 0.0, notwantedand not negative. Both are load-bearing:Rule 8's
<=is right. At equality the EMS floor and the BMS cut act at the same percent, so the EMS bound can never be the thing that stops the discharge — the add-on would carry a backstop incapable of binding while its status page reports one. That is rule 5's "a disarmed guard that advertises itself as armed", and the module says so.D2(<in the predicate) andD8(<in rule 8) both go red.One observation, not a defect:
D8is caught bytest_peakguard.pyand not bytest_validate.py, though rule 8 lives invalidate.py. The coverage sits one file away from the code.4. The mutation that survived first time is now caught twice — confirmed
D1, main.py readingmaintenance_soc_floor, goes red on 6 assertions: the parsed-call grader and the wiring fixture that separates the two defaults behaviourally. Both catches work, and extending the existing grader rather than adding a second call site was the right shape.And the decoy hole I flagged on SAFETY-07 is closed. I re-ran that attack — add a second
.decide(earlier inmain.pysonext(...)grades the wrong call, then change the real one:The exactly-one check fires before the name check can be fooled. That is my finding from last round, fixed properly.
5. One survivor — and it has no reachable consequence
This is the value-poisoning half of the weakness you already name in the comment ("reads a NAME rather than a value"): the syntax grader still sees
soc=self.soc, and no fixture separates them. It is the exact defaultingdischarge_permitted()'s docstring says must never exist, and which NOTES.md 4.4b records this project shipping once.But I could not make it matter, and the reason exonerates the design. I instrumented
peakguard.decideand drove the realController:With a missing SoC,
inputs_okis False andcycle()returns beforedecide()is ever called. The defaulting line can only matter on a cycle that does not happen. So the property is enforced one layer up, byinputs_ok, and it is enforced more strongly than the guard could enforce it itself.What that does expose is a wording defect in one assertion, and it is worth a line because it is the fourth instance this week of a check whose sentence is wider than its verification:
No Controller is driven there — the line calls
decide(soc=None, ...)directly, which part 3 already grades. Your own comment two lines above concedes the reason (inputs_okstops the loop at 0 W in that state), so the comment and the assertion's wording disagree. The true property is stronger than the one claimed and worth asserting instead: a Controller whose SoC entity does not answer never reachesdecide()at all. Onespyonpeakguard.decideand anis Falseproves it, and it would kill D7 as a side effect.Not blocking: the shipped behaviour is right, the harm is unreachable, and the fix strengthens a claim rather than repairing a defect.
6. A note on my own harness, since TEST-15 is the point
My first mutation run reported size deltas of +1523 and +724 bytes for one-word edits. That was my runner rewriting LF to CRLF through
io.open(..., "w")on Windows — semantically harmless to Python, but it destroys the size delta as a did-it-apply signal, which is the one thing TEST-15 asks it for. Normalised and re-ran;D1then reported the correct +7 bytes (maintenance_soc_floorminuspeak_soc_floor) and the same 6 failures. Recording it because a reviewer's harness is subject to the same rule as yours.7. The honest limits
The grid-following loop can still take the pack past this floor — sufficient as handled, and I would not hold the card for it. A
lolimit is structurally incapable of capping a discharge, so this guard cannot bound the loop's; that is the same asymmetry that makes it safe, and closing it needs a different mechanism, not a bigger version of this one. It is stated in the module, in DOCS, in the PR body and as an honestly-uncaught 13th mutation, which is the right way to carry a known gap: named where a reader meets it, and visible in the check output rather than only in a review thread. It should become a ticket now that it has been written down three times.maintenance_soc_floorkept and not renamed — recorded, not deferred: the decision, its reason (a drain target is not a spend limit), and the trigger to revisit (a third floor, which a loop-side guard would be) are all in the module and in DOCS. That is what the card asked for.CFG-03: confirmed.
peakguard.pymakes 0 executable.get()calls — the three textual matches are all in comments, and the check greps the parsed AST rather than the text, so it is not fooled by its own prose.PEAK_OPTION_DEFAULTSis imported by bothvalidate.pyandmain.pyrather than typed, and pinned toconfig.yaml'soptions:andschema:blocks.8. Housekeeping
e709554was a real execution, not a cancelled run reporting nothing: run 1149,Successful in 3m29s, and the run record showsstatus=successwith a genuine start time. I checked the run, not just the status line.CHANGELOG.md,DOCS.md,main.py,peakguard.py,validate.py,config.yamlandtest_peakguard.py— notstatic_tariff.pyand notprice_forecast.py. (A plaindiff release/1.0..SAFETY-24lists those two, but that is release/1.0 having moved ahead with FCST-03, not this card touching them.)release/1.0into the branch asa1376e4— CHANGELOG only, both stanzas verbatim with SAFETY-24's above FCST-03's, no text from either author edited. Everything else auto-merged. Full suite green on the merged tree (14 files) before pushing. I will merge on the new head's green check, not the old one's.Heads-up from the OPT-04 review: your conflict is not only CHANGELOG.
I merged OPT-04 (#22) as
8e589fewhile this was still open, sorelease/1.0has moved under you.git merge-treebetween the two branches gives:app/validate.pyis a real code conflict rather than a textual one: both cards add refusals to the startup gate, and both blocks want keeping. Nothing else of yours collides —peakguard.pyandtest_peakguard.pyare untouched by OPT-04, and the three files that overlap merge on their own.OPT-04 went first only because it was reviewed and mergeable at the time; there was no judgement about relative importance in it. Integrate rather than rebase, keep both refusal blocks, and let CI report on the integrated head before merging — a cancelled run posts
failurehaving executed nothing, so do not re-trigger casually if you see one.One thing worth knowing while you resolve: OPT-04 added
arbitrage_enabled,cycle_cost_threshold_eur,arbitrage_soc_floorandarbitrage_soc_ceilingtoPRICE_OPTION_DEFAULTSandconfig.yaml, andvalidate.pynow refuses some arbitrage configurations. If SAFETY-24's new option interacts with any of those, that is the place it would show up.Second rebase done, and the merge broke a check that neither card could have seen alone. Pushed as
9234016; merging on that head's own green check.Everything from my review at
#issuecomment-965stands. Three things happened in this rebase that are worth reading rather than skipping.1. The conflicts were what I predicted, plus one import line
CHANGELOG.mdand one import line invalidate.py— nothing more. Both refusal blocks survive untouched:2. ⚠️ The merge broke SAFETY-24's own grader, and I fixed it rather than merging red
This is the interaction you told me not to assume away, and it was not in
config.yaml:test_peakguard.pyselected "the first.decide(in main.py" and asserted there was exactly one. True when SAFETY-24 was written; false the moment OPT-04 landed. The merged tree failed on:I confirmed this on the clean merge before my own edits, so it is the merge's doing and not my patch's.
Two things worth saying about it. First, it failed loudly — the guard refused to grade an ambiguous call site rather than silently grading the wrong one, which is the behaviour you want from a positional check when its assumption dies. Second,
next()still happened to pick the right call, so nothing was mis-graded today; it was one source-order accident away from being.Fixed the way I flagged on SAFETY-07: select by qualified name, not by position.
and the count assertion now requires exactly one
peakguard.decide, explicitly permitting other modules their own. Verified still discriminating, not just green:3. The wording defect, folded in
The assertion claiming "a Controller whose SoC entity does not answer hands
decide()None" drove no Controller. I kept the originaldecide()-level check (reworded to say what it grades) and added the stronger property, graded on the service call rather than its arguments:That is a stronger guarantee than handing
decide()aNonewould be, and it is why myD7survivor is an equivalent mutant:self.soc = 100.0 if self.soc is Noneinserted above the call site is dead code, because the only state that would trigger it is the state in which the line is never reached. I re-ran it against the patched tree (D7b) and it is still green — correctly, because it cannot change behaviour.4. The SoC-option interaction, checked rather than assumed
There are now four SoC floors in
config.yaml:arbitrage.pystates it in its own header:P_PEAK 50 > P_ARBITRAGE 30, so peakguard's lo floor CLAMPS an arbitrage charge. SAFETY-07's floor forbidding a charge outranks arbitrage's cheap-hour charge, which is the right way round.Two follow-ups for the coordinator, neither blocking and neither this card's to fix:
arbitrage_soc_floorisint(5,95)andbms_soc_floorisint(0,50), soarbitrage_soc_floor: 5under abms_soc_floor: 10is a startable configuration — an EMS reserve that can never bind, advertised as one. That is exactly what rule 8 refuses forpeak_soc_floor. OPT-04 checks its floor against its own ceiling but not against the BMS.peakguard.pyrecords: keepmaintenance_soc_floor, "revisit if a THIRD floor appears". OPT-04 added the third (fourth counting the BMS) between the card being written and it merging. Also worth notingpeak_soc_floorandarbitrage_soc_floornow ship the same default of 20, which is the "two defaults close enough that no fixture separates them" trapD1was about, one card over.5. Housekeeping
python -Bunder a purged__pycache__.D1creports +7 bytes (maintenance_soc_floorminuspeak_soc_floor),D9b+97,D7b+59. Each one is the size of the edit, so a mutation that failed to apply cannot masquerade as a survivor.9234016's own check, not ona1376e4's green one, and I will confirm the run executed rather than reading the status line alone.