Files
goodwe-addon/goodwe_controller/test_modes_p3.py
T
glenn schrooyenandClaude 891ed7bdd7
checks / unit (pull_request) Successful in 47m49s
infra-pin / fresh (pull_request) Successful in 2s
PRICE-02 round 2: require now, undo the test loosening, analyse ties
- TariffPlan.arbitrage(now=...) is required (keyword-only, no default); so are
  homeview.trade_verdict/refusal_key's now. Existing test calls pass one.
- test_arbitrage.py: no module-level decide wrapper. Fixtures that probed a
  one-day plan after its extremes now hold two days (as a site does); the
  flap day gets a unique cheapest and dearest hour; the replay breaks its
  ties without direction. The flap bound is <= 4 and the replay check is
  len(_trades) < 365 again. 228 checks pass on the old and the new code.
- test_price02.py part 6: ties (equal quarters, a real 15-minute day,
  alternating hourly prices) and the required now.
- Identity pins: the masked digest is only for the older-tree comparison;
  this tree's own digests are pinned with nothing hidden.
- DECISIONS: the tie analysis with numbers.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-22 10:37:43 +02:00

761 lines
38 KiB
Python

"""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 on release/1.0 7a00f12 (2026-09-29), goodwe-addon-checks:1, 1080
# cycles each; the trade CHARGEs and DISCHARGEs.
#
# PRICE-02 (2026-10-22) changed two things in the trade sentence and nothing
# else: the plan now drops price periods that have ended, so a sale's "N above
# the cheapest" is another number once the first cheap period is over, and the
# sentences say "price period" for "hour". So there are three pins:
# AT_7A00F12_OLD the digests printed on 7a00f12, sentence included. They no
# longer match, and the check below says so: that is what
# makes the mask necessary.
# AT_7A00F12 the same rows through `ident.trade_digest()`, which hides
# exactly those two things (and only for this comparison with
# an older tree). It equals what 7a00f12 gives through it.
# AT_NOW this tree's own digests, sentence included, with nothing
# hidden: any later change to a command, a sentence or a
# figure moves it. The mask cannot hide a future drift.
AT_7A00F12_OLD = {
"n1": "71245b227ca40bada4f71f57ea6154475f4b0bd60239c9ad4f80bffb0094514c",
"n2": "56cf28010b06765c29e5479abb815d7503d570b39240d82dfc818efa90c93722",
}
AT_7A00F12 = {
"n1": "33886ef03f364852883f0370230e6e04823fbb46a4704f88ca75bf9082d98c01",
"n2": "666a31f41491c22750720524853d95944d5fba395d1d14040c460d71414b275f",
"n1_off": "33886ef03f364852883f0370230e6e04823fbb46a4704f88ca75bf9082d98c01",
"n2_off": "666a31f41491c22750720524853d95944d5fba395d1d14040c460d71414b275f",
}
AT_NOW = {
"n1": "939b3d47bbd64fa50e3d70538f092fb98e5c4bd5ead85a7fc696a1dbb514039e",
"n2": "c51008213dd739e1e5142c236d9a950aa585992393e4af120086f1ba52ff3e0d",
"n1_off": "939b3d47bbd64fa50e3d70538f092fb98e5c4bd5ead85a7fc696a1dbb514039e",
"n2_off": "c51008213dd739e1e5142c236d9a950aa585992393e4af120086f1ba52ff3e0d",
}
_cmd_digest = ident.trade_digest
_rows = {k: ident.run(**ident.SCENARIOS[k])[0] for k in AT_7A00F12}
_got = {k: _cmd_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]}")
_full = {k: ident.digest(v) for k, v in _rows.items()}
check("with battery modes off (option absent or false), every command, "
"integrator, allocation and trade action equals 7a00f12's, at one "
"inverter and at two (the sentence masked as above)",
_got == AT_7A00F12)
check("...the mask is needed and nothing more: unmasked, n1 and n2 differ "
"from 7a00f12's printed digests, and unmasked they equal this tree's "
"own pins, so a later change to any sentence or figure fails here",
all(_full[k] != AT_7A00F12_OLD[k] for k in AT_7A00F12_OLD)
and _full == AT_NOW)
_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 _cmd_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 _cmd_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")