SAFETY-135 round 2: the first guard stop in a period may restart once; tests and docs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
66d8777b39
commit
4066624cce
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 \
|
||||
|
||||
Reference in New Issue
Block a user