- A sale block on the plan no longer says "Worth up to" at the import price: a sale earns the lower injection price. - The selling switch help (EN/NL) and DOCS say the grid pays less than power costs; DECISIONS records why P3 loses on the replay. - test_modes_p3 part 11: modes on ignore the site trade switch (R13), a battery in maintenance takes no part (R10), a headroom stop latches for the period (R8). All three mutants survived before and are red now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
667 lines
32 KiB
Python
667 lines
32 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. The selling switch (`pack_sell`).
|
|
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.
|
|
|
|
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(**v)[0] for k, v in ident.SCENARIOS.items()}
|
|
_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.SOLAR_FIRST
|
|
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
|
|
|
|
|
|
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))
|
|
|
|
# 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})}")
|
|
check("B refusing to charge: no grid trade is filed after the refusal is "
|
|
"learned, B's role is no longer 'charge', and A covers the house",
|
|
_ref and all(r["action"] is None for r in _ref)
|
|
and all(r["roles"].get(B) != modetrade.CHARGE for r in _ref)
|
|
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: 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")
|
|
_st = rp._Store()
|
|
_ctl = Controller(dict(rp.options(True, False)), rp.Hass(("a", "b"),
|
|
lambda t: 0.0), _st, rp._Pub(), stop=asyncio.Event())
|
|
_r1 = asyncio.run(_ctl.handle_action("pack_sell", unit=B, value=True))
|
|
_bad9 = [asyncio.run(_ctl.handle_action("pack_sell", unit=B, value=v))
|
|
for v in ("yes", 1, 0, None)]
|
|
_bad9.append(asyncio.run(_ctl.handle_action("pack_sell", unit="x",
|
|
value=True)))
|
|
check("pack_sell stores true/false per battery, logs it, and returns it",
|
|
_r1 == {"unit": B, "use": True, "mode": packmode.SOLAR_FIRST,
|
|
"sell": True}
|
|
and _st.data[unit_key(B, packmode.SELL_KEY)] is True
|
|
and any("selling switched on" in str(e) for e in _ctl.events))
|
|
check("...anything else (1, 0, 'yes', None, an unknown battery) is refused "
|
|
"with a plain reason and stores nothing",
|
|
all(set(x) == {"error"} for x in _bad9)
|
|
and _st.data[unit_key(B, packmode.SELL_KEY)] is True)
|
|
check("...Solar first never sells, even with the switch on",
|
|
packmode.read(_st.data, B).sell is True
|
|
and packmode.read(_st.data, B).can_sell is False)
|
|
_off_ctl = Controller(dict(rp.options(False, False)), rp.Hass(
|
|
("a", "b"), lambda t: 0.0), rp._Store(), rp._Pub(), stop=asyncio.Event())
|
|
check("...and with battery modes off it is refused like the others",
|
|
"error" in asyncio.run(_off_ctl.handle_action("pack_sell", unit=B,
|
|
value=True)))
|
|
|
|
# ============================================================================ #
|
|
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))
|
|
_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)
|
|
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()
|
|
if fails:
|
|
print(f"{len(fails)} FAILED:")
|
|
for f in fails:
|
|
print(f" - {f}")
|
|
sys.exit(1)
|
|
print("all checks passed")
|