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
+27 -10
View File
@@ -2,6 +2,7 @@
Moved unchanged from app/main.py (MAINT-21). DECISIONS/MAIN-orchestration.md."""
import collections
import dataclasses
import logging
from datetime import datetime, timedelta, timezone
@@ -401,10 +402,18 @@ class TradeMixin:
quarter = int(now.timestamp() // 900)
was = self.mode_tick is not None and \
self.mode_tick.action == modetrade.CHARGE
# SAFETY-135: a stop in a young period holds only until it is not
# young (no latch), and no charge starts there while a give falls.
young = now.timestamp() - quarter * 900 < modetrade.YOUNG_S
falling = self._give_falling(now)
# SAFETY-135: the first guard stop in a period (or measured headroom
# stop in a young one) does not latch; the charge may start again
# RESTART_S after it, but not in the period's last RESTART_S. No
# start in a young period while a give falls.
# DECISIONS/SAFETY-135-young-period.md
st = getattr(self, "_charge_stop", None)
paused = (st is not None and st[0] == quarter
and (now.timestamp() - st[1] < modetrade.RESTART_S
or (quarter + 1) * 900 - now.timestamp()
< modetrade.RESTART_S))
wait = ("wait" if paused else
"settle" if self._give_falling(now) else None)
self.mode_tick = modetrade.decide(
runs, now, packs, charge_ok=charge_ok, sell_ok=sell_ok,
trade_why=trade_why, price_now=price_now,
@@ -413,7 +422,7 @@ class TradeMixin:
blocked=self._charge_blocked_q == quarter,
done_charge=done_c, done_sell=done_s, targets=targets,
draining=any(r.phase == DRAIN for r in maint_results or ()),
young=falling or (young and self._charge_young_q == quarter))
wait=wait)
self._latch_charge(now, runs, targets, packs)
# A charge the guard or the headroom stopped stays off this period.
# OPT-15: only a MEASURED stop latches; the first unmeasured headroom
@@ -424,8 +433,10 @@ class TradeMixin:
gap = why == "headroom" and self.arb_headroom_w is None
if why == "peak" or (why == "headroom" and not gap) or (
gap and self._charge_gap_q == quarter):
if young and not gap:
self._charge_young_q = quarter # SAFETY-135
young = now.timestamp() - quarter * 900 < modetrade.YOUNG_S
if (why == "peak" or (young and not gap)) and (
st is None or st[0] != quarter):
self._charge_stop = (quarter, now.timestamp())
else:
self._charge_blocked_q = quarter
elif gap:
@@ -438,10 +449,15 @@ class TradeMixin:
show that yet, so a charge room read now is too big.
DECISIONS/SAFETY-135-young-period.md"""
t = now.timestamp()
gave = self._gave
gave = getattr(self, "_gave", None)
if gave is None:
gave = collections.deque()
self._gave = gave
if gave and t < gave[-1][0]:
gave.clear() # the clock stepped back
if self.site_batt_w is not None:
gave.append((t, max(0.0, float(self.site_batt_w))))
while gave and not 0.0 <= t - gave[0][0] < modetrade.GAVE_S:
while gave and t - gave[0][0] >= modetrade.GAVE_S:
gave.popleft()
if self.site_batt_w is None or not gave \
or t % 900 >= modetrade.YOUNG_S:
@@ -500,7 +516,8 @@ class TradeMixin:
None, 0.0, "size")
if size == 0.0 or runs is None:
reason = ("battery modes: no price run now" if tick.why is None
else f"battery modes: no trade ({tick.why})")
else modetrade.WAIT_WORDS.get(tick.why)
or f"battery modes: no trade ({tick.why})")
self.arb = arbitrage.ArbitrageDecision(
action=None, power_w=None, reason=reason,
spread_eur_kwh=None, required_eur_kwh=None,
+1 -1
View File
@@ -529,7 +529,7 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin,
self._mode_trade_was = False
self._charge_blocked_q = None
self._charge_gap_q = None # OPT-15: an unmeasured stop's quarter
self._charge_young_q = None # SAFETY-135: a young stop's quarter
self._charge_stop = None # SAFETY-135: (quarter, t) first stop
self._gave = collections.deque() # SAFETY-135: (t, W given), GAVE_S
self._ref_cheap = None
# OPT-15: a run edge the loop holds at E: (E, step sign, until, the
+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 \