SAFETY-25: say what peak_soc_floor bounds, and measure what it does not
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:
2026-09-01 03:46:43 +02:00
co-authored by Claude Opus 5
parent 6ea79f1043
commit 182102eeaf
9 changed files with 878 additions and 15 deletions
+8
View File
@@ -18,6 +18,14 @@ THE FAILURE NEEDS NO FAULT ANYWHERE ELSE. A frozen-but-talking meter is
- there is no SoC guard on the grid-following loop at all. `soc_floor` belongs
to the maintenance cycle; the BMS is the sole energy backstop.
⚠️ STILL TRUE, AND NOW FILED. SAFETY-24 added `peak_soc_floor` and
OPT-04 added `arbitrage_soc_floor`, so the sentence above is no longer
"there is one SoC floor and it is maintenance's" - but neither of the new
ones binds THIS loop, and `peak_soc_floor`'s name reads as though it does.
SAFETY-25 replayed the exposure (test_peakguard.py part 10) and recorded
the options in DECISIONS/SAFETY-25-loop-soc-floor.md; whether the loop gets
a reserve of its own is an open product decision, not an oversight.
The loop reads a constant error and integrates it. Time from freeze to the rail,
replaying the real `control.compute()` at 15 s cycles:
+40 -2
View File
@@ -985,6 +985,16 @@ class Controller:
They cannot both be true: peakguard.decide()'s floor-limited branch
returns before the `achievable_w` clamp that sets `saturated`.
⚠️ SAFETY-25 - AND THE MESSAGE SAYS WHAT THE FLOOR IS NOT. Read on its
own, "the capacity-peak guard has run out of battery" invites exactly
one wrong inference: that the pack is now being held at
`peak_soc_floor`. It is not. `peak_soc_floor` bounds THIS GUARD only;
the grid-following loop has no SoC floor at all and, on the committed
2026-08-20 replay, is being granted discharge in EVERY cycle that raises
this notification. An operator who reads the alarm and relaxes because
"the EMS has a 20 % reserve" has been misled by us, so the alarm carries
the correction itself rather than leaving it to DOCS.md.
Edge-triggered, and de-asserting arms it again - FreezeWatchdog's shape,
for FreezeWatchdog's reason: one notification per episode, not one per
cycle for a quarter of an hour.
@@ -1010,6 +1020,13 @@ class Controller:
# energy floor is not a saturated inverter and is not a frozen meter,
# and an operator who has to dismiss three identical-looking cards
# learns to dismiss the one that mattered.
# ⚠️ SAFETY-25's sentence must render on the unreadable-SoC branch too,
# where `soc_floor` is a number but naming it would invite the reader to
# think the pack is AT it. `decide()` guards its own reason string the
# same way; a f-string that formats a None here would turn the one
# notification that matters into a traceback.
_at_floor = ("at the floor" if g.soc_floor is None
else f"at {float(g.soc_floor):.0f} %")
await self.hass.call("persistent_notification", "create", {
"notification_id": "goodwe_peak_soc_floor",
"title": "GoodWe: the capacity-peak guard has run out of battery",
@@ -1023,7 +1040,13 @@ class Controller:
f"deliberately above the pack's own BMS cut-out. Charging stays "
f"blocked. Shedding load is the only thing that changes the bill "
f"now; if this pack can afford a deeper discharge, lower "
f"peak_soc_floor."),
f"peak_soc_floor.\n\nSAFETY-25 - WHAT THIS FLOOR IS NOT: "
f"peak_soc_floor bounds THE PEAK GUARD ONLY. It is not a "
f"pack-wide reserve, and the battery is NOT being held "
f"{_at_floor} - ordinary self-consumption has no SoC "
f"floor at all and will go on discharging past it, down to the "
f"pack's own BMS cut-out. What has stopped is the guard buying "
f"you a lower bill, not the pack being spent."),
})
async def check_meter_frozen(self) -> None:
@@ -1421,8 +1444,23 @@ class Controller:
# is going to be spent and a human is the only actuator left. A
# separate row from saturation because the two ask for opposite
# things - see check_peak_floor_limited().
#
# ⚠️ SAFETY-25. THE SCOPE SENTENCE IS PART OF THE ROW, NOT A
# DOCS FOOTNOTE. This is the one line an operator reads at the
# exact moment the guard stops discharging, and read alone it
# says "the battery is being held at peak_soc_floor" - which is
# false, and measurably so: on the committed evening replay the
# loop goes on discharging in EVERY cycle this row is showing.
# A floor that binds one consumer and not the other is worse
# than no floor if the page does not say which.
out.append({"ok": False, "warn": False,
"text": _pg.explain()})
"text": _pg.explain()
+ ". NOTE: peak_soc_floor bounds THE PEAK "
"GUARD ONLY - it is not a pack-wide "
"reserve, and ordinary self-consumption "
"has no SoC floor, so the battery may "
"keep discharging below this level down "
"to the BMS cut-out (SAFETY-25)"})
elif _pg.blind:
out.append({"ok": False, "warn": True, "text": _pg.explain()})
else:
+22
View File
@@ -286,6 +286,22 @@ the one place the question is answered. Four things about the shape of it:
discharge, which is the thing SAFETY-07 introduced. A loop-side SoC guard
is a different lever with a different right value.
⚠️ SAFETY-25 MEASURED HOW FAR PAST, so this is no longer a caveat that has
to be taken on trust. On the committed 2026-08-20 capture, 19:00-22:00 UTC
- three hours of house load after the sun has gone, 830 W mean, zero export
- the 13.8 kWh reference pack started one point ABOVE `peak_soc_floor`
ends the evening at 10.0 %, i.e. at `bms_soc_floor`, having delivered
1525 Wh; 343 of 587 control cycles deliver discharge with the pack already
under this floor, and the guard files no floor at all on any of them. The
same replay at `peak_soc_floor` = 5, 11, 20 and 60 ends at the SAME 10.0 %,
which is the finding stated as sharply as it can be: THE EXPOSURE IS A
PROPERTY OF THE CONTROL LAW, NOT A VALUE ANYONE CAN CONFIGURE. And on the
same day's high-load block the two states coexist - 49 cycles reporting
PEAK GUARD FLOOR-LIMITED while the arbiter grants the loop up to 2000 W of
discharge in every one of them. test_peakguard.py part 10 is the replay;
DECISIONS/SAFETY-25-loop-soc-floor.md carries the options and the
recommendation, and the decision itself is the product owner's and is open.
3. IT IS GRADED ONLY WHERE IT IS NOT DEGENERATE. The SoC question is asked
AFTER `wanted > 0` is established - i.e. only where a forced discharge is
actually being asked for. Asked earlier it would grade a quantity that
@@ -373,6 +389,12 @@ CEILING_MONTH_PEAK = "the month's banked peak"
# notification all have to say WHICH, and "floor-limited" alone is unactionable.
FLOOR_SOC_UNREADABLE = ("the battery state of charge was not measured this "
"cycle, and an unreadable SoC is not a full battery")
# ⚠️ SAFETY-25. "the peak guard's SoC floor", not "the SoC floor", and the
# possessive is load-bearing: this string is what the status page, the action log
# and the operator notification all print, and `peak_soc_floor` bounds THIS GUARD
# ONLY. Widening it to "the SoC floor" would make the one sentence an operator
# reads at the moment the battery stops shaving into a claim about the pack that
# the grid-following loop is at that instant disproving.
FLOOR_AT_SOC_FLOOR = "state of charge is at or below the peak guard's SoC floor"
SOURCE = "peak"