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,