"""Runnable check for TARIFF-24 round 2 (AUDIT-02 F1). `python3 test_tariff24_weather.py` With battery modes on, the price gap a mode needs was always widened x1.5 (`Uncertainty.NO_WEATHER`) even with a solar forecast read. Now the doubt is added only when no usable forecast is read (`arbitrage.weather_uncertainty()`), in the run plan (`_price_runs()`), the charge gate (`_mode_roles()`) and Home's mirror (`homeview.trade_verdict()`). Modes off is untouched. DECISIONS/TARIFF-24-modes-need-the-tariff-task.md. 1. The rule itself, and that modes off keeps the doubt. 2. Day/night 0.34/0.26 (gap 0.08), modes on, real Controller, night period: with a forecast at cycle cost 0.07 the cheap run is planned and the Cheap hours battery charges; without one, or with a stale one, the same cost needs 0.105 and the reason is `not_worth` / "Prices differ too little". 3. A dynamic day with a 0.12 gap at cost 0.10: on with a forecast, not worth without. 4. The forecast coming (the gate follows in the same cycle) and going mid-run: a run under way keeps its gap to its end, the next run needs the wider one; a forecast unavailable for one or two reads mid-run does not stop or restart the charge. 5. Home agrees with control in each case (Batteries Why, Home line, and `trade_verdict()`, Home's mirror of the gate). Mutations (MUTATION.md, purged copies, graded over this file): the doubt always added (the old code) fails parts 2 to 5; never added fails the without-forecast rows; Home's mirror left on the old rule fails part 5. """ import asyncio import logging import math 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 arbitrage, homeview, modetrade # noqa: E402 from app.main import Controller # noqa: E402 from app.p1 import make_sample, split_signed # noqa: E402 from app.price import (PricePoint, PriceProduct, PriceRead, # noqa: E402 Uncertainty, brussels) from app.static_tariff import TariffPlan # 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" PROFILE = ('{"periods": [' '{"name": "night", "days": "all", "from": "22:00", "to": "07:00",' ' "eur_per_kwh": 0.26},' '{"name": "day", "days": "all", "from": "07:00", "to": "22:00",' ' "eur_per_kwh": 0.34}]}') L = timezone(timedelta(hours=1)) NIGHT = datetime(2026, 10, 30, 23, 0, tzinfo=L).astimezone(timezone.utc) CHOICE = {"a": (True, "leftover_solar", False), "b": (True, "cheap_hours", False)} FC_TODAY, FC_TOMORROW = "sensor.fc_today", "sensor.fc_tomorrow" TARIFF_REFRESH_S = m.TARIFF_REFRESH_S def opts(cost, *, arb=False, sensors=True): o = dict(rp.options(True, arb, 2)) o.update(cycle_cost_threshold_eur=cost, static_profile=PROFILE) if sensors: o.update(pv_forecast_entity=FC_TODAY, pv_forecast_tomorrow_entity=FC_TOMORROW) return o def dynamic_points(start): """A dynamic day-ahead shape, 0.16 .. 0.28 (gap 0.12), 15-minute points.""" out, t = [], start.replace(minute=0, second=0, microsecond=0) for _ in range(48 * 4): h = brussels(t).hour + brussels(t).minute / 60 p = 0.22 + 0.06 * math.cos((h - 19) / 24 * 2 * math.pi) out.append(PricePoint(start=brussels(t), eur_per_kwh=round(p, 4), provisional=False)) t += timedelta(minutes=15) return out def sensor(kwh, at): return {"state": f"{kwh}", "attributes": {"unit_of_measurement": "kWh"}, "last_updated": at.isoformat(), "last_reported": at.isoformat()} def forecast_mode(mode, t, now): """What the two forecast sensors read: 'fresh', 'stale', 'gone', or a function of the simulated seconds that returns one of those.""" if callable(mode): mode = mode(t) if mode == "gone": return {"state": "unavailable", "attributes": {}} at = now if mode == "fresh" else now - timedelta(hours=5) return at def take(c, now): """What Home and the Batteries screen say now, and Home's mirror of the gate (it reads the trade switch, so ask it as a site that has it on next to the modes).""" keep = c.o c.o = dict(keep, arbitrage_enabled=True) try: mirror, why = homeview.trade_verdict(c, c.fresh_tariff_plan(now), now) finally: c.o = keep return dict(home=homeview.build_plan(c, now, False, True), cards=homeview.battery_rows(c, False, True), mirror=mirror, mirror_why=why) def drive(o, *, fc="fresh", points=None, minutes=20, tick=5.0, house_w=300.0, soc0=None, snap_at=()): """-> (controller, rows). `points`: a feed-shaped plan injected every cycle (no task); else the static profile via the real tariff task.""" store = rp._Store() for u, (use, mode, sell) in CHOICE.items(): for k, val in (("pack_use", use), ("pack_mode", mode), ("pack_sell", sell)): store.data[unit_key(f"inverter-{u}", k)] = val rows = [] snaps = {} class H(rp.Hass): async def state(self, e): now = NIGHT + timedelta(seconds=self.t) if fc is None: return None at = forecast_mode(fc, self.t, now) if isinstance(at, dict): return at return sensor(0.0 if e == FC_TODAY else 12.0, at) async def go(): hass = H(("a", "b"), lambda t: house_w, soc0 or {"a": 40.0, "b": 40.0}) tmp = tempfile.mkdtemp(prefix="t24w-") 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 = [NIGHT] 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 pts = None if points is None else points task = None if pts is None: task = asyncio.create_task(c.run_tariff_refresh()) try: t = 0.0 while t < minutes * 60: now = NIGHT + timedelta(seconds=t) hass.t = t clock[0] = now if pts is not None: c.tariff_plan = TariffPlan( PriceRead.series(pts, PriceProduct.DAY_AHEAD, "generic_dayahead", now), None, "provider") c.tariff_at = now elif t == 0.0 or t % TARIFF_REFRESH_S == 0.0: # The task's own 30 min wait is wall clock, so the # simulated clock restarts it: one refresh per period. if task is not None and t > 0.0: task.cancel() try: await task except asyncio.CancelledError: pass task = asyncio.create_task(c.run_tariff_refresh()) for _ in range(500): if c.tariff_plan is not None and ( c.tariff_at is not None and c.tariff_at >= now): 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) rp_ = c.mode_runs rows.append(dict( t=t, now=now, fc_none=c.solar_fc is None, runs_on=None if rp_ is None else rp_.on, reason=None if rp_ is None else rp_.reason, roles=dict(c.mode_tick.roles) if c.mode_tick else {}, why=None if c.mode_tick is None else c.mode_tick.why, charging=hass.plants["b"].p < -200.0, soc_b=hass.plants["b"].soc, cheap_end=None if rp_ is None or rp_.cheap is None else rp_.cheap.end)) if t in snap_at: snaps[t] = take(c, now) t += tick last = take(c, clock[0]) c.home_plan, c.cards = last["home"], last["cards"] c.mirror, c.mirror_why = last["mirror"], last["mirror_why"] c.snaps = snaps finally: if task is not None: task.cancel() try: await task except asyncio.CancelledError: pass m.datetime, homeview.datetime = real_dt, real_hv return c return asyncio.run(go()), rows def why_of(c, name): card = first((b for b in c.cards if b["name"] == name), f"card {name}") return card["why_key"] # --------------------------------------------------------------------------- # print("1. the rule") NW = frozenset({Uncertainty.NO_WEATHER}) check("modes on with a forecast read: no doubt", arbitrage.weather_uncertainty( True, object()) == frozenset()) check("modes on with no forecast (None): the doubt", arbitrage.weather_uncertainty( True, None) == NW) check("modes off, forecast or not: the doubt, as before", arbitrage.weather_uncertainty(False, object()) == NW and arbitrage.weather_uncertainty(False, None) == NW) # --------------------------------------------------------------------------- # print("\n2. day/night 0.34 / 0.26 (gap 0.08), night period") c1, r1 = drive(opts(0.07), fc="fresh") l1 = nth(r1, -1, "last cycle") print(f" forecast read, cost 0.07: mode_runs on={l1['runs_on']} " f"reason={l1['reason']}; B charging {sum(r['charging'] for r in r1)} of " f"{len(r1)}; Why A {why_of(c1, A)} B {why_of(c1, B)}") check("with a forecast at cost 0.07 the cheap run is planned (needed 0.07 < " "0.08) and the Cheap hours battery charges in the night period", present(l1) and not l1["fc_none"] and l1["runs_on"] is True and sum(r["charging"] for r in r1) > len(r1) // 2 and l1["roles"].get(B) == modetrade.CHARGE) for label, mode in (("no forecast sensor reads", "gone"), ("a stale forecast", "stale")): c, rr = drive(opts(0.07), fc=mode) ll = nth(rr, -1, "last cycle") print(f" {label}, cost 0.07: on={ll['runs_on']} reason={ll['reason']}; " f"B charging {sum(r['charging'] for r in rr)}; Why A " f"{why_of(c, A)} B {why_of(c, B)}") check(f"{label}: the same cost needs 0.105 > 0.08, so not_worth and " f"'Prices differ too little', no grid charge", present(ll) and ll["fc_none"] and ll["runs_on"] is False and ll["reason"] == "not_worth" and why_of(c, A) == "no_spread" and why_of(c, B) == "no_spread" and not any(r["charging"] for r in rr)) c0, r0 = drive(opts(0.07, sensors=False), fc=None) check("no forecast sensor configured at all: not_worth too (as 0.19.0)", present(nth(r0, -1)) and r0[-1]["runs_on"] is False) c2, r2 = drive(opts(0.10), fc="fresh") check("at the shipped cost 0.10 even a forecast does not make 0.08 enough " "(needs 0.10): still not_worth, honestly", present(nth(r2, -1)) and r2[-1]["runs_on"] is False and r2[-1]["reason"] == "not_worth") # --------------------------------------------------------------------------- # print("\n3. a dynamic day, gap 0.12, cost 0.10") pts = dynamic_points(NIGHT - timedelta(hours=1)) cd, rd = drive(opts(0.10), fc="fresh", points=pts) cn, rn = drive(opts(0.10), fc="gone", points=pts) _ld, _ln = nth(rd, -1, "last cycle"), nth(rn, -1, "last cycle") print(f" with forecast: on={_ld['runs_on']} reason={_ld['reason']}; " f"without: on={_ln['runs_on']} reason={_ln['reason']}") check("with a forecast the run plan is on (needs 0.10 < 0.12)", present(nth(rd, -1)) and rd[-1]["runs_on"] is True and why_of(cd, A) != "no_spread") check("without one it is not_worth (needs 0.15 > 0.12) and says so", present(nth(rn, -1)) and rn[-1]["runs_on"] is False and rn[-1]["reason"] == "not_worth" and why_of(cn, B) == "no_spread") # --------------------------------------------------------------------------- # print("\n4. the forecast going and coming mid-run (a run under way keeps its gap)") CUT = 600.0 # 10 min in SOC = {"a": 40.0, "b": 60.0} # B's cheap run is then about 2 h def going(t): return "fresh" if t < CUT else "gone" def coming(t): return "gone" if t < CUT else "fresh" def flap(n_reads): """Unavailable for `n_reads` forecast reads (60 s apart) from CUT.""" return lambda t: "gone" if CUT <= t < CUT + 60.0 * n_reads else "fresh" MID, AFTER = 3000.0, 7800.0 cg, rg = drive(opts(0.07), fc=going, minutes=135, soc0=SOC, snap_at=(MID, AFTER)) cc, rc = drive(opts(0.07), fc=coming, minutes=25) _end = nth([r["cheap_end"] for r in rg if r["cheap_end"] is not None], 0, "the cheap run's end") _end_t = (_end - NIGHT).total_seconds() if present(_end) else 1e9 lost = [r for r in rg if r["fc_none"]] print(f" going: cheap run ends at t={_end_t:.0f} s; forecast lost from " f"t={lost[0]['t'] if lost else None}") check("going: the run planned with the forecast ends before the snapshot " "taken after it (the scenario is long enough)", present(_end) and CUT + 300.0 < _end_t < AFTER - 300.0 and bool(lost) and lost[0]["t"] - CUT <= 60.0) check("going: the forecast read is lost mid-run and the run under way KEEPS " "its gap: the plan stays on and B keeps charging, cycle after cycle, " "until B is full or the run ends", all(r["runs_on"] is True and (r["roles"].get(B) == modetrade.CHARGE or r["soc_b"] >= 94.0) for r in rg if r["fc_none"] and r["t"] < _end_t - 60.0) and all(r["charging"] or r["soc_b"] >= 94.0 for r in rg if 60.0 <= r["t"] < _end_t - 60.0) and max(r["soc_b"] for r in rg if r["fc_none"]) >= 90.0) check("going: from the run's end the wider gap is back: the next run is " "not_worth (needs 0.105 at 0.07), no new run, and no grid charge", all(r["runs_on"] is False and r["reason"] == "not_worth" for r in rg if r["t"] >= _end_t + 30.0) and not any(r["charging"] for r in rg if r["t"] >= _end_t + 60.0)) _sm, _sa = cg.snaps.get(MID), cg.snaps.get(AFTER) check("going, Home agrees mid-run: its mirror still allows, the Batteries " "screen says it charges from the grid, and Home does not say the gap " "is too small", _sm is not None and _sm["mirror"] is not None and first((b for b in _sm["cards"] if b["name"] == B), "a card")["why_key"] == "grid_charge" and not any("price gap is too small" in ln for ln in _sm["home"]["lines"])) check("going, Home agrees after the run: its mirror refuses, the Batteries " "screen says 'differ too little' and Home says the gap is too small", _sa is not None and _sa["mirror"] is None and first((b for b in _sa["cards"] if b["name"] == B), "a card")["why_key"] == "no_spread" and any("price gap is too small" in ln for ln in _sa["home"]["lines"])) for n in (1, 2): cf, rf = drive(opts(0.07), fc=flap(n), minutes=30, soc0=SOC, snap_at=(CUT + 30.0,)) gone = [r for r in rf if r["fc_none"]] print(f" flap, {n} read(s) lost: {len(gone)} cycles without a " f"forecast; B charging in {sum(r['charging'] for r in rf)} of " f"{len(rf)}") check(f"flap: the forecast is unavailable for {n} read(s) mid-run: the " f"plan stays on and B charges without a break (no stop, no " f"restart)", bool(gone) and all(r["runs_on"] is True for r in rf) and all(r["charging"] and r["roles"].get(B) == modetrade.CHARGE for r in rf if r["t"] >= 60.0)) sf = cf.snaps.get(CUT + 30.0) check(f"flap, {n} read(s): Home agrees while the read is lost (mirror " f"allows, Batteries 'charges from the grid')", sf is not None and sf["mirror"] is not None and first((b for b in sf["cards"] if b["name"] == B), "a card")["why_key"] == "grid_charge") def flips(rows): """Cycles where the forecast read changed, and whether the plan and the charge gate changed in that very cycle.""" out = [] for i in range(1, len(rows)): if rows[i]["fc_none"] != rows[i - 1]["fc_none"]: out.append((i, rows[i]["t"], rows[i]["runs_on"], rows[i]["charging"], rows[i - 1]["charging"])) return out fcm = flips(rc) print(f" coming: forecast read arrives at cycle/t {fcm}") check("coming (no run under way before): not_worth until the read is back, " "then on in the same cycle, and the battery charges", len(fcm) == 1 and fcm[0][1] - CUT <= 60.0 and fcm[0][2] is True and all(r["runs_on"] is False for r in rc if r["fc_none"]) and all(r["runs_on"] is True for r in rc if not r["fc_none"]) and any(r["charging"] for r in rc if r["t"] >= fcm[0][1] + 40.0)) # --------------------------------------------------------------------------- # print("\n5. Home agrees with control") CASES = (("forecast, cost 0.07", c1, r1, True), ("no forecast, cost 0.07", *drive(opts(0.07), fc="gone"), False), ("dynamic, forecast", cd, rd, True), ("dynamic, no forecast", cn, rn, False)) for label, cx, rx, on in CASES: last = nth(rx, -1, "last cycle") hp = cx.home_plan mirror_ok = cx.mirror is not None print(f" {label}: control on={last['runs_on']}; Home mirror " f"{'allows' if mirror_ok else 'refuses'} ({cx.mirror_why}); " f"lines {hp['lines'][:1]}; Why B {why_of(cx, B)}") check(f"{label}: Home's mirror of the gate allows exactly when control " f"plans", present(last) and mirror_ok is on and last["runs_on"] is on) check(f"{label}: the Batteries screen and the Home line say the gap is " f"too small exactly when control does", (why_of(cx, B) == "no_spread") is (not on) and any("price gap is too small" in ln for ln in hp["lines"]) is (not on)) if label.startswith("dynamic"): continue # its cheap run is tomorrow morning: not on today's chart check(f"{label}: Home draws a grid charge block exactly when control " f"charges", any(r["kind"] == "grid" for r in hp["runs"]) is on) print() if fails: print(f"{len(fails)} FAILED:") for f in fails: print(f" - {f}") sys.exit(1) print("all checks passed")