From d667f3f3a87f4b6e59829d339cbcd1641e9cff65 Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Wed, 28 Oct 2026 01:02:32 +0100 Subject: [PATCH 1/5] TARIFF-24: battery modes start the tariff task (hotfix 0.19.1) With battery modes on, a day/night or fixed static_profile, no price feed and arbitrage off, tariff_refresh_wanted() was false: no tariff task, no tariff_plan, mode_runs None, so Leftover solar / Cheap hours never charged from the grid and the Batteries screen said "No prices". packmode.enabled(opts) is now a third term of the gate. With price_provider off the task is pure arithmetic on static_profile, so no HTTPS call is added. Also: version 0.19.1, CHANGELOG entry, DECISIONS note, test_tariff24.py, and a shorter tariff_refresh_wanted() docstring (history stays in DECISIONS). Co-Authored-By: Claude --- goodwe_controller/CHANGELOG.md | 26 ++ .../TARIFF-24-modes-need-the-tariff-task.md | 35 ++ goodwe_controller/app/main.py | 66 +--- goodwe_controller/config.yaml | 2 +- goodwe_controller/test_tariff24.py | 359 ++++++++++++++++++ 5 files changed, 433 insertions(+), 55 deletions(-) create mode 100644 goodwe_controller/DECISIONS/TARIFF-24-modes-need-the-tariff-task.md create mode 100644 goodwe_controller/test_tariff24.py diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index 5131424..c098410 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -1,5 +1,31 @@ # Changelog +## 0.19.1 + +**A patch bump: one fix for battery modes on a day/night or fixed tariff, and no option changes.** +No option was added, removed or renamed, and no default changed (86 options). +**Existing installs need no option change:** options that start 0.19.0 also +start 0.19.1. The change below applies with one inverter or two. + +**Battery modes now see a day/night or fixed tariff.** With battery modes on, a day/night or fixed tariff and no price service, the batteries never got the prices: "Cheap hours" never charged from the grid at the cheap rate, and the Batteries screen said "No prices". They get them now, with no price service and no internet call added. + +Contents of this release, in merge order since 0.19.0 (`793a676`): + +| | | +|---|---| +| `TARIFF-24` | With battery modes on, the add-on now also resolves the tariff when no price service and no price trading are set, so a day/night or fixed tariff gives the batteries their price runs ([[TARIFF-24]]). | + +### Known issues + +The list under `## 0.19.0` below still applies. + +### If you are crossing from a version older than 0.10.0 + +`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the +withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and +`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the +bare options (`jq '.data.options'`) first. + ## 0.19.0 **A minor bump: price trading behaves differently, plus a fix for a false warning, the Home price chart in 15-minute bars, more Dutch text and EMS-port wording, and no option changes.** diff --git a/goodwe_controller/DECISIONS/TARIFF-24-modes-need-the-tariff-task.md b/goodwe_controller/DECISIONS/TARIFF-24-modes-need-the-tariff-task.md new file mode 100644 index 0000000..4fc0411 --- /dev/null +++ b/goodwe_controller/DECISIONS/TARIFF-24-modes-need-the-tariff-task.md @@ -0,0 +1,35 @@ +# TARIFF-24 - battery modes need the tariff task + +**Status: FIXED in 0.19.1 (hotfix from 0.19.0, owner go 2026-10-28).** +Follows TARIFF-10 (fetching is not trading). Tests: `test_tariff24.py`. + +**The defect.** The control cycle gets prices only from +`Controller.tariff_plan`, which only `run_tariff_refresh()` stores. `amain()` +starts that task when `tariff_refresh_wanted(opts)`, which was +`provider_configured(opts) or arbitrage.enabled(opts)`. Battery modes read the +plan but were not a term. With battery modes on, a day/night or fixed +`static_profile`, `price_provider: off` and `arbitrage_enabled: false` (the +site trade switch is not read with modes on) no task ran. Then +`fresh_tariff_plan()` was None, `_price_runs()` was None and `mode_runs` was +None, so Leftover solar and Cheap hours never charged from the grid. The +Batteries screen said "No prices, so it follows the house" while Home priced +the day from `static_plan()` itself (`homeview.build_plan()`). + +**The fix** (`main.py`, `tariff_refresh_wanted()`). `packmode.enabled(opts)` +is a third term. The gate stays a widening: nothing that started the task +before stops. No HTTPS call is added: with `price_provider: off`, +`resolve_tariff()` is the pure `static_plan()`. + +**Not changed.** Modes off, arbitrage off, no feed still starts no task. The +control law, the price runs and every message are untouched. + +**One rule, not two.** Gating on "modes on and a static profile" was +considered. It saves one arithmetic pass every 30 min on a site with modes on +and no prices at all, and it adds a second condition that has to track what +`resolve_tariff()` accepts. With no profile and no feed the plan is `no_data`, +exactly what the cycle sees without the task. + +**UX-55.** Home said "no prices have arrived" (`mode_runs` None) with every +battery on Solar first. The same cause, and this fix clears it for a site with +a static profile (`test_tariff24.py` part 5). A site with no profile and no +feed still has no prices, and Home still says so. diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index bc71984..5d4dcea 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -174,57 +174,17 @@ TARIFF_MAX_AGE_S = 3 * TARIFF_REFRESH_S def tariff_refresh_wanted(opts: dict) -> bool: - """TARIFF-10. Does this install need the background tariff refresh at all? + """Does this install need the background tariff refresh? (TARIFF-10, TARIFF-24) - THE DEFECT THIS PREVENTS, and it shipped: the gate below used to be - `arbitrage.enabled(opts)` alone. That option defaults to `false`, and - `run_tariff_refresh` is the only caller of `resolve_tariff()`, which is the - only caller of `price_history.carry()` - so on a shipped install with a - provider configured NOTHING was ever fetched, no plan was ever resolved, - `/data/price_history.json` was never created, the *Price history* row read - `empty` forever, and TARIFF-09's short-horizon warning could not fire - because there was no fetch to warn about. Measured over a 40 h soak of two - instances side by side: zero feed requests and `present: false` across all - 802 samples on the shipped default, against a resolved plan from the same - feed on the instance with arbitrage armed. ⚠️ AND `DOCS.md` HAS - DESCRIBED THE BEHAVIOUR BELOW SINCE THE COMMIT THAT WROTE THE GATE: - OPT-04 (bdd7f18) added the refresh task, the `arbitrage.enabled()` guard on - it, and the sentence saying a configured provider still fetches, all in one - change. config.yaml has said it since TARIFF-02 (852fc7c). - - Two terms, and they answer two different questions: - - provider_configured(opts) -> "the operator asked for a price feed", so - fetch it, validate it and record the day - (§8.3). This is the term TARIFF-10 ADDS. - arbitrage.enabled(opts) -> "the operator armed trading". This term is - what the gate USED to be, kept verbatim. - - ⚠️ IT IS AN `or`, SO THE GATE IS A STRICT WIDENING. Every configuration - that started this task before still starts it; none that did not start it - stops anything. That matters because the second term is not redundant: - `price_provider: off` + a `static_profile` + `arbitrage_enabled: true` is a - configuration validate.py explicitly ACCEPTS, and it trades - measured, at - `cycle_cost_threshold_eur: 0.05` on a 0.20/0.284 day/night profile, - `ARBITRAGE_CHARGE -5000 W`. Dropping that term to make the gate read - `provider_configured(opts)` alone would leave `self.tariff_plan` at None on - that site forever and `decide()` would answer "no tariff plan has been - resolved yet" on every cycle - silently disarming a strategy the UI says is - running, which is the exact failure validate.py's OPT-04 rules exist to - refuse. - - ⚠️ AND NO CONFIGURATION ACQUIRES A PERIODIC HTTPS CALL FROM THIS TICKET. - With `price_provider` off, `resolve_tariff()` returns `static_plan()` - pure - arithmetic, no socket - so the second term starts a task that fetches - nothing. The true shipped default (no provider, no arbitrage) starts no - task at all. What is fetched is decided by the FIRST term only. - - ⚠️ THIS IS NOT A TRADING SWITCH. It decides whether prices are LOOKED UP. - Whether they move energy is `arbitrage.enabled()` at the one call site in - `cycle()`, and TARIFF-10 did not touch it: `arbitrage_enabled: false` still - means no trade, ever, on any configuration. + Yes with a price feed, with trading on price armed, or with battery modes + on: the modes read `self.tariff_plan` for their price runs. With + `price_provider` off the refresh is pure arithmetic on `static_profile`, so + no term here adds an HTTPS call. It decides whether prices are looked up, + never whether energy moves. See DECISIONS/TARIFF-10-fetching-is-not-trading.md + and DECISIONS/TARIFF-24-modes-need-the-tariff-task.md. """ - return provider_configured(opts) or arbitrage.enabled(opts) + return (provider_configured(opts) or arbitrage.enabled(opts) + or packmode.enabled(opts)) def load_options() -> dict: @@ -8051,11 +8011,9 @@ async def amain() -> None: # ESP32 heartbeat. A fetch on that path is a supplier's TLS timeout able # to push this add-on towards layer 1's 30 s veto. See TARIFF_REFRESH_S. # - # ⚠️ STARTED WHEN A PROVIDER IS CONFIGURED, *OR* WHEN TRADING IS ARMED - # - TARIFF-10, and it used to be the second term alone. `arbitrage_ - # enabled` ships `false`, so a site that had selected a price feed - # fetched nothing at all; the whole argument, the measurement and what - # the `or` is protecting are in `tariff_refresh_wanted()` above. + # ⚠️ STARTED FOR A PROVIDER, FOR ARMED TRADING OR FOR BATTERY MODES: + # see `tariff_refresh_wanted()` above and DECISIONS/TARIFF-10 and + # TARIFF-24 for why each term is there. # # ⚠️ IT DOES NOT ARM ANYTHING. Fetching a price and trading on one are # two decisions; this is the first, and `cycle()`'s diff --git a/goodwe_controller/config.yaml b/goodwe_controller/config.yaml index ba01954..f515955 100644 --- a/goodwe_controller/config.yaml +++ b/goodwe_controller/config.yaml @@ -1,5 +1,5 @@ name: GoodWe RS485 Controller -version: "0.19.0" +version: "0.19.1" slug: goodwe_controller description: >- Drives a GoodWe ES/BP battery inverter over its RS485 EMS port by sending it a diff --git a/goodwe_controller/test_tariff24.py b/goodwe_controller/test_tariff24.py new file mode 100644 index 0000000..4fccf6f --- /dev/null +++ b/goodwe_controller/test_tariff24.py @@ -0,0 +1,359 @@ +"""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. Nothing else moved: modes off, the site trade on a static profile, and a + feed site start the same task as before. +""" + +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 +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 = next(n for n in ast.walk(_TREE) + if isinstance(n, ast.AsyncFunctionDef) and n.name == "amain") +_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): + return next(b["why_key"] for b in c.battery_cards if b["name"] == name) + + +# --------------------------------------------------------------------------- # +print("\n2. end to end, night, modes on, static day/night, no feed, no arbitrage") +c1, r1, started1 = drive(opts()) +_last = r1[-1] +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 {r1[0]['soc']['b']:.1f} -> {r1[-1]['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", _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", + _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 r1[-1]["soc"]["b"] > r1[0]["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", + r1[-1]["tick"] is not None + and r1[-1]["tick"].roles.get(B) == modetrade.CHARGE + and r1[-1]["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 = r3[-1] +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 _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 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") -- 2.54.0 From 230ba0b57a2fb6effd6d172895aa3c237cafbc65 Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Wed, 28 Oct 2026 01:09:39 +0100 Subject: [PATCH 2/5] TARIFF-24: test file follows the repo's suite rules (MUTATION.md pointer, named absence) Co-Authored-By: Claude --- goodwe_controller/test_tariff24.py | 47 ++++++++++++++++++++---------- 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/goodwe_controller/test_tariff24.py b/goodwe_controller/test_tariff24.py index 4fccf6f..f5b74a4 100644 --- a/goodwe_controller/test_tariff24.py +++ b/goodwe_controller/test_tariff24.py @@ -13,8 +13,15 @@ the profile itself. DECISIONS/TARIFF-24-modes-need-the-tariff-task.md. Leftover solar and one Cheap hours. 3. The same with a gap below the cycle cost. 4. Home and the Batteries screen agree. -5. Nothing else moved: modes off, the site trade on a static profile, and a - feed site start the same task as before. +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 @@ -32,6 +39,7 @@ 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 @@ -101,8 +109,9 @@ def _guards_of(node, guards=()): _TREE = ast.parse(open(os.path.join(HERE, "app", "main.py"), encoding="utf-8").read()) -_AMAIN = next(n for n in ast.walk(_TREE) - if isinstance(n, ast.AsyncFunctionDef) and n.name == "amain") +_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"] @@ -252,13 +261,16 @@ def charging_b(rows): def why_of(c, name): - return next(b["why_key"] for b in c.battery_cards if b["name"] == 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 = r1[-1] +_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']}") @@ -266,41 +278,44 @@ 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 {r1[0]['soc']['b']:.1f} -> {r1[-1]['soc']['b']:.1f}") + 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", _last["plan"] is not None - and _last["plan"].has_prices) +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", - _last["runs"] is not None and _last["runs"].on + 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 r1[-1]["soc"]["b"] > r1[0]["soc"]["b"] + 0.3) + 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", - r1[-1]["tick"] is not None - and r1[-1]["tick"].roles.get(B) == modetrade.CHARGE - and r1[-1]["tick"].roles.get(A) != modetrade.CHARGE) + 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 = r3[-1] +_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 _l3["plan"] is not None and _l3["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)) -- 2.54.0 From 382b0ea41182f0622f789369198afc4c58bcea29 Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Wed, 28 Oct 2026 15:08:21 +0100 Subject: [PATCH 3/5] TARIFF-24 round 2: a usable solar forecast replaces the "no weather" x1.5 (AUDIT-02 F1) With battery modes on, the price gap a mode needs was always widened x1.5 by Uncertainty.NO_WEATHER, even with a solar forecast read, so a 0.08 day/night gap never traded at the shipped cycle cost. The doubt is now added only when no usable forecast is read (solar_fc is None, the day plan's own test), in the run plan (_price_runs), the charge gate (_mode_roles) and Home's mirror (homeview.trade_verdict). The rule is one helper, arbitrage.weather_uncertainty(); the modes-off trade path (main.py cycle) keeps its literal NO_WEATHER. DOCS "Battery modes" sentence, CHANGELOG 0.19.1 line and the DECISIONS note follow. Tests: test_tariff24_weather.py. Co-Authored-By: Claude --- goodwe_controller/CHANGELOG.md | 8 +- .../TARIFF-24-modes-need-the-tariff-task.md | 29 ++ goodwe_controller/DOCS.md | 2 +- goodwe_controller/app/arbitrage.py | 15 +- goodwe_controller/app/homeview.py | 6 +- goodwe_controller/app/main.py | 4 +- goodwe_controller/test_tariff24_weather.py | 354 ++++++++++++++++++ 7 files changed, 409 insertions(+), 9 deletions(-) create mode 100644 goodwe_controller/test_tariff24_weather.py diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index c098410..a3eca8c 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -2,18 +2,20 @@ ## 0.19.1 -**A patch bump: one fix for battery modes on a day/night or fixed tariff, and no option changes.** +**A patch bump: two fixes for battery modes, and no option changes.** No option was added, removed or renamed, and no default changed (86 options). **Existing installs need no option change:** options that start 0.19.0 also -start 0.19.1. The change below applies with one inverter or two. +start 0.19.1. The changes below apply with one inverter or two. **Battery modes now see a day/night or fixed tariff.** With battery modes on, a day/night or fixed tariff and no price service, the batteries never got the prices: "Cheap hours" never charged from the grid at the cheap rate, and the Batteries screen said "No prices". They get them now, with no price service and no internet call added. +**A solar forecast now counts for the price gap.** With battery modes on and a solar forecast read, a price mode needs a gap of at least *Battery cycle cost*, not 1.5 times that. Without a forecast it is still 1.5 times. A day/night gap of 0.08 now works at a cycle cost of 0.07 or less. + Contents of this release, in merge order since 0.19.0 (`793a676`): | | | |---|---| -| `TARIFF-24` | With battery modes on, the add-on now also resolves the tariff when no price service and no price trading are set, so a day/night or fixed tariff gives the batteries their price runs ([[TARIFF-24]]). | +| `TARIFF-24` | With battery modes on, the add-on now also resolves the tariff when no price service and no price trading are set, so a day/night or fixed tariff gives the batteries their price runs ([[TARIFF-24]]). With battery modes on and a solar forecast read, the price gap a mode needs is *Battery cycle cost* (widened only for a real-time feed), not 1.5 times that; Home and the Batteries screen use the same rule ([[AUDIT-02]] F1). | ### Known issues diff --git a/goodwe_controller/DECISIONS/TARIFF-24-modes-need-the-tariff-task.md b/goodwe_controller/DECISIONS/TARIFF-24-modes-need-the-tariff-task.md index 4fc0411..e6e6f8a 100644 --- a/goodwe_controller/DECISIONS/TARIFF-24-modes-need-the-tariff-task.md +++ b/goodwe_controller/DECISIONS/TARIFF-24-modes-need-the-tariff-task.md @@ -33,3 +33,32 @@ exactly what the cycle sees without the task. battery on Solar first. The same cause, and this fix clears it for a site with a static profile (`test_tariff24.py` part 5). A site with no profile and no feed still has no prices, and Home still says so. + +## Round 2 (AUDIT-02 F1): the forecast replaces the "no weather" doubt + +**Decided 2026-10-28 (owner), in the same hotfix.** The price gap a mode needs +was `cycle_cost_threshold_eur` x 1.5 (`Uncertainty.NO_WEATHER`) whether or not +a solar forecast was read. At the shipped cost 0.10 that is 0.15, so a +day/night gap (0.08) and many dynamic days never traded, and the screens said +only "Prices differ too little". + +**Why the doubt goes with a forecast.** The widening stands in for not knowing +the weather. The forecast is that knowledge, and the day plan already trusts it +for how much to charge. Trusting it for the plan but doubting it for the gap +was one input judged twice. + +**The rule** (`arbitrage.weather_uncertainty()`). With battery modes on and a +usable forecast (`solar_fc` not None, the day plan's own test) the doubt is +not added: the gap needed is the cycle cost, widened by the price product only. +No forecast, or a stale or unusable one: 1.5 x, as before. It is read in three +places that must agree: the run plan (`_price_runs()`), the charge gate +(`_mode_roles()`) and Home's mirror (`homeview.trade_verdict()`). The forecast +is read at most every 60 s and the plan key already holds "forecast or not", so +the gap follows in the cycle the read changes, both ways. A run already under +way is kept to its end (`priceruns.keep_started()`), as for any change. + +**Not changed.** Modes off (the site trade) keeps the doubt always. The +shipped cost 0.10 still needs a gap of 0.10 even with a forecast: a 0.08 +day/night site must set *Battery cycle cost* to 0.08 or less. Showing the two +numbers on the screens (AUDIT-02 F1 option b) is a separate card. + diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index 27b7b20..c35b20e 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -538,7 +538,7 @@ With battery modes on, these choices replace the *Charging and selling on price* **Cheap and expensive hours.** They come from the published day-ahead prices, as runs of consecutive 15-minute periods, not a single period. - The cheap run is the cheapest stretch long enough to fill the batteries that may charge, at their power. -- An expensive hour costs at least *Battery cycle cost* (`cycle_cost_threshold_eur`, widened as for trading) more than the cheap run. The batteries hold their power until then. +- An expensive hour costs more than the cheap run by at least *Battery cycle cost* (`cycle_cost_threshold_eur`) when a solar forecast is read, and by 1.5 times that when none is (a real-time price feed widens it further). The batteries hold their power until then. - The sale uses the most expensive stretch inside those hours, and never a period below 0. - A carried (not yet published) price is never used. A run that has started runs to its end. - With no prices, or too small a gap, every battery follows the house. diff --git a/goodwe_controller/app/arbitrage.py b/goodwe_controller/app/arbitrage.py index 2fbde48..86ab97e 100644 --- a/goodwe_controller/app/arbitrage.py +++ b/goodwe_controller/app/arbitrage.py @@ -147,7 +147,7 @@ from dataclasses import dataclass from datetime import datetime from .arbiter import ACTUATOR_SITE, P_ARBITRAGE, Claim -from .price import Absence, price_option +from .price import Absence, Uncertainty, price_option _LOG = logging.getLogger("goodwe.arbitrage") @@ -267,6 +267,19 @@ def threshold_eur_kwh(opts: dict) -> float: return v +def weather_uncertainty(modes_on: bool, solar_fc) -> frozenset: + """The "no weather" doubt (x1.5) a trade must clear. TARIFF-24. + + Battery modes read a solar forecast; with one read (`solar_fc` not None) the + doubt is gone. Everything else, and the modes without a forecast, keep it. + One rule for the run plan, the charge gate and Home's mirror of it. See + DECISIONS/TARIFF-24-modes-need-the-tariff-task.md. + """ + if modes_on and solar_fc is not None: + return frozenset() + return frozenset({Uncertainty.NO_WEATHER}) + + def soc_bounds(opts: dict) -> tuple: """(floor, ceiling) in percent. The reserve arbitrage may not spend.""" return (float(price_option(opts, "arbitrage_soc_floor")), diff --git a/goodwe_controller/app/homeview.py b/goodwe_controller/app/homeview.py index dfc6940..2a7cbe7 100644 --- a/goodwe_controller/app/homeview.py +++ b/goodwe_controller/app/homeview.py @@ -22,7 +22,7 @@ from . import arbitrage, messages, peakguard, uitext from .maintenance import CHARGE as M_CHARGE, DRAIN as M_DRAIN, HOLD as M_HOLD from .maintenance import IDLE as M_IDLE, next_slot_start from .packmode import CHEAP_HOURS, GRID_CHARGE_MODES, LEFTOVER_SOLAR, SOLAR_FIRST -from .price import Absence, Uncertainty, brussels, _cover_table +from .price import Absence, brussels, _cover_table from .static_tariff import static_plan from .tariff import BILLING_TZ from .uimodel import REGION_PRESETS, options_to_ui @@ -277,7 +277,9 @@ def trade_verdict(c, plan, now): return None, "no_prices" verdict = plan.arbitrage(peak_guard_active=guard, min_spread_eur_kwh=arbitrage.threshold_eur_kwh(c.o), - uncertainty=frozenset({Uncertainty.NO_WEATHER}), + uncertainty=arbitrage.weather_uncertainty( + bool(getattr(c, "modes_on", False)), + getattr(c, "solar_fc", None)), now=now) return verdict.match( allowed=lambda v: (v, "allowed"), diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 5d4dcea..7283bcb 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -2250,7 +2250,7 @@ class Controller: required = price_required_spread( arbitrage.threshold_eur_kwh(self.o), price_uncertainty_of(plan.read.product) - | {Uncertainty.NO_WEATHER}) + | arbitrage.weather_uncertainty(True, self.solar_fc)) ref = self._ref_cheap if ref is not None and now - ref.end > REF_CHEAP_KEEP: ref = self._ref_cheap = None @@ -2329,7 +2329,7 @@ class Controller: return plan.arbitrage( peak_guard_active=active, min_spread_eur_kwh=arbitrage.threshold_eur_kwh(self.o), - uncertainty=frozenset({Uncertainty.NO_WEATHER}), + uncertainty=arbitrage.weather_uncertainty(True, self.solar_fc), now=now).match( allowed=lambda _v: True, refused=lambda _v: False) if guard is not None and plan is not None and runs is not None: diff --git a/goodwe_controller/test_tariff24_weather.py b/goodwe_controller/test_tariff24_weather.py new file mode 100644 index 0000000..9af4f52 --- /dev/null +++ b/goodwe_controller/test_tariff24_weather.py @@ -0,0 +1,354 @@ +"""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 going and coming mid-run: the gate follows in the same cycle + the forecast read changes, both ways. +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" + + +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 drive(o, *, fc="fresh", points=None, minutes=20, tick=5.0, + house_w=300.0, soc0=None): + """-> (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 = [] + + 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: + for _ in range(500): + 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) + 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)) + t += tick + c.home_plan = homeview.build_plan(c, clock[0], False, True) + c.cards = homeview.battery_rows(c, False, True) + # 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). + c2 = c + c2.o = dict(c.o, arbitrage_enabled=True) + c.mirror, c.mirror_why = homeview.trade_verdict( + c2, c.fresh_tariff_plan(clock[0]), clock[0]) + c.o = dict(c.o, arbitrage_enabled=o.get("arbitrage_enabled", + False)) + 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") +CUT = 600.0 # 10 min in + + +def going(t): + return "fresh" if t < CUT else "gone" + + +def coming(t): + return "gone" if t < CUT else "fresh" + + +cg, rg = drive(opts(0.07), fc=going, minutes=25) +cc, rc = drive(opts(0.07), fc=coming, minutes=25) + + +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 + + +fg, fcm = flips(rg), flips(rc) +print(f" going: forecast read lost at cycle/t {fg}; coming: {fcm}") +check("going: the plan is on with the forecast and not_worth the cycle the " + "read is lost (within one forecast read, 60 s, of the sensor change)", + len(fg) == 1 and fg[0][1] - CUT <= 60.0 and fg[0][2] is False + and all(r["runs_on"] is True for r in rg if not r["fc_none"]) + and all(r["runs_on"] is False for r in rg if r["fc_none"])) +check("...and the grid charge stops with it: no charging after the cycle " + "that lost the forecast, plus the 15 s the inverter ramp takes", + len(fg) == 1 and not any( + r["charging"] for r in rg if r["t"] >= fg[0][1] + 40.0)) +check("coming: 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") -- 2.54.0 From d424b862d82716dd082beefc402f6f40a0a5554e Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Wed, 28 Oct 2026 15:42:50 +0100 Subject: [PATCH 4/5] TARIFF-24 round 2: test_modes_p4 scenarios keep the 0.30 gate they were written for With a forecast read the gap needed is the cycle cost (0.20), not 1.5 x (0.30), so a midday 0.21 gap now plans a grid top-up that four scenarios (sunny, Cheap hours partly, forecast too high, refusal) are not about. They ask for cost 0.30 with the forecast; the no-forecast leg keeps 0.20 (= 0.30 widened). Co-Authored-By: Claude --- goodwe_controller/test_modes_p4.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/goodwe_controller/test_modes_p4.py b/goodwe_controller/test_modes_p4.py index c2079d9..c31e4ee 100644 --- a/goodwe_controller/test_modes_p4.py +++ b/goodwe_controller/test_modes_p4.py @@ -540,6 +540,12 @@ rp.Hass.state = _sensor rp_start = [UTC0] FC_OPTS = {"pv_forecast_entity": "sensor.energy_production_today_remaining", "cycle_cost_threshold_eur": 0.20} +# TARIFF-24 round 2: with a forecast read, a price mode needs the cycle cost +# itself (0.20 here), no longer 1.5 times it (0.30). These scenarios were +# written for a 0.30 gap, and a midday 0.21 gap now plans a grid top-up they +# are not about, so they ask for 0.30 with the forecast; the no-forecast leg +# (`rows_nf`) keeps 0.20, which is 0.30 widened: the same gate on both sides. +GATE_030 = {"cycle_cost_threshold_eur": 0.30} def seed_profile(c): @@ -597,7 +603,8 @@ def invariants(tag, rows, c): # Sunny: 40 kWh forecast. B (Leftover solar) at 30 % needs nothing. -rows, st_, c = run_day(SF_LS, fc=40.0, soc0={"a": 60.0, "b": 30.0}) +rows, st_, c = run_day(SF_LS, fc=40.0, soc0={"a": 60.0, "b": 30.0}, + extra=GATE_030) charge = invariants("sunny", rows, c) _last = nth(rows, -1, "a cycle") print(f" sunny: verdict {present(_last) and _last['plan'] and _last['plan'].verdict}, " @@ -623,7 +630,8 @@ check("dark: A rests while B charges", all(r["roles"][A] == modetrade.REST for r in charge)) # Cheap hours on a partly sunny day: ignores the plan, fills as in P3. -rows, st_, c = run_day(SF_CH, fc=14.0, soc0={"a": 60.0, "b": 30.0}) +rows, st_, c = run_day(SF_CH, fc=14.0, soc0={"a": 60.0, "b": 30.0}, + extra=GATE_030) charge = invariants("cheap hours, partly", rows, c) top = max(r["soc"]["b"] for r in rows) print(f" cheap hours partly: B reached {top:.1f} %") @@ -648,7 +656,8 @@ check("forecast lost mid-run: Leftover solar was charging, and stops within " # The forecast is far too high (40 kWh, and there is no sun at all): no grid # charge, so B covers less of the evening - a money risk, never a safety one. -rows, st_, c = run_day(SF_LS, fc=40.0, soc0={"a": 30.0, "b": 30.0}) +rows, st_, c = run_day(SF_LS, fc=40.0, soc0={"a": 30.0, "b": 30.0}, + extra=GATE_030) invariants("forecast too high", rows, c) check("forecast too high: no grid charge, and B still keeps its power for " "the dear run", not [r for r in rows if r["action"] @@ -658,7 +667,7 @@ check("forecast too high: no grid charge, and B still keeps its power for " # A refusal wins over the plan. rows, st_, c = run_day(SF_LS, fc=1.0, soc0={"a": 60.0, "b": 30.0}, - refuse=("b",)) + refuse=("b",), extra=GATE_030) invariants("refusal", rows, c) check("refusal: B refuses to charge, so the plan's charge ends and B is " "never a charger after it", rows and not [ -- 2.54.0 From 97686319f38d73c39bee8652e16d2e786c11aa39 Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Wed, 28 Oct 2026 17:17:56 +0100 Subject: [PATCH 5/5] TARIFF-24 round 3: a run planned with a forecast keeps its gap to its end Losing the solar forecast mid-run used to widen the gap at once and stop a grid charge (and a sensor that went unavailable for a minute stopped and restarted it). A run under way when the forecast is lost now keeps the gap it was planned with until it ends (Controller.weather_held(), recorded by _wx_track() each cycle a forecast is read); the wider gap applies from the next run. The plan key holds the hold state so the plan rebuilds when it ends. Home's mirror (homeview.trade_verdict) asks the same weather_held(now). Tests: test_tariff24_weather.py part 4 (going: the charge continues to the run's end, the next run is not_worth; flap of one and two reads: no stop, no restart; Home agrees) and a test_modes_p4.py scenario that runs a forecast day at a gap between 1x and 1.5x the cycle cost. DECISIONS wording follows. Co-Authored-By: Claude --- .../TARIFF-24-modes-need-the-tariff-task.md | 6 +- goodwe_controller/app/arbitrage.py | 10 +- goodwe_controller/app/homeview.py | 4 +- goodwe_controller/app/main.py | 37 ++++- goodwe_controller/test_modes_p4.py | 29 ++++ goodwe_controller/test_tariff24_weather.py | 147 ++++++++++++++---- 6 files changed, 191 insertions(+), 42 deletions(-) diff --git a/goodwe_controller/DECISIONS/TARIFF-24-modes-need-the-tariff-task.md b/goodwe_controller/DECISIONS/TARIFF-24-modes-need-the-tariff-task.md index e6e6f8a..322049a 100644 --- a/goodwe_controller/DECISIONS/TARIFF-24-modes-need-the-tariff-task.md +++ b/goodwe_controller/DECISIONS/TARIFF-24-modes-need-the-tariff-task.md @@ -54,8 +54,10 @@ No forecast, or a stale or unusable one: 1.5 x, as before. It is read in three places that must agree: the run plan (`_price_runs()`), the charge gate (`_mode_roles()`) and Home's mirror (`homeview.trade_verdict()`). The forecast is read at most every 60 s and the plan key already holds "forecast or not", so -the gap follows in the cycle the read changes, both ways. A run already under -way is kept to its end (`priceruns.keep_started()`), as for any change. +the gap follows in the cycle the read changes, both ways, with one exception: +a run planned with a forecast that is under way keeps that gap to its end +(`Controller.weather_held()`), so a sensor that goes unavailable does not stop +or restart a charge. The wider gap applies from the next run. **Not changed.** Modes off (the site trade) keeps the doubt always. The shipped cost 0.10 still needs a gap of 0.10 even with a forecast: a 0.08 diff --git a/goodwe_controller/app/arbitrage.py b/goodwe_controller/app/arbitrage.py index 86ab97e..eb4e0c6 100644 --- a/goodwe_controller/app/arbitrage.py +++ b/goodwe_controller/app/arbitrage.py @@ -267,15 +267,17 @@ def threshold_eur_kwh(opts: dict) -> float: return v -def weather_uncertainty(modes_on: bool, solar_fc) -> frozenset: +def weather_uncertainty(modes_on: bool, solar_fc, held: bool = False) -> frozenset: """The "no weather" doubt (x1.5) a trade must clear. TARIFF-24. Battery modes read a solar forecast; with one read (`solar_fc` not None) the - doubt is gone. Everything else, and the modes without a forecast, keep it. - One rule for the run plan, the charge gate and Home's mirror of it. See + doubt is gone, and `held` (a run planned with a forecast is under way, see + `Controller.weather_held()`) keeps it gone until that run ends. Everything + else, and the modes without a forecast, keep it. One rule for the run plan, + the charge gate and Home's mirror. See DECISIONS/TARIFF-24-modes-need-the-tariff-task.md. """ - if modes_on and solar_fc is not None: + if modes_on and (solar_fc is not None or held): return frozenset() return frozenset({Uncertainty.NO_WEATHER}) diff --git a/goodwe_controller/app/homeview.py b/goodwe_controller/app/homeview.py index 2a7cbe7..03e98b9 100644 --- a/goodwe_controller/app/homeview.py +++ b/goodwe_controller/app/homeview.py @@ -279,7 +279,9 @@ def trade_verdict(c, plan, now): min_spread_eur_kwh=arbitrage.threshold_eur_kwh(c.o), uncertainty=arbitrage.weather_uncertainty( bool(getattr(c, "modes_on", False)), - getattr(c, "solar_fc", None)), + getattr(c, "solar_fc", None), + bool(getattr(c, "weather_held", + lambda _n: False)(now))), now=now) return verdict.match( allowed=lambda v: (v, "allowed"), diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 7283bcb..8902a73 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -960,6 +960,9 @@ class Controller: # and whether last tick filed a mode trade (a run edge reseeds). self.mode_runs = None self._runs_key = None + # TARIFF-24: the end of the run that was under way while a forecast + # was read; until then losing the forecast does not widen the gap. + self._wx_free_to = None self.mode_tick = None self._run_done = {} self._mode_trade_was = False @@ -2226,6 +2229,28 @@ class Controller: probing=probing)) return out + def _wx_track(self, now) -> None: + """TARIFF-24, once per cycle, before the plan: with a forecast read, + remember when the run now under way (cheap, give or sell) ends.""" + if self.solar_fc is None: + return + rp = self.mode_runs + ends = [] + if rp is not None and rp.on: + ends = [r.end for r in (rp.cheap, rp.sell) + if r is not None and r.holds(now)] + g = rp.give_at(now) + if g is not None: + ends.append(g.end) + self._wx_free_to = max(ends) if ends else None + + def weather_held(self, now) -> bool: + """TARIFF-24: no forecast now, but a run planned with one is under + way: it keeps its gap to its end. The wider gap applies from the next + run. Pure; Home asks the same (`homeview.trade_verdict()`).""" + return (self.solar_fc is None and self._wx_free_to is not None + and now < self._wx_free_to) + def _price_runs(self, now, packs, floor_pct, ceiling_pct): """The kept run plan, rebuilt at most once per price slot (15 min) or when the prices or a battery's choice change; a run under way is @@ -2239,9 +2264,11 @@ class Controller: slot = int(now.timestamp() // 900) key = (id(plan), slot, tuple((p.name, p.use, p.mode, p.sell) for p in packs)) + held = self.weather_held(now) if self.fc_entity is not None: - # P4: a forecast arriving or going rebuilds the plan at once. - key += (self.solar_fc is None,) + # P4: a forecast arriving or going rebuilds the plan at once (and + # so does the end of a run that held the gap, TARIFF-24). + key += (self.solar_fc is None, held) if key != self._runs_key: done_c = self._run_done.get("charge", (None, set()))[1] done_s = self._run_done.get("sell", (None, set()))[1] @@ -2250,7 +2277,7 @@ class Controller: required = price_required_spread( arbitrage.threshold_eur_kwh(self.o), price_uncertainty_of(plan.read.product) - | arbitrage.weather_uncertainty(True, self.solar_fc)) + | arbitrage.weather_uncertainty(True, self.solar_fc, held)) ref = self._ref_cheap if ref is not None and now - ref.end > REF_CHEAP_KEEP: ref = self._ref_cheap = None @@ -2319,6 +2346,7 @@ class Controller: quarter), exactly the gate `arbitrage.decide()` has.""" floor_pct, ceiling_pct = arbitrage.soc_bounds(self.o) packs = self._mode_packs(maint_results) + self._wx_track(now) runs = self._price_runs(now, packs, floor_pct, ceiling_pct) guard = arbitrage.guard_state(self.peak_guard) plan = self.fresh_tariff_plan(now) @@ -2329,7 +2357,8 @@ class Controller: return plan.arbitrage( peak_guard_active=active, min_spread_eur_kwh=arbitrage.threshold_eur_kwh(self.o), - uncertainty=arbitrage.weather_uncertainty(True, self.solar_fc), + uncertainty=arbitrage.weather_uncertainty( + True, self.solar_fc, self.weather_held(now)), now=now).match( allowed=lambda _v: True, refused=lambda _v: False) if guard is not None and plan is not None and runs is not None: diff --git a/goodwe_controller/test_modes_p4.py b/goodwe_controller/test_modes_p4.py index c31e4ee..39ea544 100644 --- a/goodwe_controller/test_modes_p4.py +++ b/goodwe_controller/test_modes_p4.py @@ -614,6 +614,35 @@ check("sunny: a day plan is made, and says sunny", for r in rows)) check("sunny: no grid charge at all", not charge) +# TARIFF-24 round 2: the same day at the cycle cost 0.20 itself. With the +# forecast read the gap needed is 0.20 (not 1.5 x = 0.30), so the midday +# quarter 0.339 against the evening 0.551 (gap 0.21, between 1x and 1.5x) is +# a planned top-up for B (Leftover solar) up to its planned level. +rows_g, st_, c_g = run_day(SF_LS, fc=40.0, soc0={"a": 60.0, "b": 30.0}) +charge_g = invariants("gap between 1x and 1.5x", rows_g, c_g) +_first_g = nth(charge_g, 0, "a charge cycle") +_gaps = [min(g.mean_eur_kwh for g in r["runs"].give) + - r["runs"].cheap.mean_eur_kwh for r in charge_g + if r["runs"] is not None and r["runs"].give] +print(f" gap 0.20 <= gap < 0.30: {len(charge_g)} charge cycles, run gap " + f"{min(_gaps, default=0):.3f}, B to " + f"{max((r['soc']['b'] for r in rows_g), default=0):.1f} %") +check("a forecast day whose price gap lies between 1x and 1.5x the cycle " + "cost (0.20 < 0.21 < 0.30) plans a cheap run: B charges from the grid " + "inside it, only B, and only up to its planned level", + present(_first_g) and len(_gaps) == len(charge_g) + and all(0.20 <= g < 0.30 for g in _gaps) + and all(r["runs"].cheap.holds(r["now"]) for r in charge_g) + and all(r["roles"][B] == modetrade.CHARGE for r in charge_g) + and all(r["roles"][A] != modetrade.CHARGE for r in charge_g) + and max(r["soc"]["b"] for r in rows_g) <= 45.64 + 2.0) +check("...and the same day with no forecast read does not (it needs 0.30): " + "the old gate, kept for a day without a forecast", + not [r for r in run_day(SF_LS, fc=None, + soc0={"a": 60.0, "b": 30.0})[0] + if r["action"] == arbitrage.CHARGE + and r["now"].astimezone(L).hour == 12]) + # Dark: 1 kWh. B buys the evening's shortfall only, in the cheap run. rows, st_, c = run_day(SF_LS, fc=1.0, soc0={"a": 60.0, "b": 30.0}) charge = invariants("dark", rows, c) diff --git a/goodwe_controller/test_tariff24_weather.py b/goodwe_controller/test_tariff24_weather.py index 9af4f52..e0ec286 100644 --- a/goodwe_controller/test_tariff24_weather.py +++ b/goodwe_controller/test_tariff24_weather.py @@ -14,8 +14,10 @@ DECISIONS/TARIFF-24-modes-need-the-tariff-task.md. 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 going and coming mid-run: the gate follows in the same cycle - the forecast read changes, both ways. +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). @@ -69,6 +71,7 @@ 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): @@ -108,8 +111,23 @@ def forecast_mode(mode, t, now): 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): + 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() @@ -118,6 +136,7 @@ def drive(o, *, fc="fresh", points=None, minutes=20, tick=5.0, ("pack_sell", sell)): store.data[unit_key(f"inverter-{u}", k)] = val rows = [] + snaps = {} class H(rp.Hass): async def state(self, e): @@ -163,9 +182,19 @@ def drive(o, *, fc="fresh", points=None, minutes=20, tick=5.0, "generic_dayahead", now), None, "provider") c.tariff_at = now - elif t == 0.0: + 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: + 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()) @@ -182,18 +211,17 @@ def drive(o, *, fc="fresh", points=None, minutes=20, tick=5.0, 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)) + 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 - c.home_plan = homeview.build_plan(c, clock[0], False, True) - c.cards = homeview.battery_rows(c, False, True) - # 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). - c2 = c - c2.o = dict(c.o, arbitrage_enabled=True) - c.mirror, c.mirror_why = homeview.trade_verdict( - c2, c.fresh_tariff_plan(clock[0]), clock[0]) - c.o = dict(c.o, arbitrage_enabled=o.get("arbitrage_enabled", - False)) + 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() @@ -274,8 +302,9 @@ check("without one it is not_worth (needs 0.15 > 0.12) and says so", and why_of(cn, B) == "no_spread") # --------------------------------------------------------------------------- # -print("\n4. the forecast going and coming mid-run") +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): @@ -286,8 +315,73 @@ def coming(t): return "gone" if t < CUT else "fresh" -cg, rg = drive(opts(0.07), fc=going, minutes=25) +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): @@ -301,19 +395,10 @@ def flips(rows): return out -fg, fcm = flips(rg), flips(rc) -print(f" going: forecast read lost at cycle/t {fg}; coming: {fcm}") -check("going: the plan is on with the forecast and not_worth the cycle the " - "read is lost (within one forecast read, 60 s, of the sensor change)", - len(fg) == 1 and fg[0][1] - CUT <= 60.0 and fg[0][2] is False - and all(r["runs_on"] is True for r in rg if not r["fc_none"]) - and all(r["runs_on"] is False for r in rg if r["fc_none"])) -check("...and the grid charge stops with it: no charging after the cycle " - "that lost the forecast, plus the 15 s the inverter ramp takes", - len(fg) == 1 and not any( - r["charging"] for r in rg if r["t"] >= fg[0][1] + 40.0)) -check("coming: not_worth until the read is back, then on in the same cycle, " - "and the battery charges", +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"]) -- 2.54.0