SAFETY-135 round 2: the first guard stop in a period may restart once; tests and docs
checks / unit (pull_request) Successful in 29m42s
infra-pin / fresh (pull_request) Successful in 1s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
This commit is contained in:
glenn schrooyen
2026-11-11 19:49:18 +01:00
co-authored by Claude Opus 5.5
parent 66d8777b39
commit 4066624cce
8 changed files with 356 additions and 95 deletions
+17 -10
View File
@@ -36,9 +36,10 @@ Rules that keep this safe (tests: test_modes_p3.py, test_price_run_fixes.py):
makes the other batteries stop giving power, so a few watts of charge
flipping on and off would swing the house between them and the grid
(measured on the 2026-09-09 replay: 13 flips in 4 minutes at 3.5 kW);
SAFETY-135: a stop in a period's first YOUNG_S s holds only until then,
not to the period's end, and no charge starts then while the batteries'
give is still falling (`young`);
SAFETY-135: the first such stop in a period holds the charge RESTART_S
only, a second one to the period's end (at most one restart), and in a
period's first YOUNG_S s no charge starts while the batteries' give is
still falling (`wait`);
* selling needs selling on for that battery, the sell run, a price >= 0
now, and the battery above `arbitrage_soc_floor`; it stops there. The
peak guard never stops a sell (section 9.1: discharge is never capped);
@@ -72,11 +73,17 @@ PRICE_MODES = (LEFTOVER_SOLAR, CHEAP_HOURS)
# would stop covering the house for less than they give.
START_W = 500.0
# SAFETY-135: seconds a 15-minute period is young, and how far back a
# battery's give counts as maybe not yet on the meter.
# SAFETY-135: how long the first stop in a period holds the charge, how long
# a period is young, and how far back a give may not be on the meter yet.
# DECISIONS/SAFETY-135-young-period.md
RESTART_S = 60.0
YOUNG_S = 60.0
GAVE_S = 15.0
# SAFETY-135: the plain status words for `wait` (UX-08).
WAIT_WORDS = {
"wait": "battery modes: grid charge paused, it tries again in a minute",
"settle": "battery modes: grid charge waits for the meter to catch up",
}
@dataclass(frozen=True)
@@ -173,7 +180,7 @@ def energy(packs, floor_pct, ceiling_pct, done_charge=(), done_sell=(),
def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now,
headroom_w, floor_pct, ceiling_pct, house_w, charging_now=False,
blocked=False, done_charge=(), done_sell=(),
targets=None, draining=False, young=False) -> Tick:
targets=None, draining=False, wait=None) -> Tick:
"""This tick's ranges, roles and site ask.
`runs` is the kept `priceruns.RunPlan` or None (no fresh prices).
@@ -186,8 +193,8 @@ def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now,
it going); `blocked` that the guard or the headroom stopped a charge in
this 15-minute period. `targets` is the day plan (`charge_to()`).
`draining` says a battery drains for its monthly cycle, so none may
charge from the grid (why "cycle"). `young` says no charge may start
yet in this young period (SAFETY-135, why "young").
charge from the grid (why "cycle"). `wait` ("wait" or "settle", the
why) says no charge may start yet (SAFETY-135).
"""
plan_on = runs is not None and runs.on
in_cheap = plan_on and runs.cheap.holds(now) and runs.charge_useful()
@@ -208,8 +215,8 @@ def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now,
why = trade_why
elif headroom_w is None or not headroom_w > need:
why = "headroom"
elif young and not charging_now:
why = "young"
elif wait and not charging_now:
why = wait
else:
charging = True
se = sellers(packs, floor_pct, done_sell) if in_sell and not charging \