OPT-20: a refusal probe asks only PROBE_W, not the whole trade
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:
co-authored by
Claude
parent
bf9019e70d
commit
06339a2446
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)))
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user