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