OPT-20: a refusal probe asks only PROBE_W, not the whole trade
checks / unit (pull_request) Failing after 40m47s
infra-pin / fresh (pull_request) Successful in 1s

The probe's trade was sized at the probing battery's rating. Its SAFETY-73
pin held 200 W of that, and the allocator put the rest on a battery with no
mode range (one in a maintenance phase): TEST-23 OPT-15 g7, -2300 W on A in
its maintenance charge for 34 s, grid 3100 W.

modetrade.decide() now asks PROBE_W for the probing battery plus what the
other chargers would have had without the probe; size() adds the probe on top
of their share, or files PROBE_W exactly, or nothing. _mode_trade() carries
the pin only on the ticks it is filed, so the window's first and last cycle
do not move another battery's share either. Modes off and modes on with no
probe are unchanged (bf9019e digests). Tests: test_opt20.py.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-10-15 13:08:59 +02:00
co-authored by Claude
parent bf9019e70d
commit 06339a2446
5 changed files with 482 additions and 4 deletions
+26 -4
View File
@@ -47,12 +47,17 @@ Rules that keep this safe (tests: test_modes_p3.py, test_price_run_fixes.py):
is its SAFETY-73 probe inside a cheap run (OPT-15): it is a charger then,
pinned at PROBE_W by its own claim, so the others rest (one sign) and the
card's "tries again every 15 minutes" holds in the run too;
* OPT-20: a probe asks PROBE_W for the probing battery and nothing more.
The rest of the ask is what the other chargers would have had without
the probe (none: PROBE_W alone), so a probe never places charge on a
battery in a maintenance phase or on one that rests;
* no grid charge while another battery drains for its monthly cycle
(`draining`, SAFETY-78): it would charge against the drain.
"""
from dataclasses import dataclass
from .control import PROBE_W
from .packmode import (CHEAP_HOURS, GRID_CHARGE_MODES, LEFTOVER_SOLAR,
PLAN_CHARGE_MODES, SOLAR_FIRST)
@@ -88,6 +93,7 @@ class Tick:
action: str | None # "charge" | "sell" | None
ask_w: float # signed site ask before sizing (0 if none)
why: str | None # why no trade when a run is on, else None
probe_w: float = 0.0 # OPT-20: the part of `ask_w` that is a probe
def _room_kwh(p: Pack, pct: float) -> float:
@@ -232,8 +238,12 @@ def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now,
ranges[p.name] = (lo, hi)
if charging:
ask = -sum(abs(p.rating_w) for p in ch)
return Tick(ranges, roles, CHARGE, ask, None)
# OPT-20: a probing refuser is asked PROBE_W (its own pin holds it
# there); only the batteries that charge without a probe are asked
# for their rating.
probe_w = PROBE_W * sum(1 for p in ch if p.refused < 0)
ask = -sum(abs(p.rating_w) for p in ch if p.refused >= 0) - probe_w
return Tick(ranges, roles, CHARGE, ask, None, probe_w)
if selling:
ask = sum(abs(p.rating_w) for p in se) + max(0.0, float(house_w))
return Tick(ranges, roles, SELL, ask, None)
@@ -250,8 +260,20 @@ def size(tick: Tick, bound, headroom_w) -> float:
return 0.0
lo, hi = bound
if tick.action == CHARGE:
cap = min(-float(tick.ask_w), -float(lo),
float(headroom_w) if headroom_w is not None else 0.0)
room = float(headroom_w) if headroom_w is not None else 0.0
pw = float(tick.probe_w)
if pw > 0.0:
# OPT-20: the others' share is sized as without the probe; the
# probe's PROBE_W is added on top (the grid rises by PROBE_W at
# most), and alone it is sized by the bound and the headroom.
rest = max(0.0, min(-float(tick.ask_w) - pw, -float(lo) - pw,
room))
if rest > 0.0:
return -(rest + pw)
# PROBE_W exactly or no trade: less would void the pin and hand
# the remainder to whichever battery the allocator can reach.
return -pw if min(-float(lo), room) >= pw else 0.0
cap = min(-float(tick.ask_w), -float(lo), room)
return -max(0.0, cap)
return max(0.0, min(float(tick.ask_w), float(hi)))