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
+407 -10
View File
@@ -2,7 +2,7 @@
python3 test_peakguard.py
Ten parts. What each is for, and - where it matters - what it is deliberately
Eleven parts. What each is for, and - where it matters - what it is deliberately
NOT allowed to be tested at:
1. THE CONTROL LAW, `excess_w`, graded AWAY from the two points where it is
@@ -46,7 +46,16 @@ NOT allowed to be tested at:
capture, against a MODELLED PACK small enough that the guard's own 878 Wh
day would take it to the BMS cut-out - so the two worlds, before and after
this card, are run against each other rather than reasoned about.
10. MUTATION. Eleven one-line defeats of these two guards, each run against
10. SAFETY-25: THE LOOP, WHICH THIS FLOOR DOES NOT BOUND. A TRIPWIRE, not a
guarantee - it records a defect and goes red when the defect is fixed.
Part 11's mutation 13 has said in words since SAFETY-24 that ordinary
self-consumption can take the pack past `peak_soc_floor`; this part
replays a sustained-load evening through the SHIPPED control law, the
SHIPPED guard and the REAL arbiter and measures how far past. It also
sweeps `peak_soc_floor` over the whole schema range to show the answer
does not move by one part in ten thousand, and pins the four
operator-facing surfaces that now say so.
11. MUTATION. Eleven one-line defeats of these two guards, each run against
the checks above to show they are not vacuous.
------------------------------------------------------------------------------
@@ -99,7 +108,7 @@ from app import peakguard
from app.arbiter import (
P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_PEAK, P_SAFETY, Claim, resolve,
)
from app.control import peak_at_risk
from app.control import Tuning, compute, peak_at_risk
from app.freeze import FreezeWatchdogConfig, looks_frozen, rail_w
from app.main import Controller
from app.p1 import (
@@ -169,12 +178,26 @@ def read_option(key: str):
def read_schema_max(key: str):
"""The schema's upper bound for `key` - `int(lo,hi)` -> hi."""
return _schema_bound(key, 1)
def read_schema_min(key: str):
"""The schema's lower bound for `key` - `int(lo,hi)` -> lo.
SAFETY-25's sweep needs BOTH ends: the argument there is that no value the
schema accepts changes the answer, and a sweep that only reached upwards
would leave "well, set it lower then" unanswered.
"""
return _schema_bound(key, 0)
def _schema_bound(key: str, which: int):
body = (HERE / "config.yaml").read_text(encoding="utf-8")
marker = body.index("\nschema:")
for line in body[marker:].splitlines():
st = line.strip()
if st.startswith(f"{key}:") and "(" in st and ")" in st:
return float(st[st.index("(") + 1:st.rindex(")")].split(",")[1])
return float(st[st.index("(") + 1:st.rindex(")")].split(",")[which])
return None
@@ -882,6 +905,11 @@ class _Hass:
def __init__(self):
self.writes = []
self.notifications = []
# SAFETY-25. The whole payload, not only the id. AC 3 is about what the
# operator READS, so a fixture that records only that a notification
# fired cannot grade it - TEST-06's finding on SAFETY-18's NOT COVERED
# row, one file across.
self.calls = []
async def limits(self, entity):
return (-LIVE_MAX_W, LIVE_MAX_W, 1.0)
@@ -895,8 +923,15 @@ class _Hass:
async def call(self, domain, service, data):
self.notifications.append(data.get("notification_id"))
self.calls.append(dict(data))
return True
def last_message(self, notification_id: str) -> str:
"""The body of the most recent notification with that id, or ""."""
bodies = [d.get("message", "") for d in self.calls
if d.get("notification_id") == notification_id]
return bodies[-1] if bodies else ""
def built(db_path, opts=None, store=None):
hass = _Hass()
@@ -1814,9 +1849,360 @@ check("...and it is a SEPARATE KEY from maintenance_soc_floor, whose SHIPPED "
and "maintenance_soc_floor" not in PEAK_OPTION_DEFAULTS)
# ==============================================================================
print()
print("10. mutation: what defeats these guards, and which check bites")
print("10. SAFETY-25: the loop discharges past this floor, and how far past")
# ⚠️ WHAT THIS PART IS. It is a TRIPWIRE, in MAINT-04's manner, and it records a
# DEFECT rather than a guarantee. SAFETY-24's mutation 13 said in words that the
# grid-following loop can take the pack past `peak_soc_floor`; that sentence has
# now been written down four times across four tickets and never once measured.
# This part measures it on the committed capture and pins the number, so that:
#
# * the honesty half of SAFETY-25 - the config comment, DOCS.md, the operator
# notification and the status row - is anchored to a replayed figure rather
# than to a claim, and
# * THE DAY SOMEBODY BUILDS A LOOP-SIDE FLOOR, THIS GOES RED. That is the
# point. The exposure must not be able to change under a green suite; it has
# to be closed deliberately, and closing it means answering AC 2 and
# deleting or inverting these checks along with
# DECISIONS/SAFETY-25-loop-soc-floor.md's open status.
#
# ⚠️ AND IT DRIVES THE SHIPPED CODE, not a transcription of it: control.compute()
# at the shipped tuning, peakguard.decide(), peakguard.claim() and
# arbiter.resolve(), in main.py's order. Break any of them and this part moves -
# which is what makes it a check rather than a paragraph. See the memo section 7
# for what is modelled and what is measured.
# The shipped tuning, READ from config.yaml rather than typed - the same rule as
# every other number in this file. A tuning transcribed here would grade a
# control law nobody ships.
LOOP_TUNING = Tuning(
gain=read_option("gain"), max_w=read_option("max_w"),
slew_w=read_option("slew_w"), deadband_w=read_option("deadband_w"),
step_w=int(read_option("step_w")), saturation_w=read_option("saturation_w"),
saturation_cycles=int(read_option("saturation_cycles")),
target_grid_w=read_option("target_grid_w"))
LOOP_MAX_W = float(LOOP_TUNING.max_w)
print(f" the shipped loop tuning, out of config.yaml: {LOOP_TUNING}")
# ⚠️ THE REFERENCE PACK, DERIVED FROM THE THREE HARDWARE NUMBERS THIS REPOSITORY
# ALREADY STATES, not from a rounded kWh figure typed here. peakguard.py's
# PEAK_OPTION_DEFAULTS comment says "300 Ah at 51.2 V with DoD 90 - 13.8 kWh
# usable"; that sentence is asserted below, so if the reference pack ever
# changes this fixture goes red instead of quietly modelling the old one.
REF_PACK_WH = 300.0 * 51.2 * 0.90
_pg_src = (HERE / "app" / "peakguard.py").read_text(encoding="utf-8")
check("the modelled pack is the repository's own reference pack, and the source "
"still says so - 300 Ah at 51.2 V with DoD 90. A fixture whose hardware "
"assumption drifts away from the module it grades is a fixture measuring "
"a machine nobody owns",
"300 Ah at 51.2 V with DoD 90" in _pg_src
and abs(REF_PACK_WH - 13800.0) < 100.0)
def evening(h0: int, h1: int):
"""The rows of 2026-08-20's capture inside [h0, h1) UTC, as they were.
`h1` may be 24, meaning "to the end of the capture" - the file runs to
21:59:56 UTC, which is local midnight.
"""
lo = datetime(2026, 8, 20, h0, tzinfo=timezone.utc)
hi = (datetime(2026, 8, 21, tzinfo=timezone.utc) if h1 >= 24
else datetime(2026, 8, 20, h1, tzinfo=timezone.utc))
out = []
for line in gzip.open(BAD, "rt", encoding="utf-8"):
row = json.loads(line)
t = datetime.fromisoformat(row["t"])
if lo <= t < hi:
out.append((t, float(row["w"])))
return out
def replay_loop(window, *, start_soc, soc_floor, usable_wh=REF_PACK_WH):
"""The GRID-FOLLOWING LOOP over a window, closed-loop, with a modelled pack.
part 6's `replay()` models the PEAK GUARD's discharge and pins the SoC above
the floor on purpose - it is grading SAFETY-07. This one models the thing
SAFETY-07 never claimed to bound: ordinary self-consumption.
The cycle is main.py's, in main.py's order and with main.py's claims:
peakguard.decide(...) the guard sees this cycle's SoC
control.compute(...) the loop sees the meter minus the battery
arbiter.resolve([supervised, loop@P_LOOP, peakguard.claim(...)])
⚠️ THE ARBITER IS IN THE PATH AND THAT IS THE WHOLE POINT. Cutting it out
and reading `Decision.target_w` directly would measure the control law and
prove nothing about the floor: the question is whether the guard's `lo`
claim, filed at P_PEAK against a loop winning at P_LOOP, can cap the
discharge. It cannot, and it is `resolve()` that has to say so.
The pack model is part 9's, one line for one line - SoC falls by the
DELIVERED discharge integrated over the real telegram stamps, the BMS is a
hard cut at `bms_soc_floor`, and nothing recharges inside the window.
`start_soc` is the one number the captures cannot supply (they carry meter
power only), and it is derived from the shipped option rather than picked.
"""
ing = P1Ingest(phases=1, max_age_s=MAX_HOLD_S)
store = fresh_store(f"s25-{window[0][0]:%H%M}-{soc_floor:.0f}-{start_soc:.0f}")
tracker = PeakTracker(store, cap_w=CAP_W)
batt = 0.0 # delivered discharge, W (positive)
prev_w = i_w = 0.0
sat = 0
soc = float(start_soc)
min_soc = soc
wh = 0.0
prev_t = next_cycle = None
cycles = below = below_discharging = limited = limited_discharging = 0
at_bms = guard_floors = 0
max_granted = 0.0
for t, w in window:
if prev_t is not None:
dwh = batt * (t - prev_t).total_seconds() / 3600.0
wh += dwh
soc = max(0.0, soc - 100.0 * dwh / usable_wh)
min_soc = min(min_soc, soc)
prev_t = t
ing.submit(sample(t, w - batt))
tracker.sync(ing)
if next_cycle is not None and t < next_cycle:
continue
next_cycle = t + timedelta(seconds=CYCLE_S)
cycles += 1
d = decide(PeakTracker.project(ing.averager, t), batt_w=batt,
cap_w=CAP_W, reading=tracker.peak(t),
achievable_w=LOOP_MAX_W, max_hold_s=MAX_HOLD_S, soc=soc,
soc_floor=soc_floor, frozen=False)
dec = compute(prev_w, w - batt, batt, LOOP_TUNING, sat_count=sat,
i_w=i_w)
sat, i_w = dec.sat_count, dec.i_w
claims = [Claim.limit("supervised", P_SAFETY, -LOOP_MAX_W, LOOP_MAX_W,
"max_w"),
Claim.set("loop", P_LOOP, dec.target_w, dec.reason)]
pc = peakguard.claim(d)
if pc is not None:
claims.append(pc)
if pc.lo > 0.0:
guard_floors += 1
r = resolve(claims)
prev_w = r.target_w
# THE BMS, MODELLED - part 9's line, for part 9's reason. The pack's own
# cut-out is a real floor and it works; the finding is that it is the
# only one.
if soc <= BMS_FLOOR:
at_bms += 1
batt = 0.0
else:
batt = max(0.0, r.target_w)
max_granted = max(max_granted, batt)
if soc < soc_floor:
below += 1
below_discharging += 1 if batt > 0.0 else 0
if d.floor_limited:
limited += 1
limited_discharging += 1 if batt > 0.0 else 0
return {"cycles": cycles, "wh": wh, "min_soc": min_soc, "end_soc": soc,
"below": below, "below_discharging": below_discharging,
"limited": limited, "limited_discharging": limited_discharging,
"at_bms": at_bms, "guard_floors": guard_floors,
"max_granted": max_granted}
# --- 10a. the sustained-load evening ----------------------------------------
# 19:00-22:00 UTC is 21:00-24:00 local: the sun is down, the PV is off, and the
# house is simply drawing. Chosen for that property and CHECKED for it below -
# a window with export in it would let the loop recharge and the replay would be
# measuring something else.
EVE = evening(19, 24)
EVE_W = [w for _, w in EVE]
print(f" the evening of 2026-08-20, {EVE[0][0]:%H:%M}-{EVE[-1][0]:%H:%M} UTC: "
f"{len(EVE)} telegrams, mean {sum(EVE_W) / len(EVE_W):.0f} W, "
f"min {min(EVE_W):.0f} W, max {max(EVE_W):.0f} W")
check("the window really is a SUSTAINED-LOAD evening and not a quiet one - "
"every telegram in it is an IMPORT, so the pack has nowhere to recharge "
"from and the loop is discharging for the whole three hours",
min(EVE_W) > 0.0 and len(EVE) > 1500)
# ⚠️ ONE POINT ABOVE THE FLOOR, DERIVED FROM THE SHIPPED OPTION. This is the
# state an operator who has read `peak_soc_floor: 20` believes is protected: the
# pack is above the number they set. It is the only fixture parameter the
# captures cannot supply, and it is taken from config.yaml rather than chosen.
EVE_START = SOC_FLOOR + 1.0
EV = replay_loop(EVE, start_soc=EVE_START, soc_floor=SOC_FLOOR)
print(f" the reference pack ({REF_PACK_WH:.0f} Wh usable) starting at "
f"{EVE_START:.0f} %, one point above peak_soc_floor {SOC_FLOOR:.0f} %:")
print(f" ends at {EV['end_soc']:.1f} %, low-water {EV['min_soc']:.1f} %, "
f"{EV['wh']:.0f} Wh delivered by the LOOP over {EV['cycles']} cycles")
print(f" {EV['below']} cycles with the pack under peak_soc_floor, "
f"{EV['below_discharging']} of them still discharging; "
f"{EV['at_bms']} at the BMS cut-out; the guard filed a floor on "
f"{EV['guard_floors']}")
check(f"THE FINDING, REPLAYED. One sustained-load evening takes the pack from "
f"above peak_soc_floor to the pack's own BMS cut-out - "
f"{EVE_START:.0f} % -> {EV['end_soc']:.1f} % against a "
f"{BMS_FLOOR:.0f} % cut-out - and the EMS floor an operator set is not "
f"what stopped it",
EV["min_soc"] <= BMS_FLOOR + 0.05 and EV["at_bms"] > 0)
check("...and it is not one cycle of overshoot past the floor either: the loop "
"goes on delivering discharge for hundreds of cycles with the pack "
"already below the number the operator configured",
EV["below_discharging"] > 100 and EV["below"] > EV["below_discharging"])
check("...and the peak guard is not involved at all. It never files a floor on "
"this evening, because 830 W of house load never projects a quarter over "
"the 3500 W ceiling - so `peak_soc_floor` is not merely too weak here, it "
"is never consulted", EV["guard_floors"] == 0 and EV["limited"] == 0)
# --- 10b. and it is a BEHAVIOUR, not a config value -------------------------
# ⚠️ THE SWEEP IS THE ARGUMENT. "Raise peak_soc_floor" is the first thing anyone
# reading the option name would try, so the check is not that it is insufficient
# - it is that it does NOTHING, at any value the schema will accept, to four
# decimal places. Both ends of the schema range are swept rather than a
# comfortable middle.
SOC_SCHEMA_LO = float(read_schema_min("peak_soc_floor"))
SOC_SCHEMA_HI = float(read_schema_max("peak_soc_floor"))
SWEEP_FLOORS = [SOC_SCHEMA_LO, BMS_FLOOR + 1.0, SOC_FLOOR, SOC_SCHEMA_HI]
SWEPT = [(f, replay_loop(EVE, start_soc=EVE_START, soc_floor=f))
for f in SWEEP_FLOORS]
for f, res in SWEPT:
print(f" peak_soc_floor {f:5.0f} % -> ends at {res['end_soc']:.4f} %,"
f" {res['wh']:.2f} Wh out, guard floors {res['guard_floors']}")
check(f"the exposure is a BEHAVIOUR, NOT A CONFIG VALUE - the same evening ends "
f"at the same state of charge at every peak_soc_floor the schema accepts "
f"({SOC_SCHEMA_LO:.0f}..{SOC_SCHEMA_HI:.0f} %), to four decimal places. "
f"This is AC 4's sentence, measured: the option an operator would reach "
f"for cannot move this number by any amount at all",
len({round(res["end_soc"], 4) for _, res in SWEPT}) == 1
and len({round(res["wh"], 4) for _, res in SWEPT}) == 1)
# ⚠️ THE SWEEP DELIBERATELY OVERRUNS WHAT VALIDATE WOULD ACCEPT. Its low end is
# the SCHEMA's, and rule 8 refuses anything at or below `bms_soc_floor`, so a
# start at 5 % is not a configuration an operator could boot. Including it is the
# point: it closes "you swept only the legal band" without leaving the reader to
# wonder what happens outside it. The band that IS startable is bounded below by
# rule 8, and BMS_FLOOR + 1 is its first member, which is in the sweep too.
check("...and the sweep spans the whole schema range, rule 8's refused low end "
"included, so 'a legal value would have worked' is closed off rather "
"than left open. The first STARTABLE floor - one point above "
"bms_soc_floor - is swept as well, and lands on the same number",
SOC_SCHEMA_HI >= 60.0 and SOC_SCHEMA_LO <= BMS_FLOOR
and (BMS_FLOOR + 1.0) in SWEEP_FLOORS
and len([f for f in SWEEP_FLOORS if f > BMS_FLOOR]) >= 3)
# --- 10c. the two states in the SAME cycle ----------------------------------
# The evening never engages the guard, so it cannot show the sharpest form of
# the finding: the status page saying FLOOR-LIMITED while the pack empties. The
# same day's high-load block can, and it is the same capture and the same code.
HOT = evening(15, 17)
HOT_R = replay_loop(HOT, start_soc=SOC_FLOOR - 1.0, soc_floor=SOC_FLOOR)
print(f" the same day's high-load block "
f"{HOT[0][0]:%H:%M}-{HOT[-1][0]:%H:%M} UTC, pack started one point BELOW "
f"the floor:")
print(f" {HOT_R['limited']} cycles reporting PEAK GUARD FLOOR-LIMITED, "
f"{HOT_R['limited_discharging']} of them with the loop simultaneously "
f"granted discharge (max {HOT_R['max_granted']:.0f} W)")
check("THE ASYMMETRY, ON REAL DATA. On every cycle where the guard reports "
"FLOOR-LIMITED - 'commanding no forced discharge' on the status page and "
"in the operator's notification - the arbiter is at the same instant "
"granting the LOOP a discharge. A `lo` limit cannot cap a discharge, so "
"the guard's floor and the pack emptying are not alternatives; they "
"happen together",
HOT_R["limited"] > 10
and HOT_R["limited_discharging"] == HOT_R["limited"]
and HOT_R["max_granted"] > 0.0)
check("...which is why SAFETY-24's floor could not be widened into this one, "
"and why the fix is not a bigger number. Recorded in "
"DECISIONS/SAFETY-25-loop-soc-floor.md; AC 2 is the product owner's and "
"is open", HOT_R["min_soc"] <= BMS_FLOOR + 0.05)
# --- 10d. the trap in the obvious fix, checked rather than asserted ---------
# ⚠️ THE MEMO'S CENTRAL ENGINEERING CLAIM, RUN THROUGH THE REAL ARBITER. The
# first shape anyone reaches for is a `hi` limit on the loop - and filed while
# the peak guard is asking for a positive floor it is `lo > hi`, which
# arbiter.py resolves to 0 W: THE BATTERY PARKED, in the one quarter of the
# month that sets the bill. That is the same crossing-limits hazard
# shaping_suspended() already exists to avoid, and it is why the memo's Option B
# carries an ORDERING RULE rather than a number.
_RESERVE_HI = Claim.limit("reserve", P_LOOP, hi=0.0, reason="loop SoC floor")
_naive = resolve(SAFE + [Claim.set("loop", P_LOOP, 1800.0, "grid"),
peakguard.claim(d_ok), _RESERVE_HI])
_ordered = resolve(SAFE + [Claim.set("loop", P_LOOP, 1800.0, "grid"),
lc, _RESERVE_HI])
print(f" a naive loop reserve while the guard wants a floor: "
f"{_naive.explain()}")
print(f" the same reserve while the guard is floor-limited: "
f"{_ordered.explain()}")
check("a loop-side `hi` reserve filed while the peak guard is asking for a "
"POSITIVE floor is a CONTRADICTION and parks the battery at 0 W - so the "
"obvious shape of AC 2 has a trap in it, and this is it, run rather than "
"described", _naive.contradiction and near(_naive.target_w, 0.0))
check("...and the ordering the memo proposes removes it: with "
"loop_soc_floor <= peak_soc_floor the guard is ALREADY floor-limited "
"wherever the reserve binds, so it files lo = 0, and lo == hi == 0 "
"resolves to a clean 0 W with no contradiction",
not _ordered.contradiction and near(_ordered.target_w, 0.0)
and near(lc.lo, 0.0) and peakguard.claim(d_ok).lo > 0.0)
# --- 10e. the honesty half, pinned to the shipped text ----------------------
# ⚠️ ASSERT THE STRING, NOT THE INTENT - TEST-06's lesson on SAFETY-18's NOT
# COVERED row, which was untested for exactly this reason: the one branch that
# exists to tell an operator what a guard does NOT cover is the one nothing
# grades. These four surfaces are the whole of AC 3, and each is checked where
# it actually renders.
_cfg_src = (HERE / "config.yaml").read_text(encoding="utf-8")
_docs_src = (HERE / "DOCS.md").read_text(encoding="utf-8")
_memo = HERE / "DECISIONS" / "SAFETY-25-loop-soc-floor.md"
check("config.yaml says next to the option itself that peak_soc_floor bounds "
"the peak guard only and is not a pack-wide reserve - the operator edits "
"this file, so it is the first place the scope has to be true",
"BOUNDS THE PEAK GUARD ONLY" in _cfg_src
and "NOT A PACK-WIDE RESERVE" in _cfg_src.upper()
and "bms_soc_floor) stops it" in _cfg_src)
check("...and DOCS.md says it in the options table AND in the section, with the "
"replayed evening rather than an assurance",
"not a pack-wide reserve" in _docs_src
and "IT BOUNDS THE PEAK GUARD ONLY" in _docs_src
and "SAFETY-25" in _docs_src)
check("...and the decision memo AC 1 asks for is COMMITTED, carries the options "
"and a recommendation, and is honest that the decision is not taken",
_memo.exists()
and "Status: OPEN. This is a memo, not a decision." in _memo.read_text(
encoding="utf-8")
and "Recommendation" in _memo.read_text(encoding="utf-8"))
check("...and DOCS.md sends the reader to it rather than leaving the open "
"question implicit",
"DECISIONS/SAFETY-25-loop-soc-floor.md" in _docs_src)
# The two OPERATOR-FACING surfaces, rendered by the real Controller rather than
# grepped: the status row and the notification body are what a human reads at
# the moment the guard stops, and a scope sentence that lives only in a source
# comment is a scope sentence the operator never sees.
c25, hass25, NET25, _ = wired_over_limit(
TMP / "soc25.db", LIVE_MAX_W * 0.3, soc=SOC_FLOOR - 1.0)
rows25 = [r for r in c25.checks() if "peak guard" in r["text"].lower()]
print(" status row: " + (rows25[0]["text"] if rows25 else "(none)"))
check("the STATUS PAGE row for a floor-limited guard says the floor bounds the "
"guard only and that self-consumption may keep discharging below it. "
"Without that sentence the page implies a pack-wide reserve at the exact "
"moment the replay above says the loop is still emptying the pack",
len(rows25) == 1
and "PEAK GUARD FLOOR-LIMITED" in rows25[0]["text"]
and "BOUNDS THE PEAK GUARD ONLY" in rows25[0]["text"].upper()
and "not a pack-wide reserve" in rows25[0]["text"]
and "BMS cut-out" in rows25[0]["text"])
_body25 = hass25.last_message("goodwe_peak_soc_floor")
print(f" notification body tail: ...{_body25[-180:]}")
check("...and so does the persistent notification, which outlives the page and "
"is the thing an operator reads on a phone at 19:00. It names what has "
"stopped - the guard buying a lower bill - and what has not",
"peak_soc_floor bounds THE PEAK GUARD ONLY" in _body25
and "not a" in _body25 and "pack-wide reserve" in _body25
and "BMS cut-out" in _body25)
# ==============================================================================
print()
print("11. mutation: what defeats these guards, and which check bites")
# Each entry re-implements ONE defeat and reports whether a check above would
# have gone red. The last row is the honest one: it does NOT bite, and saying so
@@ -1929,13 +2315,22 @@ MUT.append(("floor-limited reported but the floor still filed at `wanted`",
# 13. THE SECOND ONE THAT DOES NOT BITE, and it is a real limitation rather than
# a defect. Stated, because the alternative is a reader believing this card
# bounds more than it does.
#
# ⚠️ SAFETY-25 CHANGED WHAT "NOT CAUGHT" MEANS HERE, AND ONLY THAT. Nothing
# PREVENTS it - the code is unchanged and a `lo` limit still cannot cap a
# discharge - but part 10 now MEASURES it on the committed capture, so the
# exposure can no longer change size under a green suite. That is the whole
# difference between an uncaught mutation and an unrecorded one.
_loop_unbounded = near(r_disch.target_w, 1800.0, 1e-9)
MUT.append(("the grid-following loop discharging past this floor",
not _loop_unbounded,
"NOTHING, ON PURPOSE - this card bounds THE PEAK GUARD's forced "
"discharge, the thing SAFETY-07 introduced. SAFETY-18's finding "
"that the loop itself has no SoC guard is still open, and a `lo` "
"limit is structurally incapable of closing it"))
"NOTHING PREVENTS IT, ON PURPOSE - this card bounds THE PEAK "
"GUARD's forced discharge, the thing SAFETY-07 introduced. A `lo` "
"limit is structurally incapable of closing SAFETY-18's finding "
"that the loop has no SoC guard. SAFETY-25 part 10 now MEASURES "
"it: one sustained-load evening, "
f"{EVE_START:.0f} % -> {EV['end_soc']:.1f} % on the reference pack, "
f"identical at every peak_soc_floor the schema accepts"))
for label, caught, where in MUT:
print(f" {'caught' if caught else 'NOT CAUGHT':>10} {label}")
@@ -1955,7 +2350,9 @@ check("...and so is the last one, which is not a defeat at all but the EDGE OF "
"THIS CARD: the grid-following loop can still take the pack past this "
"floor, because the floor bounds the peak guard's own forced discharge "
"and a `lo` limit cannot cap a discharge. Saying so here is worth more "
"than a green line that would be untrue", not MUT[12][1])
"than a green line that would be untrue - and part 10 now says it with a "
"number instead of a sentence, which is worth more again",
not MUT[12][1] and EV["min_soc"] <= BMS_FLOOR + 0.05)
# ==============================================================================