Files
goodwe-addon/goodwe_controller/test_tariff24.py
T
glenn schrooyenandClaude 230ba0b57a
checks / unit (pull_request) Successful in 49m23s
infra-pin / fresh (pull_request) Successful in 2s
TARIFF-24: test file follows the repo's suite rules (MUTATION.md pointer, named absence)
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-28 01:09:39 +01:00

375 lines
16 KiB
Python

"""Runnable check for TARIFF-24. `python3 test_tariff24.py`
Battery modes on, a day/night `static_profile`, no price feed and the site trade
switch off used to start no tariff task: `Controller.tariff_plan` stayed None,
`mode_runs` stayed None, Leftover solar and Cheap hours never charged from the
grid, and the Batteries screen said "No prices" while Home priced the day from
the profile itself. DECISIONS/TARIFF-24-modes-need-the-tariff-task.md.
1. The startup gate: `tariff_refresh_wanted()` by configuration, and the guard
`amain()` really puts on the task (read from its source, not retyped).
2. End to end, on the real Controller and the real `run_tariff_refresh()` task,
started only if `amain()`'s own guard says so: night period, one battery
Leftover solar and one Cheap hours.
3. The same with a gap below the cycle cost.
4. Home and the Batteries screen agree.
5. UX-55: with every battery Solar first Home no longer says "no prices have
arrived" on a static profile, and still does with no profile and no feed.
6. Nothing else moved: modes off starts no task; the gate rows in part 1 keep
the arbitrage and feed cases as before.
Mutations (MUTATION.md, purged copies, graded over this file): the gate
reverted to `provider or arbitrage` fails 18 checks in parts 1 to 5; `True`
fails the modes-off rows; `bool(opts.get(...))` fails the exact-true row;
dropping the arbitrage term fails the arbitrage rows.
"""
import ast
import asyncio
import logging
import os
import sys
import tempfile
import time
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)
import opt11p3_replay as rp # noqa: E402
from named_absence import first, nth, present # noqa: E402
import app.main as m # noqa: E402
from app import homeview, modetrade, packmode # noqa: E402
from app import price as _price, static_tariff as _st # noqa: E402
from app.main import Controller, tariff_refresh_wanted # noqa: E402
from app.p1 import make_sample, split_signed # 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)
A, B = "inverter-a", "inverter-b"
# A Belgian-style day/night tariff, example values: dear 07:00-22:00, cheap
# 22:00-07:00, every day. The gap is 0.08 EUR/kWh.
PROFILE = ('{"periods": ['
'{"name": "night", "days": "all", "from": "22:00", "to": "07:00",'
' "eur_per_kwh": 0.22},'
'{"name": "day", "days": "all", "from": "07:00", "to": "22:00",'
' "eur_per_kwh": 0.30}]}')
# A Friday in winter time (+01:00), 23:00 local: inside the night period.
L = timezone(timedelta(hours=1))
NIGHT = datetime(2026, 10, 30, 23, 0, tzinfo=L).astimezone(timezone.utc)
def opts(*, modes=True, arb=False, profile=PROFILE, provider=None,
cost=0.05):
o = dict(rp.options(modes, arb, 2))
o["cycle_cost_threshold_eur"] = cost
if profile is not None:
o["static_profile"] = profile
if provider is not None:
o["price_provider"] = provider
return o
# --------------------------------------------------------------------------- #
# The guard amain() puts on the tariff task, read from amain()'s source.
# --------------------------------------------------------------------------- #
def _guards_of(node, guards=()):
"""[(guards, coroutine name)] for every create_task(...) under `node`."""
out = []
for child in ast.iter_child_nodes(node):
if isinstance(child, ast.If):
test = ast.unparse(child.test)
for stmt in child.body:
out += _guards_of(stmt, guards + (test,))
for stmt in child.orelse:
out += _guards_of(stmt, guards + (f"not ({test})",))
continue
if isinstance(child, ast.Call) and \
ast.unparse(child.func).endswith("create_task") and child.args:
arg = child.args[0]
name = (arg.func.attr if isinstance(arg, ast.Call)
and isinstance(arg.func, ast.Attribute)
else ast.unparse(arg))
out.append((guards, name))
continue
out += _guards_of(child, guards)
return out
_TREE = ast.parse(open(os.path.join(HERE, "app", "main.py"),
encoding="utf-8").read())
_AMAIN = first((n for n in ast.walk(_TREE)
if isinstance(n, ast.AsyncFunctionDef) and n.name == "amain"),
"amain() in main.py")
_TASK_GUARDS = [g for g, n in _guards_of(_AMAIN) if n == "run_tariff_refresh"]
def task_started(o) -> bool:
"""Would amain() start the tariff refresh task for options `o`?"""
if len(_TASK_GUARDS) != 1:
return False
return all(bool(eval(g, vars(m), {"opts": o})) for g in _TASK_GUARDS[0])
# --------------------------------------------------------------------------- #
print("1. the startup gate")
check("amain() starts the tariff refresh from exactly one guard, and that "
"guard can be decided from the options alone",
len(_TASK_GUARDS) == 1 and len(_TASK_GUARDS[0]) >= 1
and all(isinstance(eval(g, vars(m), {"opts": opts()}), bool)
for g in _TASK_GUARDS[0]))
GATE = [
# label, options, task wanted
("modes on + static day/night only", opts(), True),
("modes on + a fixed price only",
opts(profile='{"periods": [{"name": "all", "days": "all", "from": '
'"00:00", "to": "24:00", "eur_per_kwh": 0.27}]}'), True),
("modes on, nothing else", opts(profile=None), True),
("modes off + arbitrage off + no feed (static profile)",
opts(modes=False), False),
("modes off + arbitrage off + no feed, no profile",
opts(modes=False, profile=None), False),
("modes off + arbitrage on", opts(modes=False, arb=True), True),
("modes off + a feed", opts(modes=False, provider="generic_dayahead"),
True),
("modes on + a feed", opts(provider="generic_dayahead"), True),
("modes on + arbitrage on", opts(arb=True), True),
]
for label, o, want in GATE:
got = tariff_refresh_wanted(o)
print(f" wanted={got!s:5} started={task_started(o)!s:5} {label}")
check(f"{label}: tariff_refresh_wanted() is {want}", got is want)
check(f"{label}: amain() starts the task exactly as that says",
task_started(o) is want)
check("battery_modes_enabled has to be exactly true (packmode.enabled()): "
"'true' as text or 1 does not start the task",
not tariff_refresh_wanted(dict(opts(), battery_modes_enabled="true"))
and not tariff_refresh_wanted(dict(opts(), battery_modes_enabled=1)))
# --------------------------------------------------------------------------- #
# The real Controller over a few minutes of a night: the task starts only if
# amain()'s own guard says so; then read_inputs() + cycle() as the control
# loop runs them.
# --------------------------------------------------------------------------- #
CHOICE = {"a": (True, "leftover_solar", False),
"b": (True, "cheap_hours", False)}
FETCHES = []
async def _no_fetch(*a, **k):
FETCHES.append(a)
raise AssertionError("the tariff task reached for a price feed")
def drive(o, *, start=NIGHT, minutes=20, soc0=None, choice=CHOICE,
house_w=300.0, tick=5.0):
"""-> (controller, rows, task_was_started)."""
store = rp._Store()
for u, (use, mode, sell) in choice.items():
store.data[unit_key(f"inverter-{u}", "pack_use")] = use
store.data[unit_key(f"inverter-{u}", "pack_mode")] = mode
store.data[unit_key(f"inverter-{u}", "pack_sell")] = sell
rows = []
async def go():
hass = rp.Hass(("a", "b"), lambda t: house_w,
soc0 or {"a": 40.0, "b": 40.0})
tmp = tempfile.mkdtemp(prefix="t24-")
c = Controller(dict(o), hass, store, rp._Pub(),
stop=asyncio.Event(),
peak_db_path=os.path.join(tmp, "tariff.db"),
price_history_path=os.path.join(tmp, "ph.json"))
for i in c.inverters:
i.dev_min, i.dev_max = -5000.0, 5000.0
c.auto = True
real_dt, real_hv = m.datetime, homeview.datetime
clock = [start]
class _DT(real_dt):
@classmethod
def now(cls, tz=None):
return clock[0] if tz is None else clock[0].astimezone(tz)
m.datetime = homeview.datetime = _DT
_real_load = _price.load_prices, _st.load_prices
_price.load_prices = _st.load_prices = _no_fetch
started = task_started(o)
task = (asyncio.create_task(c.run_tariff_refresh())
if started else None)
try:
t = 0.0
while t < minutes * 60:
now = start + timedelta(seconds=t)
hass.t = t
clock[0] = now
if t == 0.0 and started:
for _ in range(500): # let the task do its first pass
if c.tariff_plan is not None:
break
await asyncio.sleep(0)
imp, exp = split_signed(hass.grid())
c.p1.submit(make_sample("ha_signed", imp, exp, phases=1,
ingest_ts=now,
ingest_mono=time.monotonic()))
await c.read_inputs(now_mono=t)
await c.cycle()
hass.advance(tick, 10.0)
rows.append(dict(
now=now, runs=c.mode_runs, tick=c.mode_tick,
plan=c.fresh_tariff_plan(now),
action=getattr(c.arb, "action", None),
p={u: p.p for u, p in hass.plants.items()},
soc={u: p.soc for u, p in hass.plants.items()}))
t += tick
# What the two screens say at the end, while the clock is still
# the simulated one.
c.home_plan = homeview.build_plan(c, clock[0], False, True)
c.battery_cards = homeview.battery_rows(c, False, True)
finally:
if task is not None:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
m.datetime, homeview.datetime = real_dt, real_hv
_price.load_prices, _st.load_prices = _real_load
return c, started
c, started = asyncio.run(go())
return c, rows, started
def charging_b(rows):
"""Cycles in which the Cheap hours battery was charging (grid: no sun and
a 300 W house in this scenario)."""
return [r for r in rows if r["p"]["b"] < -200.0]
def why_of(c, name):
card = first((b for b in c.battery_cards if b["name"] == name),
f"a card for {name}")
return card["why_key"]
# --------------------------------------------------------------------------- #
print("\n2. end to end, night, modes on, static day/night, no feed, no arbitrage")
c1, r1, started1 = drive(opts())
_last = nth(r1, -1, "the last cycle")
_first1 = nth(r1, 0, "the first cycle")
print(f" tariff task started by amain's guard: {started1}")
print(f" tariff_plan: {None if c1.tariff_plan is None else c1.tariff_plan.words}")
print(f" mode_runs: {_last['runs']}")
print(f" mode_tick reason / roles: "
f"{None if _last['tick'] is None else (_last['tick'].why, dict(_last['tick'].roles))}")
print(f" Batteries screen why_key: A {why_of(c1, A)}, B {why_of(c1, B)}")
print(f" cycles with B charging: {len(charging_b(r1))} of {len(r1)}, "
f"B soc {_first1['soc']['b']:.1f} -> {_last['soc']['b']:.1f}")
check("the tariff refresh task is started", started1)
check("...and it adds no network call: with price_provider off the feed "
"loader is never reached (it is replaced by one that fails)",
not FETCHES and not _price.provider_configured(opts()))
check("a fresh tariff plan with prices is held", present(_last)
and _last["plan"] is not None and _last["plan"].has_prices)
check("the price runs exist (mode_runs is not None) and carry a cheap run "
"that holds in the night period",
present(_last) and _last["runs"] is not None and _last["runs"].on
and _last["runs"].cheap is not None
and _last["runs"].cheap.holds(_last["now"]))
check("the Cheap hours battery charges from the grid in the night period",
len(charging_b(r1)) > len(r1) // 2
and present(_last, _first1)
and _last["soc"]["b"] > _first1["soc"]["b"] + 0.3)
check("...and the Leftover solar battery does not (no sun, no forecast)",
not any(r["p"]["a"] < -200.0 for r in r1))
check("...the reason is not 'no prices': neither battery reads no_prices",
why_of(c1, A) != "no_prices" and why_of(c1, B) != "no_prices")
check("...only the Cheap hours battery got the grid charge role",
present(_last) and _last["tick"] is not None
and _last["tick"].roles.get(B) == modetrade.CHARGE
and _last["tick"].roles.get(A) != modetrade.CHARGE)
# --------------------------------------------------------------------------- #
print("\n3. the same with a gap below the cycle cost")
c3, r3, started3 = drive(opts(cost=0.20))
_l3 = nth(r3, -1, "the last cycle")
print(f" started {started3}; mode_runs {_l3['runs']}; "
f"why A {why_of(c3, A)}, B {why_of(c3, B)}")
check("the task is started and the plan is there, so mode_runs is not None",
started3 and present(_l3) and _l3["plan"] is not None
and _l3["runs"] is not None)
check("the reason is the gap: no_spread ('Prices differ too little'), "
"never no_prices, on both batteries",
why_of(c3, A) == "no_spread" and why_of(c3, B) == "no_spread"
and present(_l3) and _l3["runs"] is not None
and not _l3["runs"].on)
check("...and no battery charges from the grid",
not charging_b(r3) and not any(r["p"]["a"] < -200.0 for r in r3))
# --------------------------------------------------------------------------- #
print("\n4. Home and the Batteries screen agree")
hp = c1.home_plan
_bars = [b for b in hp["prices"] if b["eur_kwh"] is not None]
_words = {b["why_key"]: b["why"] for b in c1.battery_cards}
print(f" Home: has_prices {hp['has_prices']}, {len(_bars)} priced bars, "
f"verdict {hp['verdict_key']}, lines {hp['lines']}")
print(f" Batteries: {[(b['name'], b['why_key'], b['why']) for b in c1.battery_cards]}")
check("Home prices the day, and the Batteries screen does not say it has no "
"prices", hp["has_prices"] and _bars
and all(b["why_key"] != "no_prices" for b in c1.battery_cards))
check("Home's plan has the grid charge block for the Cheap hours battery "
"(the 'trade' verdict) and no 'no prices have arrived' line",
hp["verdict_key"] == "trade"
and any(r["kind"] == "grid" for r in hp["runs"])
and not any("no prices have arrived" in ln for ln in hp["lines"]))
hp3 = c3.home_plan
check("with the gap too small both say so: Home 'price gap is too small', "
"the batteries 'differ too little'; neither says no prices",
any("price gap is too small" in ln for ln in hp3["lines"])
and all(b["why_key"] == "no_spread" for b in c3.battery_cards))
# --------------------------------------------------------------------------- #
print("\n5. UX-55: every battery Solar first, static day/night, no feed")
SF = {"a": (True, "solar_first", False), "b": (True, "solar_first", False)}
c5, r5, s5 = drive(opts(), choice=SF, minutes=5)
print(f" Home lines: {c5.home_plan['lines']}")
check("Home no longer says 'no prices have arrived' with every battery Solar "
"first on a static profile",
s5 and not any("no prices have arrived" in ln
for ln in c5.home_plan["lines"]))
c5b, r5b, s5b = drive(opts(profile=None), choice=SF, minutes=5)
print(f" no profile, no feed: started {s5b}, Home lines "
f"{c5b.home_plan['lines']}")
check("...and with no profile and no feed there are truly no prices, and "
"Home still says so",
any("no prices have arrived" in ln for ln in c5b.home_plan["lines"]))
# --------------------------------------------------------------------------- #
print("\n6. nothing else moved")
c6, r6, s6 = drive(opts(modes=False), choice={}, minutes=5)
check("modes off, arbitrage off, no feed: no task, no plan, no price runs "
"(as before)", not s6 and c6.tariff_plan is None
and all(r["runs"] is None for r in r6))
print()
if fails:
print(f"{len(fails)} FAILED:")
for f in fails:
print(f" - {f}")
sys.exit(1)
print("all checks passed")