"""Runnable check for OPT-11 P3, price runs per battery. `python3 test_modes_p3.py` Drives the pure modules (priceruns, modetrade) and the REAL Controller (tools/opt11p3_replay.py: read_inputs + cycle on a P1 meter, the arbiter, the allocator and the peak guard, with two lagged model batteries), in simulated time. DECISIONS/OPT-11-battery-modes.md has the record. 1. With battery modes off, every command and trade is byte-identical to release/1.0 7a00f12 on the trade path P3 changed (digests measured there), and the harness would see a change there. 2. Price runs: consecutive, firm, sized by energy; give runs clear the spread; the sell run sits in a give run with no slot below 0; a started run is kept. 3. Roles: a fuzz of modetrade.decide() and size() against the invariants. 4. The real controller over a day: no opposite-direction split, no grid charge on Solar first, the quarter never above the aim while charging, no sell below the floor or the spread, holding outside the expensive hours. 5. A refusal wins over a run. 6. No prices or too small a gap: the price modes follow the house; Solar first alone never trades. 7. A run edge reseeds the loop (handover measured with and without). 8. The site Why row: a limit that pushes power up does not "hold it back" (F2). 9. Selling is off (owner, 2026-09-29): `pack_sell` refused, read as false. 10. The Batteries screen: Why and Today lines from the roles and runs. 11. Review additions: modes on ignore the site trade switch, a battery in maintenance takes no part, the headroom stop latches for the period. 12. Option A: no sale in a day; Leftover solar never grid-charges (P4). Mutations (MUTATION.md, purged copies, graded over this file and the named tests) are listed in the pull request. """ import asyncio import logging import os import random 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.join(HERE, "tools")) logging.disable(logging.WARNING) from named_absence import nth, present # noqa: E402 import opt11p3_identity as ident # noqa: E402 import opt11p3_replay as rp # noqa: E402 from app import homeview, messages, modetrade, packmode, priceruns # noqa from app import arbitrage # noqa: E402 from app.arbiter import Claim, P_ARBITRAGE, P_LOOP, P_PEAK, Resolution # noqa from app.main import Controller, why_raised # noqa: E402 from app.price import PricePoint, brussels # noqa: E402 from app.store import unit_key # noqa: E402 fails = [] def check(name, cond): print(f" {'ok ' if cond else 'FAIL'} {name}") if not cond: fails.append(name) L = timezone(timedelta(hours=2)) A, B = "inverter-a", "inverter-b" # ============================================================================ # print("1. modes off: byte-identical to 7a00f12 on the trade path") # Printed by tools/opt11p3_identity.py on release/1.0 7a00f12 (2026-09-29), # goodwe-addon-checks:1. 1080 cycles each; the trade CHARGEs and DISCHARGEs. AT_7A00F12 = { "n1": "71245b227ca40bada4f71f57ea6154475f4b0bd60239c9ad4f80bffb0094514c", "n2": "56cf28010b06765c29e5479abb815d7503d570b39240d82dfc818efa90c93722", "n1_off": "71245b227ca40bada4f71f57ea6154475f4b0bd60239c9ad4f80bffb0094514c", "n2_off": "56cf28010b06765c29e5479abb815d7503d570b39240d82dfc818efa90c93722", } _rows = {k: ident.run(**ident.SCENARIOS[k])[0] for k in AT_7A00F12} _got = {k: ident.digest(v) for k, v in _rows.items()} for k in AT_7A00F12: print(f" {k} {'same' if _got[k] == AT_7A00F12[k] else 'DIFFERS'} " f"{_got[k][:16]}") check("with battery modes off (option absent or false), every command, " "integrator, allocation and trade sentence equals 7a00f12's, at one " "inverter and at two", _got == AT_7A00F12) _acts = {r[-1][0] for r in _rows["n2"] if r[-1]} check("...and the runs reach the trade P3 now skips with modes on: a charge " "and a sale", {arbitrage.CHARGE, arbitrage.DISCHARGE} <= _acts) def _moved(scn, patch): """Does `patch` (applied before each cycle) move scenario `scn`?""" real = ident._feed def feed(c, t): patch(c) real(c, t) ident._feed = feed try: return ident.digest(ident.run(**ident.SCENARIOS[scn])[0]) \ != AT_7A00F12[scn] finally: ident._feed = real def _as_if_trade_ended(c): c._mode_trade_was = True # the run-edge reseed, leaking to modes off def _moved_headroom(scn): """Does an unmeasured headroom (the expression P3 hoisted) move it?""" from app import peakguard real = peakguard.charge_headroom_w peakguard.charge_headroom_w = lambda g: None try: return ident.digest(ident.run(**ident.SCENARIOS[scn])[0]) \ != AT_7A00F12[scn] finally: peakguard.charge_headroom_w = real check("non-vacuous: a run-edge reseed leaking into modes off moves n2, and so " "does the headroom P3 hoisted, if it went unmeasured", _moved("n2", _as_if_trade_ended) and _moved_headroom("n1")) # ============================================================================ # print("\n2. price runs") D0 = datetime(2026, 9, 9, 0, 0, tzinfo=L) def pts(prices, start=D0, minutes=15, provisional=()): return [PricePoint(start=brussels(start + timedelta(minutes=minutes * i)), eur_per_kwh=p, provisional=i in provisional) for i, p in enumerate(prices)] # 24 h of quarters: cheap 02:00-04:00 (and one cheaper single quarter at # 05:00), dear 18:00-20:00 with a peak quarter at 19:00, flat otherwise. DAY = [0.30] * 96 for i in range(8, 16): DAY[i] = 0.10 DAY[20] = 0.05 for i in range(72, 80): DAY[i] = 0.50 DAY[76] = 0.70 P = pts(DAY) NOW = D0 + timedelta(minutes=5) r1 = priceruns.plan(P, NOW, required_eur_kwh=0.25, charge_kwh=5.0, charge_kw=2.5, sell_kwh=0.0, sell_kw=0.0) r2 = priceruns.plan(P, NOW, required_eur_kwh=0.25, charge_kwh=0.0, charge_kw=0.0, sell_kwh=2.5, sell_kw=2.5) print(f" 2 h need: {r1.cheap}; give {r1.give}") check("the cheap run lasts what the energy needs at the rating (5 kWh at " "2.5 kW = 2 h) and is the cheapest such consecutive window, not the " "cheapest single quarter", r1.cheap.start == D0 + timedelta(hours=2) and r1.cheap.end == D0 + timedelta(hours=4) and abs(r1.cheap.mean_eur_kwh - 0.10) < 1e-9) check("...with nothing to charge, one quarter: the cheapest one", r2.cheap.start == D0 + timedelta(hours=5) and r2.cheap.end == D0 + timedelta(hours=5, minutes=15)) check("give runs are the consecutive quarters at least the required spread " "above the cheap run (0.10 + 0.25): 18:00-20:00, one run", r1.on and len(r1.give) == 1 and r1.give[0].start == D0 + timedelta(hours=18) and r1.give[0].end == D0 + timedelta(hours=20)) check("the sell run is the dearest window inside a give run, sized by the " "sellers' energy (2.5 kWh at 2.5 kW = 1 h)", r2.sell.end - r2.sell.start == timedelta(hours=1) and r1.give[0].start <= r2.sell.start < r2.sell.end <= r1.give[0].end and r2.sell.start <= D0 + timedelta(hours=19) < r2.sell.end) _flat = priceruns.plan(pts([0.3] * 96), NOW, required_eur_kwh=0.1, charge_kwh=5, charge_kw=2.5, sell_kwh=0, sell_kw=0) _thr0 = priceruns.plan(pts([0.3] * 96), NOW, required_eur_kwh=0.0, charge_kwh=5, charge_kw=2.5, sell_kwh=0, sell_kw=0) check("a flat day has no plan (`not_worth`), even with a threshold of 0: the " "cheap run never counts as its own expensive run", _flat.reason == priceruns.NOT_WORTH and not _flat.give and _thr0.reason == priceruns.NOT_WORTH) _prov = priceruns.plan(pts(DAY, provisional=set(range(74, 96))), NOW, required_eur_kwh=0.25, charge_kwh=5, charge_kw=2.5, sell_kwh=2.5, sell_kw=2.5) check("a provisional (carried) price is never part of a run (section 8.3)", all(g.end <= D0 + timedelta(hours=18, minutes=30) for g in _prov.give) and (_prov.sell is None or _prov.sell.end <= D0 + timedelta(hours=18, minutes=30))) _neg = list(DAY) _neg[76] = -0.05 _rn = priceruns.plan(pts(_neg), NOW, required_eur_kwh=0.25, charge_kwh=0, charge_kw=0, sell_kwh=10.0, sell_kw=2.5) check("the sell run never spans a quarter priced below 0 (section 17)", _rn.sell is not None and not (_rn.sell.start <= D0 + timedelta( hours=19) < _rn.sell.end)) # All prices below 0 but one: the give run holds negative quarters (they # clear the spread over an even lower cheap run), and the sale must skip them. _allneg = [-0.30] * 96 for i in range(8, 16): _allneg[i] = -0.40 for i in range(72, 80): _allneg[i] = -0.05 _allneg[76] = 0.10 _rneg = priceruns.plan(pts(_allneg), NOW, required_eur_kwh=0.15, charge_kwh=0, charge_kw=0, sell_kwh=2.5, sell_kw=2.5) check("...even when the whole give run is below 0 but one quarter: the sale " "is that quarter only", _rneg.on and _rneg.sell is not None and _rneg.sell.start == D0 + timedelta(hours=19) and _rneg.sell.end == D0 + timedelta(hours=19, minutes=15)) # A spike inside the cheap run clears the spread over the run's own mean: # it must not also be a give run (charge and give would overlap). _sp = [0.50] * 96 for i in range(8, 16): _sp[i] = 0.02 _sp[15] = 0.20 _rsp = priceruns.plan(pts(_sp), NOW, required_eur_kwh=0.05, charge_kwh=5.0, charge_kw=2.5, sell_kwh=0, sell_kw=0) check("no give run overlaps the cheap run, even a quarter inside it that " "clears the spread over the run's mean", _rsp.cheap.start == D0 + timedelta(hours=2) and all(g.end <= _rsp.cheap.start or g.start >= _rsp.cheap.end for g in _rsp.give)) _few = priceruns.plan(pts([0.1]), NOW, required_eur_kwh=0.1, charge_kwh=1, charge_kw=1, sell_kwh=0, sell_kw=0) check("fewer than two firm slots: no plan (`no_prices`)", _few.reason == priceruns.NO_PRICES) _mid = D0 + timedelta(hours=3) _new = priceruns.plan(P, _mid, required_eur_kwh=0.25, charge_kwh=0.0, charge_kw=0.0, sell_kwh=0.0, sell_kw=0.0) _kept = priceruns.keep_started(r1, _new, _mid) check("a run under way is kept to its end when a new plan would move it " "(no flapping), and a run not yet started is not", _new.cheap != r1.cheap and _kept.cheap == r1.cheap and priceruns.keep_started(r1, _new, NOW).cheap == _new.cheap) _late = priceruns.plan(P, D0 + timedelta(hours=20, minutes=5), required_eur_kwh=0.25, charge_kwh=5, charge_kw=2.5, sell_kwh=0, sell_kw=0) check("a run that has ended is not planned again, and nothing beyond the " "firm prices is invented", all(g.start >= D0 + timedelta(hours=20) for g in _late.give) and not _late.on) # ============================================================================ # print("\n3. roles: modetrade.decide() fuzz") rnd = random.Random(311) _bad = [] _seen = set() for _ in range(20000): n = rnd.choice((1, 2, 3)) packs = [] for k in range(n): packs.append(modetrade.Pack( f"inv-{k}", rnd.random() > 0.15, rnd.choice(packmode.MODES), rnd.random() > 0.5, rnd.choice((1000.0, 2500.0, 5000.0)), rnd.choice((None, 5.0, 19.0, 20.0, 21.0, 60.0, 95.0, 99.0)), rnd.choice((None, 5.0, 10.0)), live=rnd.random() > 0.1, idle=rnd.random() > 0.1, refused=rnd.choice((0, 0, 0, 1, -1)))) t = D0 + timedelta(minutes=rnd.randrange(0, 24 * 60)) runs = rnd.choice((None, r1, r2, _flat)) ok_s = rnd.random() > 0.3 ok_c = ok_s and rnd.random() > 0.3 hr = rnd.choice((None, 0.0, 100.0, 499.0, 501.0, 3000.0)) price = rnd.choice((None, -0.1, 0.0, 0.5)) house = rnd.choice((None, -500.0, 800.0)) was = rnd.random() > 0.7 blocked = rnd.random() > 0.8 tk = modetrade.decide(runs, t, packs, charge_ok=ok_c, sell_ok=ok_s, trade_why="x", price_now=price, headroom_w=hr, floor_pct=20.0, ceiling_pct=95.0, house_w=house, charging_now=was, blocked=blocked) _seen.add(tk.action) by = {p.name: p for p in packs} for name, (lo, hi) in tk.ranges.items(): if not lo <= 0.0 <= hi: _bad.append(("range without 0", name, lo, hi)) for name, role in tk.roles.items(): p = by[name] if role == modetrade.CHARGE and (p.mode != packmode.CHEAP_HOURS or p.refused < 0 or not p.use or p.soc is None or p.soc >= 95.0): _bad.append(("bad charger", p)) if role == modetrade.SELL and (not p.sell or p.refused > 0 or p.mode == packmode.SOLAR_FIRST or p.soc is None or p.soc <= 20.0): _bad.append(("bad seller", p)) if not p.use and tk.ranges.get(name, (0.0, 0.0)) != (0.0, 0.0): _bad.append(("off not 0", p)) if tk.action == modetrade.CHARGE: need = 0.0 if was else modetrade.START_W if not (ok_c and hr is not None and hr > need and not blocked and runs is not None and runs.cheap.holds(t)): _bad.append(("charge without leave", ok_c, hr, blocked)) for name, (lo, hi) in tk.ranges.items(): if tk.roles[name] != modetrade.CHARGE and lo < 0.0: _bad.append(("grid charge on a non-charger", by[name])) s = modetrade.size(tk, (-7000.0, 7000.0), hr) if not (s < 0 and -s <= min(-tk.ask_w, hr) + 1e-9): _bad.append(("charge size", s, tk.ask_w, hr)) if modetrade.size(tk, (-300.0, 300.0), hr) < -300.0: _bad.append(("charge over the bound",)) if tk.action == modetrade.SELL: if not (ok_s and price is not None and price >= 0.0 and runs is not None and runs.sell.holds(t) and house is not None): _bad.append(("sell without leave", ok_s, price)) s = modetrade.size(tk, (-7000.0, 2000.0), hr) if not 0.0 < s <= 2000.0: _bad.append(("sell size", s)) if tk.action is None and modetrade.size(tk, (-1e4, 1e4), hr) != 0.0: _bad.append(("no action but a size",)) print(f" actions seen {sorted(map(str, _seen))}; {len(_bad)} bad: {_bad[:3]}") check("20000 random ticks: every range holds 0 W; Off is [0, 0]; no Solar " "first, refusing, full or unknown battery charges; no Solar first, " "refusing or at-floor battery sells; while one charges no other may " "charge; a charge needs the guard, the headroom (500 W to start) and " "no stop this period, and is sized under the headroom; a sale needs a " "price >= 0 in the sell run", not _bad and {modetrade.CHARGE, modetrade.SELL, None} <= _seen) # ============================================================================ # print("\n4. the real controller over a day (A Solar first, B Cheap hours + sell)") # Wholesale; all-in (x + 0.12) x 1.06: cheap 0.148, flat 0.339, dear 0.551. # With a cycle cost of 0.20 (0.30 widened) only 19:00-20:00 clears it. DAYW = [0.20] * 96 * 2 for i in list(range(4, 12)) + list(range(100, 108)): DAYW[i] = 0.02 # 01:00-03:00 wholesale for i in list(range(76, 80)) + list(range(172, 176)): DAYW[i] = 0.40 # 19:00-20:00 UTC0 = D0.astimezone(timezone.utc) WPTS = [(UTC0 + timedelta(minutes=15 * i), 900, p) for i, p in enumerate(DAYW)] def house_flat(w): return [(UTC0.timestamp() - 10, float(w))] AIM = rp.PEAK_CAP - 50 def run_day(variant, *, house_w=2600.0, start_h=0.75, hours=21.0, soc0=None, choice=None, refuse=(), n=2, check_cb=None, patch=None, house=None, limits=None): 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), winner=c.reason.split(" ->")[0], roles=dict(c.mode_tick.roles) if c.mode_tick else {}, soc={u: p.soc for u, p in h.plants.items()}, p={u: p.p for u, p in h.plants.items()}, runs=c.mode_runs, price=pr, why=c.why_words, unmet=getattr(c.allocation, "unmet_w", 0) or 0)) if check_cb: check_cb(c, h, now, pr) st, c = rp.run(variant, house or house_flat(house_w), WPTS, UTC0 + timedelta(hours=start_h), 3600 * hours, check=cb, soc0=soc0, choice=choice, refuse=refuse, n=n, patch=patch, extra={"cycle_cost_threshold_eur": 0.20}, limits=limits) return rows, st, c # Selling is off (owner, 2026-09-29, option A). Its code is kept behind # packmode.SELLING_ENABLED, and these checks turn it on to hold that code to # its invariants; part 12 checks it is unreachable with the flag as shipped. _SELL_SHIPPED = packmode.SELLING_ENABLED packmode.SELLING_ENABLED = True ROWS, ST, _c4 = run_day("p3_ch_sell", soc0={"a": 60.0, "b": 30.0}) charge = [r for r in ROWS if r["action"] == arbitrage.CHARGE] sell = [r for r in ROWS if r["action"] == arbitrage.DISCHARGE] print(f" {len(ROWS)} cycles, {len(charge)} charging, {len(sell)} selling; " f"{rp.summary(ST)}") check("B charged from the grid in the cheap run and sold in the dear one, " "and A never took part in either", charge and sell and all(r["roles"][A] in (modetrade.REST, modetrade.HOUSE) for r in charge + sell) and all(r["now"].astimezone(L).hour in (0, 1, 2, 3) for r in charge) and all(r["runs"].cheap.holds(r["now"]) for r in charge) and all(r["now"].astimezone(L).hour == 19 for r in sell)) _mixed = [r for r in ROWS if any(v > 0 for v in r["shares"].values()) and any(v < 0 for v in r["shares"].values())] check("no opposite-direction split on any cycle (every share one sign)", not _mixed) check("no grid charge on Solar first: while the site charges from the grid, " "A's share is never below 0 W", all(r["shares"].get(A, 0) >= 0 for r in charge)) # The quarters the add-on itself banked (the P1 blocks, as the bill reads). _chq = {int(r["now"].timestamp() // 900) for r in charge} _qc = [b.offtake_avg_w for b in _c4.p1.blocks if int(b.start.timestamp() // 900) in _chq] print(f" banked quarters with a grid charge: {len(_qc)}, highest " f"{max(_qc):.2f} W, aim {AIM} W") check("never above the SAFETY-79 aim while grid charging: every banked " "15-minute period with a grid charge is at most cap - margin", _qc and max(_qc) <= AIM + 0.5) check("...and the charge is sized to the headroom, not the rating: under a " "2.6 kW house B charges well under its 2.5 kW", max(-r["shares"].get(B, 0) for r in charge) < 1200) _sold = [r for r in sell] check("no sale below the floor or the spread: every sale cycle has B above " "arbitrage_soc_floor (20 %) and a price at least cheap + required", all(r["soc"]["b"] > 19.9 for r in _sold) and all(r["price"] is not None and rp.all_in(r["price"]) >= r["runs"].cheap.mean_eur_kwh + 0.30 - 1e-9 for r in _sold)) _hold = [r for r in ROWS if r["roles"].get(B) == modetrade.HOLD] check("outside the expensive hours B holds its power: never gives while " "holding", _hold and all(r["shares"].get(B, 0) <= 0 for r in _hold)) _give = [r for r in ROWS if r["roles"].get(B) in (modetrade.GIVE, modetrade.SELL)] check("...and gives only in the expensive hours (19:00-20:00)", all(r["now"].astimezone(L).hour == 19 for r in _give)) def starts(rows, kind): return sum(1 for a, b in zip(rows, rows[1:]) if a["action"] != kind and b["action"] == kind) check("the sale starts once and runs to its end: no on-off while the two " "batteries' 5 kW discharge makes the peak guard read 'engaged'", starts(ROWS, arbitrage.DISCHARGE) == 1) # B's inverter can only take 1000 W: the trade asks for that, not its rating. ROWS_U, _, _ = run_day("p3_ch_sell", hours=1.0, house_w=800.0, soc0={"a": 60.0, "b": 30.0}, limits={"b": (-1000.0, 1000.0)}) _cu = [r for r in ROWS_U if r["action"] == arbitrage.CHARGE] print(f" B limited to 1 kW: charge {min(r['shares'].get(B, 0) for r in _cu)}" f" W, unmet {max(abs(r['unmet']) for r in _cu)} W") check("SAFETY-45 sizing inside the modes: the charge is sized to what the " "batteries may place, so none of it is left unmet (P1's gap)", _cu and all(abs(r["unmet"]) < 1 for r in _cu) and min(r["shares"].get(B, 0) for r in _cu) >= -1000) # A house that swings over the limit every minute during the cheap run. _sw = [(UTC0.timestamp() + 60 * k, 2000.0 if k % 2 else 3900.0) for k in range(0, 200)] ROWS_W, _, _ = run_day("p3_ch_sell", hours=1.5, house=_sw, soc0={"a": 60.0, "b": 30.0}) _per_q = {} for a_, b_ in zip(ROWS_W, ROWS_W[1:]): if a_["action"] != arbitrage.CHARGE and b_["action"] == arbitrage.CHARGE: q = int(b_["now"].timestamp() // 900) _per_q[q] = _per_q.get(q, 0) + 1 print(f" house swinging 2.0/3.9 kW each minute: charge starts per period " f"{sorted(_per_q.values())}") check("a charge the peak guard or the headroom stopped stays off for the " "rest of that 15-minute period (at most one start per period)", _per_q and max(_per_q.values()) == 1) # At the floor: B starts the sale just above it. ROWS_F, _, _ = run_day("p3_ch_sell", start_h=18.75, hours=1.5, soc0={"a": 60.0, "b": 24.0}) _sf = [r for r in ROWS_F if r["action"] == arbitrage.DISCHARGE] _after = [r for r in ROWS_F if r["now"].astimezone(L).hour == 19 and r["soc"]["b"] <= 20.0] _lastf = nth(ROWS_F, -1, "the last cycle") print(f" from 24 %: sold {len(_sf)} cycles, B ends at " f"{_lastf['soc']['b'] if present(_lastf) else _lastf} %") check("the sale stops at arbitrage_soc_floor and does not re-arm in the run", _sf and _after and all(r["action"] != arbitrage.DISCHARGE for r in _after)) packmode.SELLING_ENABLED = _SELL_SHIPPED # N=1: one Cheap hours battery. ROWS1, _, _ = run_day("p3_ch_sell", n=1, soc0={"a": 30.0}, choice={"a": (True, "cheap_hours", False)}, hours=3.0) _c1 = [r for r in ROWS1 if r["action"] == arbitrage.CHARGE] check("at one inverter the same rules: it charges in the cheap run, sized " "under the aim", _c1 and max(r["grid"] for r in _c1) < AIM + 400) # ============================================================================ # print("\n5. a refusal wins over a run") ROWS_R, _, _ = run_day("p3_ch_sell", hours=1.0, soc0={"a": 60.0, "b": 30.0}, refuse=("b",)) _first = nth(ROWS_R, 0, "the first cycle") _ref = [r for r in ROWS_R if present(_first) and r["now"] > _first["now"] + timedelta(minutes=3)] print(f" B refuses to charge; after 3 min: trades " f"{sorted({str(r['action']) for r in _ref})}, B roles " f"{sorted({r['roles'].get(B) for r in _ref})}") # OPT-15: B's SAFETY-73 probe (every 15 min, 34 s) is the one grid trade # left: PROBE_W on B alone, A resting. _prb = [r for r in _ref if r["action"] is not None] check("B refusing to charge: no grid trade is filed after the refusal is " "learned but B's probe (PROBE_W on B, A resting), B charges only then, " "and A covers the house", _ref and all(r["shares"].get(B) == -200 and r["shares"].get(A, 0) == 0 for r in _prb) and len(_prb) <= 0.1 * len(_ref) and all(r["roles"].get(B) != modetrade.CHARGE for r in _ref if r["action"] is None) and sum(r["shares"].get(A, 0) > 0 for r in _ref) > len(_ref) / 2) # ============================================================================ # print("\n6. no prices, no gap, Solar first alone") def _no_plan(c): c.tariff_plan = None c.fresh_tariff_plan = lambda now: None ROWS_N, _, _ = run_day("p3_ch_sell", hours=1.0, patch=_no_plan, soc0={"a": 60.0, "b": 60.0}, house_w=1500.0) check("no prices: no trade, and Leftover solar / Cheap hours follow the " "house (they give power, not held forever)", all(r["action"] is None for r in ROWS_N) and all(r["roles"].get(B) == modetrade.HOUSE for r in ROWS_N) and any(r["shares"].get(B, 0) > 0 for r in ROWS_N)) ROWS_S, _, _ = run_day("p3_ch_sell", hours=21.0, choice={"a": (True, "solar_first", False), "b": (True, "solar_first", True)}) check("both batteries Solar first: never a grid charge or a sale all day, " "even with selling switched on", all(r["action"] is None for r in ROWS_S)) # ============================================================================ # print("\n7. a run edge reseeds the loop") def _no_reseed(c): c._reseed_run_edge = lambda claims, now: None def edge(rows, kind): for i in range(1, len(rows)): if rows[i - 1]["action"] == kind and rows[i]["action"] != kind: w = [r["grid"] for r in rows[i:i + 36]] return min(w), max(w) return None _e_on = edge(run_day("p3_ch_sell", hours=3.0, house_w=800.0, soc0={"a": 60.0, "b": 70.0})[0], arbitrage.CHARGE) _e_off = edge(run_day("p3_ch_sell", hours=3.0, house_w=800.0, soc0={"a": 60.0, "b": 70.0}, patch=_no_reseed)[0], arbitrage.CHARGE) print(f" grid in the 3 min after a 2.5 kW charge ends, 800 W house: " f"reseeded {_e_on}, not reseeded {_e_off}") check("reseeded, the loop takes over without the import spike the trade's " "watts leave behind (and exports no more than without)", _e_on and _e_off and _e_on[1] < 1000 and _e_off[1] > 2000 and _e_on[0] > _e_off[0] - 100) # ============================================================================ # print("\n8. the site Why row (OPT-11 P1 test, F2)") _cl = [Claim.set("loop", P_LOOP, 1200.0), Claim.limit("peak", P_PEAK, lo=2500.0)] _up = Resolution(2500.0, "loop", bound_by="peak(quarter peak)") _dn = Resolution(800.0, "loop", bound_by="peak(quarter peak)") check("a peak floor that raised the power reads 'raised for the peak limit', " "never 'held back'", why_raised(_up, _cl) is True and "held back" not in messages.why( "loop", 2500.0, "peak(quarter peak)", raised=True) and "raised for the peak limit" in messages.why( "loop", 2500.0, "peak(quarter peak)", raised=True)) check("...a limit that lowered it still reads 'held back'", why_raised(_dn, _cl) is False and "held back by" in messages.why("loop", 800.0, "pack")) check("...and a trade the peak floor raised reads the same way", why_raised(Resolution(2000.0, "arbitrage", bound_by="peak"), [Claim.set("arbitrage", P_ARBITRAGE, -1000.0)]) is True) # ============================================================================ # print("\n9. the selling switch is off (owner decision 2026-09-29, option A)") _st = rp._Store() _st.data[unit_key(B, packmode.SELL_KEY)] = True # an old stored "on" _st.data[unit_key(B, packmode.MODE_KEY)] = packmode.CHEAP_HOURS _ctl = Controller(dict(rp.options(True, False)), rp.Hass(("a", "b"), lambda t: 0.0), _st, rp._Pub(), stop=asyncio.Event()) _r9 = [asyncio.run(_ctl.handle_action("pack_sell", unit=B, value=v)) for v in (True, False, "yes")] check("pack_sell is refused with the plain reason 'Selling is off for now.' " "and stores nothing", all(x == {"error": messages.pack_refused("sell_off")} for x in _r9) and _st.data[unit_key(B, packmode.SELL_KEY)] is True and not any("selling switched" in str(e) for e in _ctl.events)) check("...a stored 'on' reads as false: no battery sells or can, whatever " "the store holds", packmode.SELLING_ENABLED is False and packmode.read(_st.data, B).sell is False and packmode.read(_st.data, B).can_sell is False and all(p.sell is False for p in _ctl._mode_packs())) # ============================================================================ # print("\n10. the Batteries screen") _keys = set(homeview.ROLE_WHY.values()) | {"saves_later", "no_prices", "no_spread"} _en, _nl = homeview.uitext.words("en"), homeview.uitext.words("nl") check("every role's Why line has words in English and Dutch, and they differ", all(k in _en["home_words"]["why"] and k in _nl["home_words"]["why"] and _en["home_words"]["why"][k] != _nl["home_words"]["why"][k] for k in _keys)) packmode.SELLING_ENABLED = True # the kept sell code, as in part 4 _c10 = run_day("p3_ch_sell", start_h=18.0, hours=0.5, soc0={"a": 60.0, "b": 80.0})[2] _now10 = D0 + timedelta(hours=18, minutes=30) _plan = homeview.mode_runs(_c10, *homeview.local_day(_now10.astimezone( timezone.utc)), _now10.astimezone(timezone.utc), False) packmode.SELLING_ENABLED = _SELL_SHIPPED print(f" plan blocks for B at 18:30: " f"{[(r['from'], r['to'], r['kind'], r['what']) for r in _plan[0]]}") check("the plan draws B's expensive run, the sale inside it, and nothing " "for Solar first A", _plan[0] and {r["battery"] for r in _plan[0]} == {1} and any(r["kind"] == "give" for r in _plan[0])) _sale = [r for r in _plan[0] if r["what"].startswith("Sells")] check("a sale block shows no money: the price shown is what the house pays, " "and a sale earns the lower injection price", _sale and all(r["amount"] is None and r["eur"] is None for r in _sale)) _why = homeview.battery_why( modes_on=True, mode=packmode.CHEAP_HOURS, off=False, reading=True, phase="idle", shadow=False, running=True, held=False, refused=0, peak=False, sf_ahead=True, role="hold", has_at=True) check("a holding battery's Why names when it gives power", _why == "saves" and "18:00 - 19:00" in homeview.why_words( _why, at="18:00 - 19:00")) # ============================================================================ # print("\n11. review additions (PR #153): gaps four mutants survived") import types # noqa: E402 import app.main as _main # noqa: E402 # Modes on with the site switch on: only the modes trade (R13). ROWS_X, _, _ = run_day("p2_arb", hours=3.0, soc0={"a": 30.0, "b": 30.0}, choice={"a": (True, "solar_first", False), "b": (True, "solar_first", False)}) check("modes on, 'Trade on the price spread' on, both batteries Solar first: " "no trade all through the cheap hours (the site trade is not run)", ROWS_X and all(r["action"] is None for r in ROWS_X) and all(v >= 0 for r in ROWS_X for v in r["shares"].values()) and not any(r["winner"].startswith("arbitrage") for r in ROWS_X)) # A battery in a maintenance phase takes no part in a run (R10). ROWS_M, _, _cm = run_day("p3_ch_sell", hours=0.6, house_w=800.0, soc0={"a": 60.0, "b": 30.0}) _lm = nth(ROWS_M, -1, "the last cycle") _nm = _lm["now"] if present(_lm) else None _tm = _cm._mode_roles(_nm, [types.SimpleNamespace(phase=_main.IDLE), types.SimpleNamespace(phase="CHARGE")]) \ if _nm is not None else None check("a battery in its monthly cycle is not a charger: no range, no charge " "role, and no grid charge when it is the only price battery", _tm is not None and B not in _tm.ranges and _tm.roles.get(B) != modetrade.CHARGE and _tm.action is None) # The headroom stopping a charge latches it off for the period (R8). _cm2 = run_day("p3_ch_sell", hours=0.6, house_w=800.0, soc0={"a": 60.0, "b": 30.0})[2] _ok8 = False if _nm is not None and _cm2.mode_tick is not None \ and _cm2.mode_tick.action == modetrade.CHARGE: _q = int(_nm.timestamp() // 900) _cm2.arb_headroom_w = 0.0 _t1 = _cm2._mode_roles(_nm, None) _cm2.arb_headroom_w = 3000.0 _t2 = _cm2._mode_roles(_nm + timedelta(seconds=5), None) _nq = datetime.fromtimestamp((_q + 1) * 900 + 5, timezone.utc) _cm2.tariff_at = _nq _t3 = _cm2._mode_roles(_nq, None) _ok8 = (_t1.why == "headroom" and _cm2._charge_blocked_q in (_q, _q + 1) and _t2.action is None and _t3.action == modetrade.CHARGE) check("a charge the headroom stopped stays off for the rest of the period, " "even when the headroom comes back, and may start in the next one", _ok8) # ============================================================================ # print("\n12. option A: no sale, and Leftover solar takes solar only (P4)") ROWS_A, _, _ = run_day("p3_ch_sell", soc0={"a": 60.0, "b": 30.0}, choice={"a": (True, "solar_first", False), "b": (True, "cheap_hours", True)}) print(f" Cheap hours B with a stored sell 'on': " f"{sum(r['action'] == arbitrage.CHARGE for r in ROWS_A)} charging, " f"{sum(r['action'] == arbitrage.DISCHARGE for r in ROWS_A)} selling") check("with selling on in the store, a whole day has no sale and no sell " "role, while Cheap hours only still charges from the grid", all(r["action"] != arbitrage.DISCHARGE for r in ROWS_A) and all(modetrade.SELL not in r["roles"].values() for r in ROWS_A) and any(r["action"] == arbitrage.CHARGE for r in ROWS_A)) ROWS_L, _, _c12 = run_day("p3_ch_sell", soc0={"a": 60.0, "b": 30.0}, choice={"a": (True, "solar_first", False), "b": (True, "leftover_solar", True)}) _lh = [r for r in ROWS_L if r["roles"].get(B) == modetrade.HOLD] _lg = [r for r in ROWS_L if r["roles"].get(B) == modetrade.GIVE] print(f" Leftover solar B: {len(_lh)} holding, {len(_lg)} giving, " f"grid trades {sorted({str(r['action']) for r in ROWS_L})}") check("Leftover solar never charges from the grid before the forecast: no " "grid trade and no charge role all day, in the same cheap run", all(r["action"] is None for r in ROWS_L) and all(r["roles"].get(B) != modetrade.CHARGE for r in ROWS_L)) check("...it still saves its power and gives it in the expensive hours", _lh and _lg and all(r["now"].astimezone(L).hour == 19 for r in _lg)) _pk = [modetrade.Pack("x", True, m, False, 2500.0, 30.0, 10.0) for m in packmode.MODES] check("...only Cheap hours only is a grid charger (packmode.GRID_CHARGE_MODES)", [p.mode for p in modetrade.chargers(_pk, 95.0)] == [packmode.CHEAP_HOURS]) _c12b = run_day("p3_ch_sell", start_h=0.75, hours=0.25, soc0={"a": 60.0, "b": 30.0}, choice={"a": (True, "solar_first", False), "b": (True, "leftover_solar", True)})[2] _n12 = UTC0 + timedelta(hours=1) _plan12 = homeview.mode_runs(_c12b, *homeview.local_day(_n12), _n12, False)[0] print(f" Leftover solar plan at 01:00: " f"{[(r['from'], r['to'], r['kind']) for r in _plan12]}") check("...and the plan draws no grid block for it, only its expensive run", _plan12 and all(r["kind"] != "grid" for r in _plan12)) _enw, _nlw = homeview.uitext.words("en"), homeview.uitext.words("nl") check("...and its help text promises no grid charging (EN and NL)", all("cheap" not in t.lower() and "goedkop" not in t.lower() for t in (_enw["home"]["batteries"]["choice"]["leftover_solar"] ["description"], _nlw["home"]["batteries"]["choice"]["leftover_solar"] ["description"], _enw["home"]["batteries"]["how"]["leftover_solar"] ["description"], _nlw["home"]["batteries"]["how"]["leftover_solar"] ["description"]))) print() if fails: print(f"{len(fails)} FAILED:") for f in fails: print(f" - {f}") sys.exit(1) print("all checks passed")