SAFETY-25: say what peak_soc_floor bounds, and measure what it does not
checks / unit (pull_request) Successful in 3m41s
checks / unit (pull_request) Successful in 3m41s
peak_soc_floor reads like a pack-wide reserve and is not one. The
grid-following loop has no SoC guard of any kind, so on a sustained-load
evening it discharges straight past that number and keeps going until the
pack's own BMS cut-out stops it.
The gap has now been written down four times without being filed -
SAFETY-18's finding (freeze.py:18), SAFETY-24's module note, its PR body,
and its honestly-uncaught 13th mutation - and never once measured. It is
measured here, on the committed 2026-08-20 capture, driving the SHIPPED
control.compute(), peakguard.decide(), peakguard.claim() and the real
arbiter.resolve() in main.py's order:
19:00-22:00 UTC, 830 W mean, minimum 701 W, zero export in the window
reference pack (300 Ah x 51.2 V x DoD 90) started at 21 %, one point
above peak_soc_floor
ends at 10.0 % - the bms_soc_floor cut-out
1525 Wh delivered by the LOOP over 587 cycles
549 cycles below peak_soc_floor, 343 of them still discharging
0 cycles on which the peak guard filed any floor at all
AND IT IS A BEHAVIOUR, NOT A CONFIG VALUE. The same evening ends at
9.9715 % at peak_soc_floor = 5, 11, 20 and 60 - the shipped value and both
ends of the schema range - identical to four decimal places. The option an
operator would reach for cannot move the number by any amount at all. On
the same day's high-load block the two states coexist: 49 cycles reporting
PEAK GUARD FLOOR-LIMITED and, on all 49 of them, the arbiter simultaneously
granting the loop up to 2000 W of discharge.
WHY SAFETY-24 COULD NOT CLOSE IT. The peak guard files a `lo` limit - a
LOWER bound on the setpoint. That asymmetry is exactly what makes it
trustworthy: a reviewer proved across 19,800 combinations that it can never
lower a target, cap a discharge or contradict the arbiter. The same property
makes it structurally incapable of stopping a discharge the loop itself
wants. A floor on the guard cannot become a floor on the pack by being
raised, and the sweep above is that sentence measured.
WHAT THIS COMMIT CHANGES IS THE HONESTY, on the four surfaces an operator
actually reads. That half is required whichever way the open question is
answered, and it is the real misstatement on the live system today:
* config.yaml, beside the option itself - it BOUNDS THE PEAK GUARD ONLY
and is NOT A PACK-WIDE RESERVE, with the replayed evening and the sweep;
* DOCS.md, in the options table and in a new section carrying the figures;
* the STATUS-PAGE ROW for a floor-limited guard. This is the one line a
human reads at the moment the guard stops discharging, and read alone it
says "the battery is being held at peak_soc_floor" - which the replay
shows is false in every cycle that row is rendered;
* the PERSISTENT NOTIFICATION, which outlives the page. It now names what
has stopped (the guard buying a lower bill) and what has not (the pack
being spent).
peakguard.py and freeze.py carry the measurement beside the findings they
already recorded, so the next reader does not have to take the caveat on
trust.
THE PRODUCT DECISION IS NOT TAKEN HERE, and must not be. Whether the loop
gets a reserve floor of its own decides whether this house's battery may
discharge to the BMS floor on a heavy evening; that moves real energy and it
is the owner's call. DECISIONS/SAFETY-25-loop-soc-floor.md is the memo AC 1
asks for: the options, what each costs, what each protects, weighed against
MAINT-01 (a pack repeatedly at the BMS floor is the SoC-drift condition the
maintenance cycle exists to correct, and an over-tight reserve fights it from
the other side) and against the fact that this would be the FIFTH SoC key -
OPT-04's arbitrage_soc_floor fired SAFETY-24's "revisit if a third floor
appears" trigger unremarked, so SAFETY-27's naming decision should land
first. It carries a recommendation and says plainly that it is not a
decision.
It also records a trap found while writing it, and now CHECKED through the
real arbiter rather than described: the obvious shape of a loop-side floor
is a `hi` limit at P_LOOP, and filed while the peak guard is asking for a
positive floor that is `lo > hi` - which arbiter.py resolves to a
CONTRADICTION at 0 W, the battery PARKED in the one quarter of the month
that sets the bill. It is the same crossing-limits hazard
shaping_suspended() already exists to avoid. The ordering
`bms_soc_floor < loop_soc_floor <= peak_soc_floor` removes it provably,
because wherever the reserve binds the guard is already floor-limited and
files lo = 0; both halves are run in part 10d.
test_peakguard.py part 10 is a TRIPWIRE, not a guarantee. It records a
defect and goes RED the day somebody builds a loop-side floor, so the
exposure cannot change size under a green suite and closing it has to be
deliberate. Part 11's mutation 13 stays honestly NOT CAUGHT - nothing
prevents the loop discharging past this floor - but it now reports a number
instead of a sentence.
Verified: 16/16 suites green in the CI image. Eight mutations, each against
a tree copied fresh with __pycache__ excluded and mounted :ro, so no .pyc
existed to go stale (MUTATION.md's structural immunity, the same one it
records for CI); byte deltas +61, -8, -465, -430, -1088, -46, file deleted,
and -95; none is +0 B. All eight go red on the named check:
M1 peakguard.claim() also files hi=floor_w -> 7 red, incl. 10c
M2 control.compute() gain forced to 0 -> 6 red, incl. 10a/10b
M3 scope sentence removed from the status row -> 10e red
M4 scope sentence removed from the notification -> 10e red
M5 scope paragraph removed from config.yaml -> 10e red
M6 scope bullet weakened in DOCS.md -> 10e red
M7 the decision memo deleted -> 10e red
M8 arbiter clamps crossing limits instead of
reporting a contradiction -> 5 red, incl. 10d
AC 2 is left explicitly OPEN, pending the owner's answer.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -430,7 +430,7 @@ help - see "Why the tuning is what it is".
|
||||
|---|---|
|
||||
| `peak_forecast_entity` | Quarter-hour demand forecast, for capacity-tariff markets. Empty = no cap |
|
||||
| `peak_cap_w` | The site's capacity-tariff target |
|
||||
| `peak_soc_floor` (20) | **The percent below which the capacity-peak guard will not discharge the battery.** Not optional and not the same thing as `maintenance_soc_floor` — see below |
|
||||
| `peak_soc_floor` (20) | **The percent below which the capacity-peak guard will not discharge the battery.** ⚠️ It bounds **the peak guard only** — it is **not a pack-wide reserve**, and ordinary self-consumption discharges straight past it (SAFETY-25, see below). Not optional and not the same thing as `maintenance_soc_floor` |
|
||||
| `bms_soc_floor` (10) | The pack's **own** cut-out percent, the number the BMS enforces whatever you set above. `peak_soc_floor` must be **strictly above** it or the add-on refuses to start |
|
||||
| `price_now_entity`, `price_avg_entity` | Dynamic tariff. Empty = never force a paid grid top-up |
|
||||
|
||||
@@ -552,8 +552,13 @@ exactly like the SoC drift the monthly maintenance cycle exists to correct.
|
||||
- **`peak_soc_floor` (20 %)** is where the forced discharge stops. Below it the
|
||||
guard files a **0 W** floor instead of a sized one: charging stays blocked
|
||||
(that can only ever lower your offtake), and no further discharge is asked for.
|
||||
- **It does not stop the ordinary self-consumption loop.** That loop still has no
|
||||
SoC guard of its own. This bounds what the *peak guard* spends for money.
|
||||
- **⚠️ IT BOUNDS THE PEAK GUARD ONLY. IT IS NOT A PACK-WIDE RESERVE, AND THE NAME
|
||||
READS LIKE ONE.** The ordinary self-consumption loop — the grid-following
|
||||
control law that holds your meter at zero — has **no SoC floor of any kind**.
|
||||
It will discharge straight past this number and keep going until the pack's own
|
||||
BMS cut-out stops it. What you are promised by `peak_soc_floor: 20` is *"the
|
||||
capacity-peak guard will not spend the pack for money below 20 %"*, and
|
||||
**nothing more**. See below for what that costs on a real evening.
|
||||
- **When it binds you are told**, once per episode, with notification
|
||||
`goodwe_peak_soc_floor` and a status-page row reading `PEAK GUARD
|
||||
FLOOR-LIMITED`: the quarter **will** go over the ceiling, so shedding load is
|
||||
@@ -572,6 +577,53 @@ Why 20 %: the reference pack is 13.8 kWh usable, so 10 points of margin over the
|
||||
BMS cut-out is about 1.6× the guard's worst measured day. If your pack is
|
||||
smaller, or your quarters are worse, raise it.
|
||||
|
||||
#### ⚠️ What `peak_soc_floor` does **not** protect (SAFETY-25)
|
||||
|
||||
**On a sustained-load evening this add-on will take your battery to the BMS
|
||||
cut-out, whatever `peak_soc_floor` says.** That is measured, not argued. Replayed
|
||||
on the committed 2026-08-20 capture, 19:00–22:00 UTC — three hours of house load
|
||||
after the sun has gone, 830 W average, not one watt of export in the window — with
|
||||
the 13.8 kWh reference pack started at 21 %, one point above the shipped floor:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| pack at the start of the evening | **21 %** (one point above `peak_soc_floor`) |
|
||||
| pack three hours later | **10.0 %** — the `bms_soc_floor` cut-out |
|
||||
| energy the loop took out | **1525 Wh** |
|
||||
| control cycles below `peak_soc_floor` | **549 of 587**, and **343** of those still delivering discharge |
|
||||
| cycles on which the peak guard filed *any* floor | **0** |
|
||||
| same replay at `peak_soc_floor` = 5, 11, 20, 60 | **the same 10.0 %, to four decimal places** |
|
||||
|
||||
That last row is the point: **this is a property of the control law, not a value
|
||||
you can configure.** Raising `peak_soc_floor` to 60 % changes nothing at all,
|
||||
because the guard it bounds is not the thing doing the discharging.
|
||||
|
||||
**And the two can happen in the same cycle.** On the high-load block of the same
|
||||
day (15:00–17:00 UTC) with the pack below the floor, the guard reports
|
||||
`PEAK GUARD FLOOR-LIMITED` — *"commanding no forced discharge"* — on 49 cycles,
|
||||
and on **all 49 of them** the loop is simultaneously being granted a discharge of
|
||||
up to 2000 W by the arbiter. The status page is telling you the truth about the
|
||||
guard while the battery empties.
|
||||
|
||||
**Why it is built this way, and why SAFETY-24 could not fix it.** The peak guard
|
||||
files a `lo` limit — a *lower* bound on the setpoint. That asymmetry is exactly
|
||||
what makes the guard safe: it is provably incapable of lowering a target, capping
|
||||
a discharge or contradicting the arbiter. The same property makes it incapable of
|
||||
stopping a discharge the loop itself wants. A floor on the guard cannot become a
|
||||
floor on the pack by being raised.
|
||||
|
||||
**Is this a runaway?** No. Self-consumption discharge is demand-driven and
|
||||
self-limiting: it stops when the house stops drawing, and every watt of it is
|
||||
serving load you would otherwise have imported. It is not SAFETY-18's frozen-meter
|
||||
rail. What it means is that the *depth* your pack reaches on a heavy evening is
|
||||
set by your house, not by any number in this file, and the BMS is the only thing
|
||||
underneath it.
|
||||
|
||||
**Whether it should stay that way is an open product decision**, recorded with its
|
||||
options, costs and a recommendation in
|
||||
[`DECISIONS/SAFETY-25-loop-soc-floor.md`](DECISIONS/SAFETY-25-loop-soc-floor.md).
|
||||
Until it is answered, this section is the honest statement of what you have.
|
||||
|
||||
### The winding-loop alarm (the output clamp)
|
||||
|
||||
Every setpoint this add-on writes is clamped to `max_w` before it leaves the
|
||||
|
||||
Reference in New Issue
Block a user