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
@@ -132,6 +132,23 @@ P1 is its section 6 P1, and the pull request lists what was measured.
own, and a rise past that is the peak floor's, which ends the hold. own, and a rise past that is the peak floor's, which ends the hold.
A maintenance drain, hold or charge that starts during the hold ends it A maintenance drain, hold or charge that starts during the hold ends it
on that tick (replayed at edge + 3 s). on that tick (replayed at edge + 3 s).
- OPT-20 (TEST-23 OPT-15 g7, `test_opt20.py`): a probe asks only PROBE_W.
The trade was sized at the probing battery's rating (2500 W), B's pin held
200 W of it, and the allocator put the other 2300 W on A, in its
maintenance charge phase (no mode range there, so nothing kept it out):
grid 3100 W for 34 s. Now the ask is PROBE_W for the probing battery plus
what the other chargers would have had without the probe (none: 200 W
alone), and it carries the pin only on the ticks the pin is filed (the
window's first and last cycle moved another battery's share by 200 W).
- Chosen: the probe sits on top of a headroom-sized charge, so beside a
normal charger its command is the same and the grid rises 200 W (about
7 W on the quarter) rather than that charger giving 200 W up. A probe
that the headroom or the bound cannot hold at 200 W is not filed.
- Unchanged, same on bf9019e: with a charge ask the other batteries cannot
give (one sign), so an idle-looking A that gives the house stops for the
window and the grid rises by what it gave. The trade line now says 200 W
for a probe alone; its words are UX-38's. The base P4 F4 path (a normal
trade on a battery in maintenance charge) is not touched.
## P4: the solar forecast and the day plan (2026-09-29) ## P4: the solar forecast and the day plan (2026-09-29)
- Input: a Home Assistant forecast sensor (`pv_forecast_entity`, kWh left - Input: a Home Assistant forecast sensor (`pv_forecast_entity`, kWh left
+13
View File
@@ -2441,6 +2441,19 @@ class Controller:
tick = self.mode_tick tick = self.mode_tick
if tick is None: if tick is None:
return False return False
if tick.action == modetrade.CHARGE:
# OPT-20: the ask carries PROBE_W for exactly the probes whose pin
# is filed this tick. `decide()` ran before the pin was (or the
# window closed since), so the two can differ by one cycle; left
# alone, the allocator takes the pin's watts out of another
# battery's share, or hands them to one it can reach.
pins = _ctl.PROBE_W * sum(
1 for c in claims if c.source == "refused"
and c.kind == "set" and c.value < 0)
if pins != tick.probe_w:
tick = self.mode_tick = dataclasses.replace(
tick, ask_w=tick.ask_w + tick.probe_w - pins,
probe_w=pins)
size = modetrade.size(tick, self.loop_bound(claims) size = modetrade.size(tick, self.loop_bound(claims)
if tick.action else None, self.arb_headroom_w) if tick.action else None, self.arb_headroom_w)
runs = self.mode_runs runs = self.mode_runs
+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, 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 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; 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 * no grid charge while another battery drains for its monthly cycle
(`draining`, SAFETY-78): it would charge against the drain. (`draining`, SAFETY-78): it would charge against the drain.
""" """
from dataclasses import dataclass from dataclasses import dataclass
from .control import PROBE_W
from .packmode import (CHEAP_HOURS, GRID_CHARGE_MODES, LEFTOVER_SOLAR, from .packmode import (CHEAP_HOURS, GRID_CHARGE_MODES, LEFTOVER_SOLAR,
PLAN_CHARGE_MODES, SOLAR_FIRST) PLAN_CHARGE_MODES, SOLAR_FIRST)
@@ -88,6 +93,7 @@ class Tick:
action: str | None # "charge" | "sell" | None action: str | None # "charge" | "sell" | None
ask_w: float # signed site ask before sizing (0 if 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 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: 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) ranges[p.name] = (lo, hi)
if charging: if charging:
ask = -sum(abs(p.rating_w) for p in ch) # OPT-20: a probing refuser is asked PROBE_W (its own pin holds it
return Tick(ranges, roles, CHARGE, ask, None) # 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: if selling:
ask = sum(abs(p.rating_w) for p in se) + max(0.0, float(house_w)) ask = sum(abs(p.rating_w) for p in se) + max(0.0, float(house_w))
return Tick(ranges, roles, SELL, ask, None) return Tick(ranges, roles, SELL, ask, None)
@@ -250,8 +260,20 @@ def size(tick: Tick, bound, headroom_w) -> float:
return 0.0 return 0.0
lo, hi = bound lo, hi = bound
if tick.action == CHARGE: if tick.action == CHARGE:
cap = min(-float(tick.ask_w), -float(lo), room = float(headroom_w) if headroom_w is not None else 0.0
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, cap)
return max(0.0, min(float(tick.ask_w), float(hi))) return max(0.0, min(float(tick.ask_w), float(hi)))
+266
View File
@@ -0,0 +1,266 @@
"""Runnable check for OPT-20. `python3 test_opt20.py`
A refusal probe (OPT-15 F3, SAFETY-73) asks the probing battery PROBE_W and
nothing else. On TEST-23 (OPT-15 g7) the probe made the trade ask 2500 W: B
was pinned at 200 W and the allocator put the other 2300 W on A, which sat in
its maintenance charge phase at 0 W, with the grid at 3100 W for 34 s.
1. modetrade: the ask carries PROBE_W for the probing battery, the other
chargers' ratings as without the probe, and `size()` adds the probe on top
of their share (or asks PROBE_W exactly, or nothing).
2. main: the ask carries PROBE_W for exactly the pins filed this tick, so the
first and the last cycle of a window do not move another battery's share.
3. The real Controller in the lagged replay, with B refusing and probed while
A is (a) in its maintenance charge phase (also with the charge shaped to
0 W), (b) idle, (c) a normal charger in the same run, and the OPT-15 rig's
g7 sequence: A's command is the same through the window as just before it,
B gets PROBE_W, and the commanded charge rises by PROBE_W at most.
4. Battery modes on with no probe: byte-identical to release/1.0 bf9019e.
Out of scope, and unchanged: a normal (non-probing) trade placed on a battery
in maintenance charge (OPT-11 P4 F4) and the action-log wording (UX-38).
Mutations are listed in the pull request.
"""
import dataclasses
import logging
import os
import sys
from types import SimpleNamespace
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "tools"))
logging.disable(logging.WARNING)
import opt20_scen as sc # noqa: E402
from app import arbitrage, modetrade, packmode # noqa: E402
from app import control as ctl # noqa: E402
from app import main # noqa: E402
from app.arbiter import Claim, P_SAFETY # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
PW = ctl.PROBE_W
A, B = sc.A, sc.B
# ============================================================================ #
print("1. modetrade: the ask and the size")
_runs = SimpleNamespace(on=True, cheap=SimpleNamespace(holds=lambda n: True),
charge_useful=lambda: True, give_at=lambda n: None,
sell=None)
def _dec(packs, **kw):
args = dict(charge_ok=True, sell_ok=True, trade_why=None, price_now=0.1,
headroom_w=3000.0, floor_pct=20.0, ceiling_pct=95.0,
house_w=800.0)
args.update(kw)
return modetrade.decide(_runs, sc.UTC0, packs, **args)
_pb = modetrade.Pack(B, True, packmode.CHEAP_HOURS, False, 2500.0, 30.0, 10.0,
refused=-1, probing=True)
_pa = modetrade.Pack(A, True, packmode.CHEAP_HOURS, False, 2500.0, 50.0, 10.0)
_pm = dataclasses.replace(_pa, mode=packmode.SOLAR_FIRST, idle=False)
_pi = dataclasses.replace(_pa, mode=packmode.SOLAR_FIRST)
_t_alone = _dec([_pb, _pm])
check("a probe beside a battery in maintenance asks PROBE_W and no more",
_t_alone.action == modetrade.CHARGE and _t_alone.ask_w == -PW
and _t_alone.probe_w == PW)
check("...beside an idle battery that cannot charge from the grid too",
_dec([_pb, _pi]).ask_w == -PW and _dec([_pb, _pi]).probe_w == PW)
_t_two = _dec([_pb, _pa])
check("...beside a normal charger the ask is that charger's rating plus "
"PROBE_W", _t_two.ask_w == -2500.0 - PW and _t_two.probe_w == PW)
_t_np = _dec([dataclasses.replace(_pb, probing=False), _pa])
check("no probe: the ask is the chargers' ratings, probe_w is 0 (as before)",
_t_np.ask_w == -2500.0 and _t_np.probe_w == 0.0
and _dec([_pa, _pm]).ask_w == -2500.0
and modetrade.Tick({}, {}, None, 0.0, None).probe_w == 0.0)
def sz(ask, probe_w, lo, room, action=modetrade.CHARGE):
t = modetrade.Tick({}, {}, action, ask, None, probe_w)
return modetrade.size(t, (lo, 2500.0), room)
check("size, a probe alone: PROBE_W, whatever the headroom above it",
sz(-PW, PW, -(PW), 3000.0) == -PW and sz(-PW, PW, -2700.0, 9000.0) == -PW)
check("...exactly PROBE_W or nothing: a headroom or a bound under it files "
"no trade (a smaller ask voids the pin and the allocator would place "
"it elsewhere)",
sz(-PW, PW, -2700.0, PW - 1) == 0.0 and sz(-PW, PW, -(PW - 1), 3000.0)
== 0.0 and sz(-PW, PW, -2700.0, 0.0) == 0.0
and sz(-PW, PW, -2700.0, PW) == -PW)
check("size with another charger: its share is sized as without the probe, "
"the probe's PROBE_W is added on top",
sz(-2700.0, PW, -2700.0, 3000.0) == -2700.0
and sz(-2700.0, PW, -2700.0, 1000.0) == -1200.0
and sz(-2700.0, PW, -1700.0, 3000.0) == -1700.0
and sz(-2700.0, PW, -2700.0, 0.0) == 0.0)
check("size with no probe is what it was (ask, bound, headroom)",
sz(-2500.0, 0.0, -2700.0, 3000.0) == -2500.0
and sz(-2500.0, 0.0, -2700.0, 1000.0) == -1000.0
and sz(-2500.0, 0.0, -1800.0, 3000.0) == -1800.0
and sz(-2500.0, 0.0, -2700.0, None) == 0.0
and modetrade.size(modetrade.Tick({}, {}, None, 0.0, None),
(-5.0, 5.0), 3000.0) == 0.0)
# ============================================================================ #
print("\n2. main: the ask carries the pins filed this tick")
def trade(tick, pins, bound=(-2700.0, 2500.0), room=3000.0):
"""The trade `_mode_trade()` files: (claims, size or None)."""
claims = [Claim.set("refused", P_SAFETY, -PW * 1.0, ctl.PROBING,
actuator="x") for _ in range(pins)]
me = SimpleNamespace(
mode_tick=tick, loop_bound=lambda cl: bound, arb_headroom_w=room,
mode_runs=SimpleNamespace(
cheap=SimpleNamespace(start=sc.UTC0, end=sc.UTC0,
mean_eur_kwh=0.1),
sell=None, level_eur_kwh=0.1),
arb=None, _arb_action=None, log_event=lambda *a, **k: None)
filed = main.Controller._mode_trade(me, claims, sc.UTC0)
t = [c for c in claims if c.source == arbitrage.SOURCE]
return filed, (t[0].value if t else None), me.mode_tick
_t0 = modetrade.Tick({}, {A: modetrade.CHARGE}, modetrade.CHARGE, -2500.0,
None, 0.0)
check("the pin is filed but decide() ran before it (the window's first "
"cycle): the charger keeps its whole ask and the pin is added",
trade(_t0, 1)[1] == -2700.0 and trade(_t0, 1)[2].probe_w == PW)
check("the window closed after decide() (its last cycle): the probe's PROBE_W "
"leaves the ask, so no battery is handed it",
trade(_t_two, 0)[1] == -2500.0
and trade(_t_alone, 0) == (False, None, trade(_t_alone, 0)[2])
and trade(_t_alone, 0)[2].action is None)
check("pin and ask agree: nothing changes",
trade(_t_two, 1)[1] == -2700.0 and trade(_t_alone, 1)[1] == -PW
and trade(_t0, 0)[1] == -2500.0)
# ============================================================================ #
print("\n3. the real Controller: B refuses and is probed")
def charge(r):
return -sum(min(0.0, v) for v in r["shares"].values())
def window_checks(rows, label):
"""Per probe window: A as just before, B at PROBE_W (or 0 when nothing is
traded), the charge up by at most PROBE_W."""
win = sc.windows(rows)
ok_a = ok_b = ok_sum = ok_grid = True
for i, j in win:
pre = rows[i - 1]
lo, hi = max(0, i - 3), min(len(rows) - 1, j + 3)
ext = rows[lo:hi + 1]
base_sum = sum(min(0.0, v) for v in pre["shares"].values())
ok_a &= all(abs(r["shares"].get(A, 0.0) - pre["shares"].get(A, 0.0))
< 1e-6 for r in ext)
bs = [r["shares"].get(B, 0.0) for r in ext]
if any(r["action"] for r in rows[i:j + 1]):
ok_b &= set(bs) <= {0.0, -PW} and bs.count(-PW) >= 20
else: # a drain comes first: the window trades nothing
ok_b &= set(bs) == {0.0}
ok_sum &= all(charge(r) <= charge(pre) + PW + 1e-6 for r in ext)
ok_grid &= max(r["grid"] for r in ext) <= pre["grid"] + PW + 50.0
n_ch = sum(1 for r in rows if r["probing"] and r["action"])
print(f" {label}: {len(win)} window(s), {n_ch} probing cycles with a "
f"trade, highest grid "
f"{max(r['grid'] for r in rows):.0f} W")
return win, ok_a, ok_b, ok_sum, ok_grid
SCEN = {
"(a) A in its maintenance charge phase": (
sc.day(sc.SF_CH, a_maint=sc.MAINT_CHARGE), "same"),
"(a') ...with the charge shaped to 0 W": (
sc.day(sc.SF_CH, a_maint=sc.MAINT_CHARGE, shaped=True), "same"),
"(b) A idle at 0 W (Solar first, the sun covers the house)": (
sc.day(sc.SF_CH, house_w=-10.0), "same"),
"(c) A a normal charger in the same run": (sc.day(sc.CH_CH), "same"),
"(g7) the OPT-15 rig: drain, then maintenance charge at 0 W": (
sc.g7(), "same"),
# Not the fix's: A gives the house, and a charge ask means no other
# battery gives (OPT-11 owner answer 3), so A rests, as on bf9019e.
"(b2) A gives 800 W to the house": (sc.day(sc.SF_CH), "rests"),
}
for _name, (_rows, _kind) in SCEN.items():
win, ok_a, ok_b, ok_sum, ok_grid = window_checks(_rows, _name)
check(f"{_name}: a probe happens (non-vacuous)", len(win) >= 1)
if _kind == "same":
check(f"{_name}: A's command is the same through each window, first "
"and last cycle included", ok_a)
check(f"{_name}: the grid rises by PROBE_W at most (plus 50 W of "
"noise)", ok_grid)
else:
check(f"{_name}: A stops giving for the window (the one-sign rule, "
"as on bf9019e), charges nothing and gives back after it",
all(r["shares"].get(A, 0.0) == 0.0 for i, j in win
for r in _rows[i + 1:j]) and all(
_rows[j + 4]["shares"].get(A, 0.0) > 0.0 for i, j in win))
check(f"{_name}: B gets PROBE_W and nothing else", ok_b)
check(f"{_name}: the commanded charge rises by PROBE_W at most", ok_sum)
# The g7 sequence in detail: the probe after the drain.
_g7 = SCEN["(g7) the OPT-15 rig: drain, then maintenance charge at 0 W"][0]
_w7 = sc.windows(_g7)
_after = [w for w in _w7 if _g7[w[0]]["now"] > sc.at(18.3, sc.GUTC0)]
_peak7 = max(r["grid"] for r in _g7 if r["now"] > sc.at(18.3, sc.GUTC0))
print(f" g7: probes after the drain at "
f"{[_g7[w[0]]['now'].astimezone(sc.L).strftime('%H:%M:%S') for w in _after]}"
f", grid after the drain at most {_peak7:.0f} W (TEST-23: 3100 W)")
check("g7: the probe after A's drain asks B for 200 W with A at 0 W "
"(TEST-23: -2300 W on A, the grid at 3100 W)",
_after and all(
abs(r["shares"].get(A, 0.0)) < 1e-6 for i, j in _after
for r in _g7[i:j + 4]) and _peak7 <= 800.0 + PW + 50.0)
check("g7: during the drain no probe trades (a drain comes first)",
all(r["action"] is None for r in _g7
if sc.at(18.0, sc.GUTC0) <= r["now"] < sc.at(18.28, sc.GUTC0)))
_acts = [r["reason"] for i, j in _after for r in _g7[i:j + 1]
if r["action"]]
check("g7: the action log's trade line names the probe's watts, 200 W",
_acts and all(f"{PW:.0f} W on {B}" in a for a in _acts))
check("no opposite-direction split on any of these cycles",
not [r for _r, _ in SCEN.values() for r in _r
if any(v > 0 for k, v in r["shares"].items() if r["action"])
and any(v < 0 for v in r["shares"].values())])
# ============================================================================ #
print("\n4. battery modes on, no probe: byte-identical to release/1.0 bf9019e")
# Printed by tools/opt20_scen.py on release/1.0 bf9019e (2026-10-15),
# goodwe-addon-checks:1.
AT_BASE = {
"sf_ch": "422d6578a7decf1ae3749edd1898ff3cd0541d904c95f636cdd9b1de8c1a07c9",
"ch_ch": "15fc25dd34186e56d34b752d37c9e8613295ba737938c92454acca08c83080fb",
"ch_ch_maint":
"e46e3c666dd63ab33b56e83c0a560c07cec7774a9a70560f55c62a6ae6f55920",
}
_got = sc.no_probe_digests()
for _k in AT_BASE:
print(f" {_k} {'same' if _got[_k] == AT_BASE[_k] else 'DIFFERS'} "
f"{_got[_k][:16]}")
check("with no refusal every share, grid, trade sentence, role and plant "
"power of a scripted cheap run equals bf9019e's (A Solar first, A "
"charging, A in maintenance charge)", _got == AT_BASE)
print()
if fails:
print(f"{len(fails)} FAILED:")
for f in fails:
print(f" - {f}")
sys.exit(1)
print("all checks passed")
+160
View File
@@ -0,0 +1,160 @@
"""OPT-20: the refusal-probe scenarios, on the REAL Controller in the lagged
replay (tools/opt11p3_replay.py). Uses only the API bf9019e has, so the same
file runs on both trees (test_opt20.py pins the digests printed on bf9019e).
Battery B is Cheap hours and refuses to charge; it is probed every 15 minutes
inside the cheap run (OPT-15). A is, per scenario, in its maintenance charge
phase, idle (Solar first), or a normal charger in the same run.
"""
import hashlib
import logging
import os
import sys
from datetime import datetime, timedelta, timezone
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.dirname(HERE))
logging.disable(logging.WARNING)
import opt11p3_replay as rp # noqa: E402
from app import packmode # noqa: E402
from app.maintenance import CHARGE, DRAIN, MaintResult # noqa: E402
L = timezone(timedelta(hours=2))
A, B = "inverter-a", "inverter-b"
LAG = rp.Lag(tau_s=0.05, ramp_w_s=1e9)
SF_CH = {"a": (True, packmode.SOLAR_FIRST, False),
"b": (True, packmode.CHEAP_HOURS, False)}
CH_CH = {"a": (True, packmode.CHEAP_HOURS, False),
"b": (True, packmode.CHEAP_HOURS, False)}
# A day of the owner's kind: cheap 02:00-04:00 local, flat otherwise.
D0 = datetime(2026, 9, 9, 0, 0, tzinfo=L)
UTC0 = D0.astimezone(timezone.utc)
_PR = [0.30] * 192
for _i in range(8, 16):
_PR[_i] = 0.10
WPTS = [(UTC0 + timedelta(minutes=15 * i), 900, p) for i, p in enumerate(_PR)]
# OPT-15 rig g7: prices 0 from 17:45 to 18:45 local, flat 0.06 otherwise.
G0 = datetime(2026, 10, 13, 0, 0, tzinfo=L)
GUTC0 = G0.astimezone(timezone.utc)
_GP = [0.06] * 192
for _i in range(71, 75):
_GP[_i] = 0.0
for _i in range(76, 78):
_GP[_i] = 0.5
G7PTS = [(GUTC0 + timedelta(minutes=15 * i), 900, p)
for i, p in enumerate(_GP)]
def at(h, base=UTC0):
return base + timedelta(hours=h)
def _rows(rows):
def cb(c, h, now, pr):
sh = dict(c.allocation.shares) if c.allocation is not None \
and c.allocation.shares else {}
rows.append(dict(
now=now, grid=h.grid(), shares=sh,
action=getattr(c.arb, "action", None),
reason=getattr(c.arb, "reason", None) or "",
probing=c.inverters[1].probe_from is not None,
roles=dict(c.mode_tick.roles) if c.mode_tick else {},
p={u: p.p for u, p in h.plants.items()}))
return cb
def day(choice, *, a_maint=None, shaped=False, refuse=("b",), hours=0.5,
house_w=800.0):
"""02:00..: a cheap run, `house_w` house. `a_maint` is A's forced maintenance
result from 5 minutes in (B's refusal stands by then); `shaped` closes the
maintenance charge's own watts (charge-only shaping, hi = 0)."""
rows = []
def patch(c):
if shaped:
c._charge_headroom = lambda floor, claims: 0.0
if a_maint is None:
return
m, real = c.maints[0], c.maints[0].tick
def tick(now, soc, blocked_by=None, gate_open=True):
if now >= at(1.9 + 5 / 60):
return a_maint
return real(now, soc, blocked_by=blocked_by, gate_open=gate_open)
m.tick = tick
rp.run("p3_ch_sell", [(UTC0.timestamp() - 10, house_w)], WPTS, at(1.9),
3600 * hours, check=_rows(rows), lag=LAG, n=2,
soc0={"a": 50.0, "b": 30.0}, choice=choice, refuse=refuse,
patch=patch, extra={"cycle_cost_threshold_eur": 0.10})
return rows
def g7():
"""The OPT-15 rig's g7: B refuses from the start of the cheap run, A
drains 18:00 to 18:17:04 and then sits in its maintenance charge phase
(charge-only) at 0 W. The probe after the drain used to put the trade's
2500 W on A."""
rows = []
t_end = at(18 + 17 / 60 + 4 / 3600, GUTC0)
def patch(c):
m, real = c.maints[0], c.maints[0].tick
def tick(now, soc, blocked_by=None, gate_open=True):
if at(18.0, GUTC0) <= now < t_end:
return MaintResult(DRAIN, True, 2000.0, False, [])
if now >= t_end:
return MaintResult(CHARGE, False, None, True, [])
return real(now, soc, blocked_by=blocked_by, gate_open=gate_open)
m.tick = tick
rp.run("p3_ch_sell", [(GUTC0.timestamp() - 10, 800.0)], G7PTS,
at(17.6, GUTC0), 3600 * 1.1, check=_rows(rows), lag=LAG, n=2,
soc0={"a": 17.0, "b": 73.0}, choice=SF_CH, refuse=("b",),
patch=patch, extra={"cycle_cost_threshold_eur": 0.10})
return rows
MAINT_CHARGE = MaintResult(CHARGE, False, None, True, [])
def windows(rows):
"""[(first, last)] row indexes of each probe window."""
out, i = [], 0
while i < len(rows):
if rows[i]["probing"]:
j = i
while j + 1 < len(rows) and rows[j + 1]["probing"]:
j += 1
out.append((i, j))
i = j + 1
else:
i += 1
return out
def digest(rows) -> str:
keep = [(r["now"].isoformat(), sorted(r["shares"].items()),
round(r["grid"], 3), r["action"], r["reason"],
sorted(r["roles"].items()), sorted(r["p"].items()))
for r in rows]
return hashlib.sha256(repr(keep).encode()).hexdigest()
def no_probe_digests() -> dict:
"""Battery modes on, no refusal, so no probe: a scripted cheap run."""
return {
"sf_ch": digest(day(SF_CH, refuse=(), hours=1.2)),
"ch_ch": digest(day(CH_CH, refuse=(), hours=1.2)),
"ch_ch_maint": digest(day(CH_CH, a_maint=MAINT_CHARGE, refuse=(),
hours=1.2)),
}
if __name__ == "__main__":
for k, v in no_probe_digests().items():
print(k, v)