Files
goodwe-addon/goodwe_controller/test_price02.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

430 lines
20 KiB
Python

"""Runnable check for PRICE-02. `python3 test_price02.py`
The trading verdict is taken over the price periods that have not ended.
`TariffPlan.arbitrage(now=...)` drops the points with `end <= now` (the span
`price._cover_table()` gives each one) and every caller passes its `now`:
`arbitrage.decide()` (modes off), the `allowed()` gate in
`Controller._mode_roles()` (modes on) and the Home page's `trade_verdict()`.
1. A 15-minute day, probed at :05, :20, :35 and :50 of the cheap hour: the
buy leg and the sell leg move to the current period and never name one that
has ended, and `decide()` trades in it. Cheapest in the past, the current
and a future period are all covered.
2. A plan built at 03:00 and used at 03:20 (the 30-minute refresh lag), and the
shipped refresh (`fill_from_yesterday`, which keeps the whole current hour).
3. An hourly feed and the static day/night tariff are unchanged: an hourly
point that is still current stays in.
4. Modes on: a spread that exists only because of an ended period no longer
opens the gate (`Controller._mode_roles`), nor the Home page verdict.
5. The reason text says "price period"; `now` is required (no default).
6. Ties. The verdict takes the first extreme still ahead, where it used to
take the first of the whole plan. On hour-constant 15-minute data (four
equal quarters) that is four acting periods in the cheap hour, not one;
the number of times the action changes does not grow. The same on a real
15-minute day, and hourly alternating prices change at most once an hour.
Mutations: MUTATION.md says how to run them (purged copies); the ones of
PRICE-02 are listed in the pull request.
"""
import json
import logging
import os
import sys
from datetime import datetime, timedelta, timezone
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "tools"))
logging.disable(logging.WARNING)
from app import arbitrage, homeview # noqa: E402
from app.arbitrage import CHARGE, DISCHARGE, decide # noqa: E402
from app.price import (PricePoint, PriceProduct, PriceRead, # noqa: E402
Uncertainty, brussels)
from app.price_forecast import fill_from_yesterday # noqa: E402
from app.static_tariff import TariffPlan, static_plan # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
UTC = timezone.utc
LONG_AGO = datetime(2000, 1, 1, tzinfo=UTC) # before every point: nothing has ended, the old reach
NO_WEATHER = frozenset({Uncertainty.NO_WEATHER})
D0 = datetime(2026, 10, 20, 0, 0, tzinfo=UTC) # 02:00 Brussels
H3 = D0 + timedelta(hours=1) # 03:00 Brussels
ARGS = dict(peak_guard_active=False, min_spread_eur_kwh=0.10,
uncertainty=NO_WEATHER)
def qplan(prices, start=D0, minutes=15, product=PriceProduct.DAY_AHEAD):
pts = [PricePoint(brussels(start + timedelta(minutes=minutes * i)), p,
False) for i, p in enumerate(prices)]
return TariffPlan(PriceRead.series(pts, product, "generic_dayahead",
start), None, "provider")
ARGS_NO_NOW = dict(ARGS)
ARGS5 = dict(ARGS, min_spread_eur_kwh=0.05, uncertainty=frozenset())
def dec(plan, now, **kw):
base = dict(peak_guard_active=False, charge_headroom_w=5000.0, soc=50.0,
min_spread_eur_kwh=0.10, uncertainty=frozenset(),
charge_w=2500.0, discharge_w=2500.0, soc_floor_pct=10.0,
soc_ceiling_pct=90.0)
base.update(kw)
return decide(plan, now, **base)
def hhmm(t):
return f"{brussels(t):%H:%M}"
def ahead_extreme(prices, now, pick, start=D0, minutes=15):
"""The expected leg, by plain arithmetic: the extreme price over the
periods whose end is after `now`, as the start instant (first on a tie)."""
best = None
for i, p in enumerate(prices):
s = start + timedelta(minutes=minutes * i)
if s + timedelta(minutes=minutes) <= now:
continue
if best is None or pick(p, best[1]):
best = (s, p)
return best[0]
# --------------------------------------------------------------------------- #
print("1. a 15-minute day: the verdict is taken from the current period on")
BUY = [0.30] * 192 # 48 h of quarters from 02:00
for q, p in ((4, 0.05), (5, 0.06), (6, 0.07), (7, 0.08), (68, 0.50)):
BUY[q] = p # 03:00 .05 03:15 .06 03:30 .07 03:45 .08 19:00 .50
BUY_PLAN = qplan(BUY)
check("the fixture: the day's cheapest period is 03:00, then three dearer "
"ones, and the dearest is 19:00 - so every probe has a past, a current "
"and a future period to choose between",
BUY.index(min(BUY)) == 4 and BUY.index(max(BUY)) == 68
and BUY[4] < BUY[5] < BUY[6] < BUY[7] < min(BUY[8:68]))
want_q = {5: 4, 20: 5, 35: 6, 50: 7}
for mins, q in want_q.items():
now = H3 + timedelta(minutes=mins)
v = BUY_PLAN.arbitrage(now=now, **ARGS)
d = dec(BUY_PLAN, now)
got = v.cheapest.start
exp = ahead_extreme(BUY, now, lambda a, b: a < b)
check(f"03:{mins:02d}: the buy leg is {hhmm(exp)} (the first period not "
f"over), the verdict says {hhmm(got)}, and decide() is {d.action}",
got == exp == D0 + timedelta(minutes=15 * q)
and d.action == CHARGE
and abs(d.price_now_eur_kwh - BUY[q]) < 1e-9)
check("before the change the same plan at 03:20 aimed at 03:00 (the "
"verdict without `now`) and decide() did nothing - the defect, shown",
BUY_PLAN.arbitrage(now=LONG_AGO, **ARGS).cheapest.start == H3
and arbitrage._act(BUY_PLAN, H3 + timedelta(minutes=20),
BUY_PLAN.arbitrage(now=LONG_AGO, **ARGS), 50.0, 2500.0, 2500.0,
10.0, 90.0, 5000.0).action is None)
# A cheapest period still AHEAD stays the target from every probe.
FUT = [0.30] * 192
FUT[8], FUT[68] = 0.04, 0.50 # cheapest 04:00, dearest 19:00
FUT_PLAN = qplan(FUT)
check("the cheapest in the FUTURE (04:00) is the buy leg at :05, :20, :35 and "
":50 of the hour before it - nothing ahead is dropped",
all(FUT_PLAN.arbitrage(now=H3 + timedelta(minutes=m), **ARGS)
.cheapest.start == H3 + timedelta(hours=1) for m in (5, 20, 35, 50)))
check("...and at 04:20 (that period is now the current one) it is still the "
"current period at :00-:15 only: 04:20 aims elsewhere",
FUT_PLAN.arbitrage(now=H3 + timedelta(hours=1, minutes=20), **ARGS)
.cheapest.start != H3 + timedelta(hours=1))
print("\n1b. the sell leg")
SELL = [0.30] * 192
for q, p in ((4, 0.70), (5, 0.65), (6, 0.60), (7, 0.55), (68, 0.10)):
SELL[q] = p # dearest 03:00 .70 .. 03:45 .55 ; cheapest 19:00
SELL_PLAN = qplan(SELL)
for mins, q in want_q.items():
now = H3 + timedelta(minutes=mins)
v = SELL_PLAN.arbitrage(now=now, **ARGS)
d = dec(SELL_PLAN, now)
exp = ahead_extreme(SELL, now, lambda a, b: a > b)
check(f"03:{mins:02d}: the sell leg is {hhmm(exp)}, the verdict says "
f"{hhmm(v.dearest.start)}, and decide() is {d.action}",
v.dearest.start == exp == D0 + timedelta(minutes=15 * q)
and d.action == DISCHARGE
and abs(d.price_now_eur_kwh - SELL[q]) < 1e-9)
# --------------------------------------------------------------------------- #
print("\n2. the plan is older than the verdict")
# Built at 03:00 and used at 03:20, 03:35 (the refresh runs every 30 min).
built = H3
feed = qplan(BUY).read
plan_0300 = TariffPlan(fill_from_yesterday(
feed, yesterday=PriceRead.no_data("none"), since=built,
until=built + timedelta(hours=48), stamp=built), None, "provider")
check("the plan built at 03:00 starts at 03:00 (the refresh floors to the hour)",
min(p.start for p in plan_0300.read.points) == H3)
for mins, q in ((20, 5), (35, 6)):
now = H3 + timedelta(minutes=mins)
d = dec(plan_0300, now)
check(f"used at 03:{mins:02d} the verdict is "
f"{hhmm(D0 + timedelta(minutes=15 * q))} and decide() charges there",
plan_0300.arbitrage(now=now, **ARGS).cheapest.start
== D0 + timedelta(minutes=15 * q) and d.action == CHARGE)
# The shipped refresh at 03:20 keeps the whole hour: the verdict still skips it.
now = H3 + timedelta(minutes=20)
plan_refresh = TariffPlan(fill_from_yesterday(
feed, yesterday=PriceRead.no_data("none"), since=now,
until=now + timedelta(hours=48), stamp=now), None, "provider")
check("the shipped refresh at 03:20 still holds the 03:00 period, as before, "
"and the verdict does not aim at it",
min(p.start for p in plan_refresh.read.points) == H3
and plan_refresh.arbitrage(now=now, **ARGS).cheapest.start
== H3 + timedelta(minutes=15))
# --------------------------------------------------------------------------- #
print("\n3. hourly feeds and the static tariff do not move")
HR = [0.30] * 48 # 48 hourly points from 02:00
HR[1], HR[17] = 0.05, 0.50 # cheapest 03:00, dearest 19:00
HR_PLAN = qplan(HR, minutes=60)
same = True
for m in (0, 5, 20, 35, 59):
now = H3 + timedelta(minutes=m)
a = HR_PLAN.arbitrage(now=now, **ARGS)
b = HR_PLAN.arbitrage(now=LONG_AGO, **ARGS)
same = same and a.cheapest is b.cheapest and a.dearest is b.dearest \
and a.spread_eur_kwh == b.spread_eur_kwh
check("an hourly point that is still current stays in: at 03:00, :05, :20, "
":35 and :59 the verdict is the one without `now`", same)
check("...and decide() still charges in the current hour at 03:40",
dec(HR_PLAN, H3 + timedelta(minutes=40)).action == CHARGE)
check("...and once the hour has ended its point is gone: at 04:05 the buy leg "
"is no longer 03:00",
HR_PLAN.arbitrage(now=H3 + timedelta(hours=1, minutes=5), **ARGS)
.cheapest.start != H3)
TWO = {"periods": [
{"name": "day", "from": "07:00", "to": "22:00", "days": "mon-sun",
"eur_per_kwh": 0.40},
{"name": "night", "from": "22:00", "to": "07:00", "days": "mon-sun",
"eur_per_kwh": 0.18}]}
OPTS = {"price_provider": "off", "static_profile": json.dumps(TWO)}
ok, n_checked = True, 0
for h in range(0, 24):
for m in (0, 20, 50):
now = D0 + timedelta(hours=h - 2, minutes=m) # from 22:00 UTC the day before
plan = static_plan(OPTS, now, now + timedelta(hours=48), now=now)
a = plan.arbitrage(now=now, **ARGS)
b = plan.arbitrage(now=LONG_AGO, **ARGS)
n_checked += 1
ok = ok and a.kind == b.kind == "allowed" and a.cheapest is b.cheapest \
and a.dearest is b.dearest and a.spread_eur_kwh == b.spread_eur_kwh
check(f"the static day/night plan (built from the current hour, as main.py "
f"does) gives the same verdict with and without `now` at {n_checked} "
f"instants", ok and n_checked == 72)
# --------------------------------------------------------------------------- #
print("\n4. modes on: an ended period no longer opens the gate")
GATE = [0.30] * 192
GATE[4] = 0.10 # the ONLY spread is the 03:00 period
GATE_PLAN = qplan(GATE)
now = H3 + timedelta(minutes=20)
check("the fixture: with the ended 03:00 period in view the verdict is "
"allowed; from 03:20 on the rest of the series is flat",
GATE_PLAN.arbitrage(now=LONG_AGO, **ARGS).kind == "allowed"
and GATE_PLAN.arbitrage(now=now, **ARGS).kind == "refused"
and "flat" in GATE_PLAN.arbitrage(now=now, **ARGS).reason)
import opt11p3_replay as rp # noqa: E402
WPTS = [(D0 + timedelta(minutes=15 * i), 900, 0.10) for i in range(192)]
def controller_at(at, plan):
"""The real Controller after a few cycles of the modes-on replay at `at`
(two batteries, one on Cheap hours), holding `plan` as its fresh plan."""
_st, c = rp.run("p3_ch_sell", [(D0.timestamp() - 10, 800.0)], WPTS, at,
20.0, soc0={"a": 60.0, "b": 30.0},
extra={"cycle_cost_threshold_eur": 0.05})
c.tariff_plan, c.tariff_at = plan, at
return c
from app import modetrade # noqa: E402
GATE_SEEN = {}
_real_decide = modetrade.decide
def _spy(runs, now_, packs, **kw):
GATE_SEEN.update(charge_ok=kw["charge_ok"], sell_ok=kw["sell_ok"],
trade_why=kw["trade_why"])
return _real_decide(runs, now_, packs, **kw)
modetrade.decide = _spy
c = controller_at(now, GATE_PLAN)
GATE_SEEN.clear()
c._mode_roles(now, None)
late = dict(GATE_SEEN)
c2 = controller_at(H3 + timedelta(minutes=5), GATE_PLAN)
GATE_SEEN.clear()
c2._mode_roles(H3 + timedelta(minutes=5), None)
early = dict(GATE_SEEN)
modetrade.decide = _real_decide
check(f"Controller._mode_roles() (modes on, the `allowed()` gate) at 03:05, "
f"03:00 still current: charge and sell are allowed ({early})",
early.get("charge_ok") is True and early.get("sell_ok") is True)
check(f"...and at 03:20, with only flat periods ahead: neither is allowed, "
f"and the reason is the spread ({late})",
late.get("charge_ok") is False and late.get("sell_ok") is False
and late.get("trade_why") == "not_worth")
c.o["arbitrage_enabled"] = True
v, why = homeview.trade_verdict(c, GATE_PLAN, now)
check(f"the Home page verdict takes the same `now`: no verdict at 03:20 "
f"({why!r}), and a verdict at 03:05",
v is None and why == "not_worth"
and homeview.trade_verdict(c, GATE_PLAN, H3 + timedelta(minutes=5))[0]
is not None)
check("...and the refusal key counts only periods not over: two firm periods "
"left, so 'not_worth', and with one left 'not_firm'",
homeview.refusal_key(GATE_PLAN, False, now) == "not_worth"
and homeview.refusal_key(qplan([0.1, 0.2]), False,
D0 + timedelta(minutes=20)) == "not_firm")
# --------------------------------------------------------------------------- #
print("\n5. the text")
check("the reason for an off-peak period says 'price period', not 'hour'",
arbitrage.IDLE_OFF_PEAK_HOUR
== "now is not the cheapest or the dearest price period")
_b = dec(BUY_PLAN, H3 + timedelta(minutes=20))
_s = dec(SELL_PLAN, H3 + timedelta(minutes=20))
check("...and the charge and the discharge sentences say 'price period' too "
"(the unit is 15 minutes), with no 'firm hour' left",
"cheapest firm price period" in _b.reason
and "dearest firm price period" in _s.reason
and "firm hour" not in _b.reason + _s.reason)
_hi = dec(BUY_PLAN, H3 + timedelta(minutes=20), soc=95.0)
_lo = dec(SELL_PLAN, H3 + timedelta(minutes=20), soc=10.0)
check("...and so do the full-pack and empty-pack sentences ('buy period', "
"'sell period')",
"the buy period is here" in _hi.reason
and "the sell period is here" in _lo.reason)
import inspect # noqa: E402
_par = inspect.signature(TariffPlan.arbitrage).parameters
check("`now` is required: keyword-only with no default, like the three "
"arguments beside it, so a caller that forgets it fails loudly instead "
"of getting the elapsed periods back",
_par["now"].default is inspect.Parameter.empty
and _par["now"].kind is inspect.Parameter.KEYWORD_ONLY
and all(p.default is inspect.Parameter.empty
for n, p in _par.items() if n != "self"))
try:
BUY_PLAN.arbitrage(**ARGS_NO_NOW)
_forgot = False
except TypeError:
_forgot = True
check("...and calling without it raises TypeError", _forgot)
try:
BUY_PLAN.arbitrage(now=datetime(2026, 10, 20, 3, 20), **ARGS)
_naive = False
except TypeError as e:
_naive = "tz-aware" in str(e)
check("a naive `now` is refused: it is ambiguous for one hour every October",
_naive)
# --------------------------------------------------------------------------- #
print("\n6. ties: the first extreme still ahead")
def acts(plan, instants, ago=False, args=ARGS):
out = []
for t in instants:
v = plan.arbitrage(now=LONG_AGO if ago else t, **args)
d = arbitrage._act(plan, t, v, 50.0, 2500.0, 2500.0, 10.0, 90.0,
5000.0) if v.kind == "allowed" else None
out.append(None if d is None else d.action)
return out
# Hour-constant 15-minute data: the cheap hour is four equal quarters.
HC = [round(0.40 - 0.0001 * i, 4) for i in range(192)] # falling, no ties
for q in range(4, 8):
HC[q] = 0.05 # 03:00-04:00 four equal quarters
HC[68:72] = [0.50] * 4 # 19:00-20:00 four equal quarters
HC_PLAN = qplan(HC)
probe = [H3 + timedelta(minutes=m) for m in (5, 20, 35, 50, 65)]
old = acts(HC_PLAN, probe, ago=True)
new = acts(HC_PLAN, probe)
check(f"before: four equal cheap quarters, the verdict is the first of them, "
f"so the trade acts in one quarter ({old})",
old == [CHARGE, None, None, None, None])
check(f"after: the first one still ahead, so it acts in all four and stops "
f"with the hour ({new}) - four acting periods for the same hour, one "
f"block either way",
new == [CHARGE, CHARGE, CHARGE, CHARGE, None])
changes = lambda a: sum(1 for x, y in zip(a, a[1:]) if x != y) # noqa: E731
check("...the action changes the same number of times (one start, one stop)",
changes(old) == changes(new) == 1)
# A real 15-minute day, as production runs it: refresh every 30 min from the
# floored hour, the verdict at 7 min into every period.
REAL = os.path.join(HERE, "scenarios",
"energy-charts-be-price-2026-09-09_2026-09-10-twoday.json")
_d = json.load(open(REAL))
_pts = [PricePoint(brussels(datetime.fromtimestamp(t, UTC)), p / 1000.0, False)
for t, p in zip(_d["unix_seconds"], _d["price"])]
_feed = PriceRead.series(_pts, PriceProduct.DAY_AHEAD, "generic_dayahead",
_pts[0].start)
_day0 = _pts[0].start.astimezone(UTC)
def real_day(ago):
out = []
for q in range(96):
t = _day0 + timedelta(minutes=15 * q + 7)
ref = t.replace(minute=0 if t.minute < 30 else 30, second=0,
microsecond=0)
plan = TariffPlan(fill_from_yesterday(
_feed, yesterday=PriceRead.no_data("none"), since=ref,
until=ref + timedelta(hours=36), stamp=ref), None, "provider")
v = plan.arbitrage(now=LONG_AGO if ago else t, **ARGS5)
out.append(arbitrage._act(plan, t, v, 50.0, 2500.0, 2500.0, 10.0,
90.0, 5000.0).action
if v.kind == "allowed" else None)
return out
r_old, r_new = real_day(True), real_day(False)
n_old = sum(1 for a in r_old if a)
n_new = sum(1 for a in r_new if a)
print(f" real 15-minute day 2026-09-09: acting periods {n_old} -> {n_new}, "
f"action changes {changes(r_old)} -> {changes(r_new)}")
check("a real 15-minute day (energy-charts, 2026-09-09) does not switch more "
"often: the action changes no more often than before",
changes(r_new) <= changes(r_old))
check("...and every period the old verdict acted in is still acted in, with "
"the quarters it had lost to the elapsed-period tie added",
all(n is not None for o, n in zip(r_old, r_new) if o) and n_new >= n_old)
# Alternating hourly prices (the flapping day): ties everywhere, so the verdict
# moves on every hour. At most once an hour, never once a tick.
FL = [0.20 + (0.101 if i % 2 else 0.0) for i in range(24)] * 2
FL_PLAN = qplan(FL, minutes=60)
ticks = [acts(FL_PLAN, [D0 + timedelta(seconds=30 * k)], args=ARGS5)[0]
for k in range(24 * 120)]
check(f"alternating hourly prices: {changes(ticks)} action changes over "
f"2880 ticks - at most one an hour, not one a tick",
changes(ticks) <= 24 and CHARGE in ticks and DISCHARGE in ticks)
print()
if fails:
print(f"{len(fails)} FAILED: " + "; ".join(fails))
sys.exit(1)
print("all checks passed")