"""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")