Merge PR #191: OPT-20 a refusal probe asks only PROBE_W, not the whole trade
infra-pin / fresh (push) Successful in 1s
infra-pin / fresh (push) Successful in 1s
Reviewed atccdb9dc: full suite 78/78, CI run 1289 green, identity equal tobf9019e. Review: PR #191 comment 2185. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit was merged in pull request #191.
This commit is contained in:
@@ -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,267 @@
|
|||||||
|
"""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: MUTATION.md says how to run them (purged copies); the 12 of
|
||||||
|
OPT-20 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