From 242cf434f494b66747124742d2f300d6bda5d085 Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Mon, 26 Oct 2026 11:15:29 +0100 Subject: [PATCH 01/49] UX-53: Home draws a grid charge only where the add-on will take it Modes on: Controller._mode_roles() files the trade gate it just asked as mode_gate; while it refuses, Home draws no grid charge and marks no cheap quarter. Modes off: a charge after the last dear period is asked of the same gate at its own start (homeview.leg_taken) and dropped when refused there. Words: "Lowest/Highest price left" (EN, NL); a flat remainder on a dynamic or day/night site is no longer called a fully fixed product. No change to control decisions, commands or options. Co-Authored-By: Claude --- .../DECISIONS/UX-53-home-follows-the-gate.md | 36 ++ goodwe_controller/app/homeview.py | 57 +- goodwe_controller/app/main.py | 5 + goodwe_controller/app/static_tariff.py | 8 +- goodwe_controller/app/ui_text/en.json | 4 +- goodwe_controller/app/ui_text/nl.json | 4 +- goodwe_controller/test_home.py | 2 +- goodwe_controller/test_ux53.py | 589 ++++++++++++++++++ 8 files changed, 681 insertions(+), 24 deletions(-) create mode 100644 goodwe_controller/DECISIONS/UX-53-home-follows-the-gate.md create mode 100644 goodwe_controller/test_ux53.py diff --git a/goodwe_controller/DECISIONS/UX-53-home-follows-the-gate.md b/goodwe_controller/DECISIONS/UX-53-home-follows-the-gate.md new file mode 100644 index 0000000..bec28d3 --- /dev/null +++ b/goodwe_controller/DECISIONS/UX-53-home-follows-the-gate.md @@ -0,0 +1,36 @@ +# UX-53 - Home draws a grid charge only where the add-on will take it + +**Status: DONE 2026-10-26 (owner go 2026-10-25, from the PRICE-02 rig test F-1 and F-2).** + +## Decision + +Home asks the add-on's own gate, and does not decide for itself. + +- **Modes on.** `Controller._mode_roles()` files the gate it just asked + (`TariffPlan.arbitrage()` with the measured peak guard) as `mode_gate`: None + when it allowed a charge, else its reason (`peak`, `not_worth`, + `not_covered`). While it is set, `homeview.mode_runs()` draws no grid charge + and `price_windows()` marks no cheap quarter. Control never reads it. A + refusal that is not the gate (no measured headroom, a battery cycling) is + not filed, so those pages are as before. +- **Modes off.** A charge the verdict puts after its last dear period is + refused when its time comes: the quarters left are all equal. `leg_taken()` + asks `trade_verdict()` (the control cycle's own call) at the leg's start and + keeps the leg only if the gate allows it there. A leg in progress is the verdict's own answer now. The + sale, "Tomorrow:" lines and chart marks use the same answer. +- **Words.** "Lowest/Highest published price" becomes "Lowest/Highest price + left" (the verdict reads only the periods not yet over). The flat-tariff + refusal calls a site "a fully fixed product" only when its product is fixed. + +## What does not change + +No control decision, command or option. Day/night and hourly sites with a +second day of prices keep both legs at every half hour of a day +(`test_ux53.py` part 4); price-off sites draw nothing, as before. + +## Limits + +The leg's gate is asked on today's plan, as the control cycle will find it at +that time. A plan that gets new prices in between is drawn again at the next +refresh. While the peak guard holds, a modes-on page shows no charge for that +time, as the modes-off page already did. diff --git a/goodwe_controller/app/homeview.py b/goodwe_controller/app/homeview.py index dfc6940..ae3e103 100644 --- a/goodwe_controller/app/homeview.py +++ b/goodwe_controller/app/homeview.py @@ -212,25 +212,26 @@ def _price_mode_in_use(c): return False -def price_windows(c, plan, verdict): +def price_windows(c, plan, verdict, now=None): """`[(kind, start, end)]`: where the add-on itself buys ("cheap") and gives ("dear"). With battery modes on that is the kept `priceruns.RunPlan` - (`c.mode_runs`): the cheap run when charging in it pays, and every give - run. With them off it is the arbitrage verdict's two points, the ones - `trade_runs()` draws. Nothing is classified here.""" + (`c.mode_runs`): the cheap run when charging in it pays and the trade gate + allows it (UX-53), and every give run. With them off it is the arbitrage + verdict's legs the add-on will take, the ones `trade_runs()` draws + (`trade_legs()`, given `now`). Nothing is classified here.""" if getattr(c, "modes_on", False): rp = getattr(c, "mode_runs", None) if rp is None or not rp.on or not _price_mode_in_use(c): return [] out = [("cheap", rp.cheap.start, rp.cheap.end)] \ - if rp.charge_useful() else [] + if rp.charge_useful() and getattr(c, "mode_gate", None) is None \ + else [] return out + [("dear", g.start, g.end) for g in rp.give] if verdict is None: return [] - return [("cheap", verdict.cheapest.start, - _point_end(plan, verdict.cheapest)), - ("dear", verdict.dearest.start, - _point_end(plan, verdict.dearest))] + return [("cheap" if kind == "grid" else "dear", pt.start, + _point_end(plan, pt)) + for kind, pt in trade_legs(c, plan, verdict, now)] def day_range(points): @@ -306,6 +307,27 @@ def _point_end(plan, point): return point.start + timedelta(hours=1) +def leg_taken(c, plan, pt, now): + """UX-53: will the add-on act on this point of the verdict? One in + progress is the verdict's own answer now. One still ahead is asked of the + same gate (`trade_verdict()`, the call the control cycle makes) at the + point's own start. A charge that comes after the last dear period meets a + flat remainder there and is refused, so it is not announced. When the gate + allows it, the point is still the cheapest (dearest) one: the points + before it are gone and it was the first extreme of the points from now.""" + if now is None or pt.start <= now: + return True + return trade_verdict(c, plan, pt.start)[0] is not None + + +def trade_legs(c, plan, verdict, now): + """`[(kind, point)]` by start: the verdict's charge ("grid") and give + ("give") legs that the add-on will take (`leg_taken()`).""" + legs = sorted((("grid", verdict.cheapest), ("give", verdict.dearest)), + key=lambda kp: kp[1].start) + return [(k, pt) for k, pt in legs if leg_taken(c, plan, pt, now)] + + # Under this many kWh a block is not drawn: the cycle would not move it. MIN_KWH = 0.05 @@ -322,10 +344,8 @@ def trade_runs(c, plan, verdict, day0, day1, now, shadow): if any(inv.soc is None for inv in c.inverters): return [], [], "no_level" level = [float(inv.soc) for inv in c.inverters] - legs = sorted((("grid", verdict.cheapest), ("give", verdict.dearest)), - key=lambda kp: kp[1].start) runs, later = [], [] - for kind, pt in legs: + for kind, pt in trade_legs(c, plan, verdict, now): end = _point_end(plan, pt) if end <= now: continue @@ -371,8 +391,9 @@ def mode_runs(c, day0, day1, now, shadow): them (`c.mode_runs`): the cheap run on the batteries that may charge from the grid, the give runs on Leftover solar and Cheap hours, and the sell run on those with selling on. Amounts are upper bounds ("up to"), - from the battery's own room, rating and the run's length. Returns - `(runs, no_trade_key)`.""" + from the battery's own room, rating and the run's length. UX-53: no grid + charge is drawn while the trade gate refuses one (`c.mode_gate`, what the + control cycle found). Returns `(runs, no_trade_key)`.""" rp = getattr(c, "mode_runs", None) if getattr(c, "mode_tick", None) is None: return [], None # no cycle yet: nothing is known @@ -381,6 +402,7 @@ def mode_runs(c, day0, day1, now, shadow): if not rp.on: return [], "not_worth" floor, ceiling = arbitrage.soc_bounds(c.o) + gate = getattr(c, "mode_gate", None) out = [] for i, inv in enumerate(c.inverters): pm = c.pack_mode(inv) @@ -397,8 +419,9 @@ def mode_runs(c, day0, day1, now, shadow): blocks = [] # P4: with a day plan, the grid block is the planned shortfall. need = plan_need(c, inv.name) - if rp.charge_useful() and (need > 0.0 if need is not None - else pm.mode in GRID_CHARGE_MODES): + if gate is None and rp.charge_useful() and ( + need > 0.0 if need is not None + else pm.mode in GRID_CHARGE_MODES): blocks.append(("grid", rp.cheap)) sell = rp.sell if pm.sell and pm.can_sell else None for g in rp.give: @@ -608,7 +631,7 @@ def build_plan(c, now, shadow, running, held=False, settled=True): runs, lines = [], [] # The control cycle trades on the fresh plan only, so this does too. verdict, trade_why = trade_verdict(c, fresh, now) - bars = price_bars(seen, day0, day1, price_windows(c, fresh, verdict), + bars = price_bars(seen, day0, day1, price_windows(c, fresh, verdict, now), now, days.kinds) modes_on = bool(getattr(c, "modes_on", False)) if running: diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index bc71984..3b228bf 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -1001,6 +1001,10 @@ class Controller: self.mode_runs = None self._runs_key = None self.mode_tick = None + # UX-53: why the trade gate refused a grid charge this tick (`peak`, + # `not_worth`, `not_covered`), or None when it allowed one. Home reads + # it; nothing in control does. + self.mode_gate = None self._run_done = {} self._mode_trade_was = False self._charge_blocked_q = None @@ -2380,6 +2384,7 @@ class Controller: charge_ok = sell_ok and allowed(guard) trade_why = (None if charge_ok else "peak" if sell_ok else "not_worth") + self.mode_gate = None if charge_ok else trade_why price_now = None if plan is not None: try: diff --git a/goodwe_controller/app/static_tariff.py b/goodwe_controller/app/static_tariff.py index 013b7ee..bee13c1 100644 --- a/goodwe_controller/app/static_tariff.py +++ b/goodwe_controller/app/static_tariff.py @@ -1103,8 +1103,12 @@ class TariffPlan: f"the tariff is flat - every one of the {len(firm)} firm " f"points, spanning {_whole_hours(firm)} hours, costs " f"{firm[0].eur_per_kwh:.4f} EUR/kWh, so buying low and " - f"selling high is arithmetically impossible. §9.2: a fully " - f"fixed product is self-consumption only") + f"selling high is arithmetically impossible" + # UX-53: only a real fixed product is "fully fixed". A + # dynamic or day/night site whose remaining periods happen to + # be equal trades again when they differ. + + (". §9.2: a fully fixed product is self-consumption only" + if self.product is PriceProduct.FIXED else "")) # REFUSAL 5, NEW IN FCST-03 (AC3). One widening path, driven by the set # of active uncertainty sources: the product's own (§9.2 row 2, derived diff --git a/goodwe_controller/app/ui_text/en.json b/goodwe_controller/app/ui_text/en.json index 4c1ea93..a7801cc 100644 --- a/goodwe_controller/app/ui_text/en.json +++ b/goodwe_controller/app/ui_text/en.json @@ -367,11 +367,11 @@ "run": { "grid_what": "Charges up to {kwh} from the grid.", "grid_what_would": "Would charge up to {kwh} from the grid.", - "grid_why": "Lowest published price, {eur} per kWh.", + "grid_why": "Lowest price left, {eur} per kWh.", "grid_amount": "Costs up to {eur}.", "give_what": "Gives up to {kwh}, to the house first.", "give_what_would": "Would give up to {kwh}, to the house first.", - "give_why": "Highest published price, {eur} per kWh.", + "give_why": "Highest price left, {eur} per kWh.", "sell_what": "Sells up to {kwh}, to the house first.", "sell_what_would": "Would sell up to {kwh}, to the house first.", "cheap_why": "Cheapest hours, about {eur} per kWh.", diff --git a/goodwe_controller/app/ui_text/nl.json b/goodwe_controller/app/ui_text/nl.json index 1cd3e24..b9ad0ca 100644 --- a/goodwe_controller/app/ui_text/nl.json +++ b/goodwe_controller/app/ui_text/nl.json @@ -307,11 +307,11 @@ "run": { "grid_what": "Laadt tot {kwh} van het net.", "grid_what_would": "Zou tot {kwh} van het net laden.", - "grid_why": "Laagste gepubliceerde prijs, {eur} per kWh.", + "grid_why": "Laagste prijs die nog over is, {eur} per kWh.", "grid_amount": "Kost tot {eur}.", "give_what": "Levert tot {kwh}, eerst aan het huis.", "give_what_would": "Zou tot {kwh} leveren, eerst aan het huis.", - "give_why": "Hoogste gepubliceerde prijs, {eur} per kWh.", + "give_why": "Hoogste prijs die nog over is, {eur} per kWh.", "sell_what": "Verkoopt tot {kwh}, eerst aan het huis.", "sell_what_would": "Zou tot {kwh} verkopen, eerst aan het huis.", "cheap_why": "Goedkoopste uren, ongeveer {eur} per kWh.", diff --git a/goodwe_controller/test_home.py b/goodwe_controller/test_home.py index 249c653..b1db9e9 100644 --- a/goodwe_controller/test_home.py +++ b/goodwe_controller/test_home.py @@ -523,7 +523,7 @@ _lo = min(_q[:96], key=lambda p: p.eur_per_kwh) check("quarter-hour prices: the charge block, its price and the price line's " "lowest are the same 15-minute period", _grid and _grid[0]["from"] == homeview.hhmm(_lo.start) - and _grid[0]["why"] == f"Lowest published price, €{_lo.eur_per_kwh:.2f} " + and _grid[0]["why"] == f"Lowest price left, €{_lo.eur_per_kwh:.2f} " "per kWh." and f"Today: lowest €{_lo.eur_per_kwh:.2f} at " f"{homeview.hhmm(_lo.start)}," in s_q["plan"]["price_line"] diff --git a/goodwe_controller/test_ux53.py b/goodwe_controller/test_ux53.py new file mode 100644 index 0000000..60dd4c7 --- /dev/null +++ b/goodwe_controller/test_ux53.py @@ -0,0 +1,589 @@ +"""Runnable check for UX-53. `python3 test_ux53.py` + +Home draws a grid charge only where the add-on will take it. + +1. Modes on (F-1): inside a cheap run that is under way, the trade gate + (`TariffPlan.arbitrage()`, as `Controller._mode_roles()` asks it) refuses + with `not_worth`. The control cycle files that as `Controller.mode_gate`, + and Home draws no charge, and marks no cheap quarter, from then on. With + the gate open the charge is drawn as before. The real `_mode_roles()` runs + at each instant, so the page is graded against the decision it makes. +2. Modes off (F-2): the verdict's charge comes after its last dear period. + Home asks the same gate at the charge's own start (`homeview.leg_taken()`): + a flat remainder is refused there, so no charge is drawn and no quarter is + marked cheap. A charge the gate allows at its start is drawn as before, and + so is the sale. +3. Wording, English and Dutch: "lowest price left", and "a fully fixed + product" only for a real fixed product. +4. Day/night and price-off pages are the same as before. + +Mutations (MUTATION.md, purged copies): listed in the pull request. +""" + +import asyncio +import json +import logging +import os +import sys +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) + +from app import arbitrage, homeview, modetrade, packmode # noqa: E402 +from app.main import Controller # noqa: E402 +from app.peakguard import PeakDecision # noqa: E402 +from app.price import (PricePoint, PriceProduct, PriceRead, # noqa: E402 + Uncertainty, brussels) +from app.static_tariff import TariffPlan, static_plan # 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) + + +UTC = timezone.utc + + +class _Store: + def __init__(self): + self.data = {} + + def set(self, k, v): + self.data[k] = v + + def get(self, k, d=None): + return self.data.get(k, d) + + def set_time(self, k, v): + self.data[k] = v.isoformat() + + def get_time(self, k): + v = self.data.get(k) + return None if v is None else datetime.fromisoformat(v) + + +class _Pub: + enabled = False + + def publish(self, _v): + pass + + def close(self): + pass + + +class _Hass: + session = None + token = "" + + async def limits(self, _e): + return (-5000.0, 5000.0, 1.0) + + async def number(self, _e, invert=False): + return None + + async def state(self, _e): + return None + + async def set_number(self, _e, _v): + return True + + async def call(self, *_a): + return True + + async def states(self): + return [] + + +BASE = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid", + "meter_source": "off", "max_w": 5000, "shadow_mode": "off", + "maintenance_enabled": False, "arbitrage_enabled": True, + "cycle_cost_threshold_eur": 0.10, + "inverters": [ + {"name": "inverter-a", "setpoint_entity": "number.a", + "soc_entity": "sensor.soc_a", "batt_entity": "sensor.batt_a", + "max_w": 2500, "capacity_kwh": 2.0}, + {"name": "inverter-b", "setpoint_entity": "number.b", + "soc_entity": "sensor.soc_b", "batt_entity": "sensor.batt_b", + "max_w": 2500, "capacity_kwh": 2.0}]} + +# Tuesday 27 October 2026 in Brussels (UTC+1 since 25 October). +DAY0 = datetime(2026, 10, 26, 23, 0, tzinfo=UTC) + + +def at(h, m=0, s=0): + return DAY0 + timedelta(hours=h, minutes=m, seconds=s) + + +def quarters(price_of, days=2, product=PriceProduct.DAY_AHEAD): + """48 hours of 15-minute prices; `price_of(local_hour_float)` -> EUR/kWh.""" + pts = [] + for i in range(96 * days): + t = DAY0 + timedelta(minutes=15 * i) + pts.append(PricePoint(brussels(t), price_of((i % 96) / 4.0), False)) + return TariffPlan(PriceRead.series(tuple(pts), product, + "generic_dayahead", DAY0), None, + "provider") + + +def make(opts, plan, now, *, modes=None, soc=(50.0, 50.0)): + c = Controller(dict(opts), _Hass(), _Store(), _Pub(), stop=asyncio.Event()) + c.auto = True + for i, inv in enumerate(c.inverters): + inv.soc, inv.batt, inv.last_write = soc[i], 0.0, 0.0 + inv.dev_min, inv.dev_max = -5000.0, 5000.0 + c.grid = 500.0 + c.tariff_plan, c.tariff_at = plan, now + c.peak_guard = PeakDecision(engaged=False, floor_w=None, ceiling_w=3500.0, + ceiling_from="cap", projected_w=800.0) + c.arb_headroom_w = 3000.0 + if modes: + for inv in c.inverters: + c.store.data[unit_key(inv.name, packmode.MODE_KEY)] = modes + return c + + +def build(c, now): + return homeview.build(c, level="ok", checks=[], now=now)["plan"] + + +def kinds_at(plan_json, label): + return [b["kind"] for b in plan_json["prices"] if b["label"] == label] + + +def grid_blocks(plan_json): + return [r for r in plan_json["runs"] if r["kind"] == "grid"] + + +# --------------------------------------------------------------------------- # +print("1. modes on: the gate refuses inside a cheap run (F-1)") +# All-in prices: 0.10 everywhere, 20:00 0.01, 20:15 0.09, 21:00-22:00 0.22. The +# cheap run is 20:00-20:30. At 20:00 the spread is 0.21. From 20:15 the quarters +# ahead are 0.09 to 0.22: 0.13 does not clear the 0.15 this cycle needs (0.10 +# widened by 1.5 for the missing weather forecast). +def f1_price(h): + if h == 20.0: + return 0.01 + if h == 20.25: + return 0.09 + if 21.0 <= h < 22.0: + return 0.22 + return 0.10 + + +F1_PLAN = quarters(f1_price, days=1) +c1 = make(dict(BASE, battery_modes_enabled=True), F1_PLAN, at(19, 0, 5), + modes=packmode.CHEAP_HOURS) + + +def f1(now): + """One real control tick of `_mode_roles()` at `now`, then Home.""" + c1.tariff_at = now + tick = c1._mode_roles(now, None) + return tick, build(c1, now) + + +t_pre, p_pre = f1(at(19, 0, 5)) # builds the run plan +t_open, p_open = f1(at(20, 0, 5)) +print(f" 20:00:05 roles {t_open.roles}, action {t_open.action}, " + f"gate {c1.mode_gate}, runs {c1.mode_runs.cheap}") +check("gate open (20:00): the control cycle charges, mode_gate is None, and " + "Home draws the charge on both batteries, 20:00 to 20:30", + t_open.action == modetrade.CHARGE and c1.mode_gate is None + and {r["battery"] for r in grid_blocks(p_open)} == {0, 1} + and all(r["from"] == "20:00" and r["to"] == "20:30" + for r in grid_blocks(p_open))) +check("...and marks the run's two quarters cheap on the chart", + kinds_at(p_open, "20:00") == ["cheap"] + and kinds_at(p_open, "20:15") == ["cheap"]) +t_mid, p_mid = f1(at(20, 7, 30)) +check("gate still open at 20:07: the charge goes on and is still drawn", + t_mid.action == modetrade.CHARGE and c1.mode_gate is None + and len(grid_blocks(p_mid)) == 2) +c1.inverters[0].soc = c1.inverters[1].soc = 52.0 +t_ref, p_ref = f1(at(20, 17, 52)) +print(f" 20:17:52 roles {t_ref.roles}, action {t_ref.action}, why " + f"{t_ref.why}, gate {c1.mode_gate}, run {c1.mode_runs.cheap}") +check("the run is still kept (20:00-20:30) and the gate refuses with " + "not_worth at 20:17: no charge, why not_worth", + c1.mode_runs.cheap.holds(at(20, 17, 52)) and c1.mode_gate == "not_worth" + and t_ref.action is None and t_ref.why == "not_worth" + and all(r != modetrade.CHARGE for r in t_ref.roles.values())) +check("Home draws no charge block for the refused part, on any battery", + grid_blocks(p_ref) == [] and not any( + r["what"].startswith("Charges") for r in p_ref["runs"])) +check("...marks no quarter from 20:15 cheap, keeps the 20:00 quarter it " + "did charge in, and keeps the give run marked dear", + kinds_at(p_ref, "20:00") == ["cheap"] + and kinds_at(p_ref, "20:15") == ["mid"] + and kinds_at(p_ref, "20:30") == ["mid"] + and kinds_at(p_ref, "21:00") == ["dear"]) +saved = c1.mode_gate +c1.mode_gate = None # what Home did before UX-53 +p_old = build(c1, at(20, 17, 52)) +c1.mode_gate = saved +check("the same moment without the gate record draws the charge again (the " + "defect, shown): so the fixture is the F-1 case", + len(grid_blocks(p_old)) == 2 and kinds_at(p_old, "20:15") == ["cheap"]) +row = homeview.battery_rows(c1, False, True)[0] +check("the battery card says what Home now says: it rests (role hold), and " + "does not say it charges", + t_ref.roles["inverter-a"] == modetrade.HOLD + and row["why_key"] in ("saves", "saves_later")) +t_n, p_n = f1(at(20, 31, 0)) +check("after the run: nothing is drawn, as before", + grid_blocks(p_n) == [] and c1.mode_gate == "not_worth") + +# Other refusals of the gate are followed the same way: no charge is drawn +# while the control cycle's gate says no. +c1b = make(dict(BASE, battery_modes_enabled=True), F1_PLAN, at(20, 0, 5), + modes=packmode.CHEAP_HOURS) +c1b.tariff_at = at(20, 0, 5) +c1b.peak_guard = PeakDecision(engaged=True, floor_w=1000.0, ceiling_w=3500.0, + ceiling_from="cap", projected_w=3600.0) +tb = c1b._mode_roles(at(20, 0, 5), None) +check("the peak guard holding refuses the charge (gate 'peak'): the cycle " + "does not charge and Home draws no charge", + c1b.mode_gate == "peak" and tb.action is None + and grid_blocks(build(c1b, at(20, 0, 5))) == []) +c1c = make(dict(BASE, battery_modes_enabled=True), F1_PLAN, at(20, 0, 5), + modes=packmode.CHEAP_HOURS) +c1c.peak_guard = None +tc = c1c._mode_roles(at(20, 0, 5), None) +check("a peak guard that measures nothing refuses it too (gate " + "'not_covered'): no charge, none drawn", + c1c.mode_gate == "not_covered" and tc.action is None + and grid_blocks(build(c1c, at(20, 0, 5))) == []) +c1d = make(dict(BASE, battery_modes_enabled=True), F1_PLAN, at(20, 0, 5), + modes=packmode.CHEAP_HOURS) +c1d.arb_headroom_w = None +td = c1d._mode_roles(at(20, 0, 5), None) +check("a refusal that is not the gate (no measured headroom yet) is not the " + "gate's: mode_gate stays None and the charge is still drawn", + c1d.mode_gate is None and td.why == "headroom" + and len(grid_blocks(build(c1d, at(20, 0, 5)))) == 2) + +# --------------------------------------------------------------------------- # +print("\n2. modes off: a charge after the last dear period (F-2)") +NO_WEATHER = frozenset({Uncertainty.NO_WEATHER}) + + +def decide_at(c, now, soc=50.0): + """What the control cycle does at `now` on the plan (modes off).""" + return arbitrage.decide( + c.tariff_plan, now, peak_guard_active=False, charge_headroom_w=5000.0, + soc=soc, min_spread_eur_kwh=arbitrage.threshold_eur_kwh(c.o), + uncertainty=NO_WEATHER, charge_w=2500.0, discharge_w=2500.0, + soc_floor_pct=10.0, soc_ceiling_pct=90.0) + + +OFF = dict(BASE) + + +def f2_price(h): + return 0.54 if 21.5 <= h < 21.75 else 0.24 + + +F2_PLAN = quarters(f2_price, days=1) +c2 = make(OFF, F2_PLAN, at(21, 36, 0)) +p2 = build(c2, at(21, 36, 0)) +g2 = [r for r in p2["runs"] if r["kind"] == "give"] +v2, _w = homeview.trade_verdict(c2, F2_PLAN, at(21, 36, 0)) +print(f" 21:36 verdict: cheapest {homeview.hhmm(v2.cheapest.start)}, " + f"dearest {homeview.hhmm(v2.dearest.start)}") +check("the fixture: at 21:36 the verdict's cheapest quarter (21:45) comes " + "after its dearest (21:30, in progress), and the rest of the day is flat", + homeview.hhmm(v2.cheapest.start) == "21:45" + and homeview.hhmm(v2.dearest.start) == "21:30") +check("the add-on sells in 21:30, and at 21:45 it does nothing: idle, the " + "tariff is flat", + decide_at(c2, at(21, 30, 5)).action == arbitrage.DISCHARGE + and decide_at(c2, at(21, 45, 5)).action is None + and "the tariff is flat" in decide_at(c2, at(21, 45, 5)).reason) +check("Home draws the sale in progress and NO charge at 21:45", + len(g2) == 2 and all(r["from"] == "21:30" for r in g2) + and grid_blocks(p2) == []) +check("...and marks 21:30 dear and 21:45 not cheap", + kinds_at(p2, "21:30") == ["dear"] and kinds_at(p2, "21:45") == ["mid"]) +real = homeview.leg_taken +homeview.leg_taken = lambda *a, **k: True # Home before UX-53 +p2_old = build(c2, at(21, 36, 0)) +homeview.leg_taken = real +check("without the question to the gate the page draws the 21:45 charge " + "(the defect, shown): so the fixture is the F-2 case", + [r["from"] for r in grid_blocks(p2_old)] == ["21:45", "21:45"] + and kinds_at(p2_old, "21:45") == ["cheap"]) + +# A charge the gate allows at its own start is still drawn, with the sale. +def q_price(h): + return {20.0: 0.10, 20.5: 0.12, 21.0: 0.59, 21.5: 0.54}.get(h, 0.24) + + +Q_PLAN = quarters(q_price, days=1) +c3 = make(OFF, Q_PLAN, at(20, 20, 0)) +p3 = build(c3, at(20, 20, 0)) +v3, _w = homeview.trade_verdict(c3, Q_PLAN, at(20, 20, 0)) +check("gate allows (20:20, charge 20:30, sale 21:00): both are drawn, and " + "the add-on does charge at 20:30 and sell at 21:00", + [r["from"] for r in grid_blocks(p3)] == ["20:30", "20:30"] + and [r["from"] for r in p3["runs"] if r["kind"] == "give"] == ["21:00"] * 2 + and decide_at(c3, at(20, 30, 5)).action == arbitrage.CHARGE + and decide_at(c3, at(21, 0, 5)).action == arbitrage.DISCHARGE + and kinds_at(p3, "20:30") == ["cheap"] and kinds_at(p3, "21:00") == ["dear"]) +check("...and the charge in progress at 20:30 is drawn too", + len(grid_blocks(build(c3, at(20, 35, 0)))) == 2) + +# Every probe on a day-ahead day: the charge is drawn exactly where the add-on +# will take it. "Takes it" is the control cycle's own `decide()` at the start +# of the charge's period, on the same plan. +def sweep(plan, opts, lo, hi, step=15): + rows = [] + for m in range(lo, hi, step): + now = at(0, 0, 0) + timedelta(minutes=m, seconds=7) + c = make(opts, plan, now) + v, _w = homeview.trade_verdict(c, plan, now) + if v is None: + rows.append((now, None, None, None)) + continue + legs = homeview.trade_legs(c, plan, v, now) + drawn = {k: homeview.hhmm(pt.start) for k, pt in legs} + rows.append((now, v, drawn, [k for k, _pt in legs])) + return rows + + +def takes(c, plan, kind, pt, now): + """True when decide() acts in that leg's first moment (or now, when it + is already under way).""" + t = max(now, pt.start + timedelta(seconds=5)) + d = decide_at(c, t) + return d.action == (arbitrage.CHARGE if kind == "grid" + else arbitrage.DISCHARGE) + + +bad, nlegs, nsuppressed = [], 0, 0 +for now, v, drawn, kinds in sweep(F2_PLAN, OFF, 0, 24 * 60): + if v is None: + continue + c = make(OFF, F2_PLAN, now) + for kind, pt in (("grid", v.cheapest), ("give", v.dearest)): + nlegs += 1 + shown = kind in kinds + if not shown: + nsuppressed += 1 + if shown != takes(c, F2_PLAN, kind, pt, now): + bad.append((homeview.hhmm(now), kind, shown)) +print(f" {nlegs} verdict legs over a day, {nsuppressed} not drawn, " + f"{len(bad)} where Home and decide() disagree {bad[:3]}") +check("over a whole day of 15-minute probes, a leg is drawn exactly when " + "decide() acts on it at its start; and some are not drawn", + not bad and nsuppressed > 0) + +# The same, on 40 random days of 15-minute prices (rounded to a cent, so there +# are ties), today only, every quarter probed: Home and decide() agree on every +# leg, and the page does drop some. +import random # noqa: E402 +rnd = random.Random(53) +n_leg = n_drop = 0 +disagree = [] +for day in range(40): + shape = [round(rnd.choice((0.10, 0.18, 0.24, 0.24, 0.30, 0.45, 0.60)) + + rnd.randrange(0, 3) * 0.01, 2) for _ in range(96)] + if day % 2: + shape = [shape[i // 4 * 4] for i in range(96)] # hour-constant + plan = quarters(lambda h, sh=shape: sh[int(h * 4)], days=1) + c = make(OFF, plan, at(0, 0, 0)) + for q in range(96): + now = at(0, 0, 0) + timedelta(minutes=15 * q, seconds=7) + v, _w = homeview.trade_verdict(c, plan, now) + if v is None: + continue + kinds = [k for k, _pt in homeview.trade_legs(c, plan, v, now)] + for kind, pt in (("grid", v.cheapest), ("give", v.dearest)): + n_leg += 1 + shown = kind in kinds + n_drop += not shown + if shown != takes(c, plan, kind, pt, now): + disagree.append((day, homeview.hhmm(now), kind, shown)) +print(f" 40 random days: {n_leg} verdict legs, {n_drop} not drawn, " + f"{len(disagree)} disagreements with decide() {disagree[:3]}") +check("40 random 15-minute days, every quarter: a leg is drawn exactly " + "when decide() acts on it at its start", + not disagree and n_leg > 3000 and n_drop > 0) + +# A charge tomorrow: announced only when the add-on will take it. Today's +# dear period is 21:00 (0.50, then 0.30). Tomorrow opens with a dear hour +# (0.45) and the cheapest quarter comes right after it, at 01:00 (0.24). +def two_days(tomorrow): + pts = [] + for i in range(96 * 2): + t = DAY0 + timedelta(minutes=15 * i) + h = (i % 96) / 4.0 + price = (0.50 if h == 21.0 else 0.30) if i < 96 else tomorrow(h) + pts.append(PricePoint(brussels(t), price, False)) + return TariffPlan(PriceRead.series(tuple(pts), PriceProduct.DAY_AHEAD, + "generic_dayahead", DAY0), None, + "provider") + + +def tomorrow_lines(plan, now): + c = make(OFF, plan, now) + return [x for x in build(c, now)["lines"] if x.startswith("Tomorrow")] + + +T_FLAT = two_days(lambda h: 0.45 if h < 1.0 else 0.24) +T_WITH = two_days(lambda h: 0.45 if h < 1.0 else (0.50 if h == 18.0 else 0.24)) +l_with = tomorrow_lines(T_WITH, at(21, 5, 0)) +l_flat = tomorrow_lines(T_FLAT, at(21, 5, 0)) +print(f" tomorrow lines: with a later dear period {l_with}; without {l_flat}") +check("tomorrow's charge (01:00) is announced when a dearer period follows " + "it, and not when the rest of tomorrow is flat", + len(l_with) == 1 and "01:00" in l_with[0] and l_flat == []) +check("...and the add-on charges at 01:00 on the first and does not on the " + "second", + decide_at(make(OFF, T_WITH, at(21, 5, 0)), + at(25, 0, 5)).action == arbitrage.CHARGE + and decide_at(make(OFF, T_FLAT, at(21, 5, 0)), + at(25, 0, 5)).action is None) + +# --------------------------------------------------------------------------- # +print("\n3. wording, English and Dutch") +c6 = make(OFF, Q_PLAN, at(20, 20, 0)) +en = homeview.build(c6, level="ok", checks=[], now=at(20, 20, 0), + lang="en")["plan"] +nl = homeview.build(c6, level="ok", checks=[], now=at(20, 20, 0), + lang="nl")["plan"] +gw_en = [r["why"] for r in en["runs"] if r["kind"] == "grid"] +gw_nl = [r["why"] for r in nl["runs"] if r["kind"] == "grid"] +dw_en = [r["why"] for r in en["runs"] if r["kind"] == "give"] +dw_nl = [r["why"] for r in nl["runs"] if r["kind"] == "give"] +print(f" {gw_en[:1]} {gw_nl[:1]} {dw_en[:1]} {dw_nl[:1]}") +check("the charge says 'lowest price left', English and Dutch", + gw_en and set(gw_en) == {"Lowest price left, \u20ac0.12 per kWh."} + and set(gw_nl) == {"Laagste prijs die nog over is, \u20ac0,12 per kWh."}) +check("...and the sale 'highest price left'", + set(dw_en) == {"Highest price left, \u20ac0.59 per kWh."} + and set(dw_nl) == {"Hoogste prijs die nog over is, \u20ac0,59 per kWh."}) +check("...and no 'published' is left in the run words of either language", + all("ublished" not in str(homeview.uitext.words(l)["home_words"]["run"]) + and "gepubliceerd" not in str( + homeview.uitext.words(l)["home_words"]["run"]) + for l in ("en", "nl"))) +check("the short words fit the plan-line rule: one sentence of at most 12 " + "words", + all(len(x.split()) <= 12 for x in gw_en + gw_nl + dw_en + dw_nl)) + +NOW6 = at(21, 45, 5) +FLAT_DYN = quarters(lambda h: 0.24, days=1) +r_dyn = FLAT_DYN.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.10, + uncertainty=NO_WEATHER, now=NOW6).match( + allowed=lambda v: None, refused=lambda v: v.reason) +FIXED = {"periods": [{"name": "all", "from": "00:00", "to": "24:00", + "days": "all", "eur_per_kwh": 0.30}]} +FIXED_PLAN = static_plan({"static_profile": json.dumps(FIXED)}, at(0), at(36), + now=at(0)) +r_fix = FIXED_PLAN.arbitrage(peak_guard_active=False, + min_spread_eur_kwh=0.10, uncertainty=NO_WEATHER, + now=NOW6).match(allowed=lambda v: None, + refused=lambda v: v.reason) +DN = {"periods": [{"name": "day", "from": "07:00", "to": "22:00", + "days": "all", "eur_per_kwh": 0.34}, + {"name": "night", "from": "22:00", "to": "07:00", + "days": "all", "eur_per_kwh": 0.26}]} +DN_FLAT = static_plan({"static_profile": json.dumps(DN)}, at(23, 0), at(30), + now=at(23, 0)) # 23:00 to 06:00: night only +r_dn = DN_FLAT.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.10, + uncertainty=NO_WEATHER, now=at(23, 10)).match( + allowed=lambda v: None, refused=lambda v: v.reason) +print(f" dynamic: {r_dyn}\n day/night: {r_dn}\n fixed: {r_fix}") +check("a dynamic site whose remaining quarters are equal is refused as flat, " + "and is not called a fully fixed product", + r_dyn and r_dyn.startswith("the tariff is flat - every one of the ") + and "fully fixed" not in r_dyn and "fixed product" not in r_dyn + and r_dyn.endswith("arithmetically impossible")) +check("...a day/night site whose remaining hours are all night: the same", + r_dn and r_dn.startswith("the tariff is flat") + and "fixed" not in r_dn) +check("...a site with a real fixed product is still told so", + r_fix and r_fix.endswith( + "§9.2: a fully fixed product is self-consumption only") + and FIXED_PLAN.product is PriceProduct.FIXED + and DN_FLAT.product is PriceProduct.STATIC + and FLAT_DYN.product is PriceProduct.DAY_AHEAD) + +# --------------------------------------------------------------------------- # +print("\n4. day/night, hourly and price-off pages") +def old_vs_new(plan, opts, hours=24): + """Verdict legs the page drew before (both, when there is a verdict) and + the ones it draws now, probed every 30 minutes.""" + same = diff = none = 0 + for m in range(0, hours * 60, 30): + now = at(0) + timedelta(minutes=m, seconds=7) + c = make(opts, plan, now) + v, _w = homeview.trade_verdict(c, plan, now) + if v is None: + none += 1 + continue + if len(homeview.trade_legs(c, plan, v, now)) == 2: + same += 1 + else: + diff += 1 + return same, diff, none + + +DN_OPTS = dict(OFF, cycle_cost_threshold_eur=0.02, + static_profile=json.dumps(DN)) +DN_48 = static_plan(DN_OPTS, at(0), at(48), now=at(0)) +s_dn = old_vs_new(DN_48, DN_OPTS) +print(f" day/night, 48 h plan, 48 probes: same {s_dn[0]}, changed " + f"{s_dn[1]}, no verdict {s_dn[2]}") +check("day/night (a 48-hour plan, as the refresh builds it): at every probe " + "that has a verdict both legs are drawn, as before; and there are some", + s_dn[1] == 0 and s_dn[0] >= 20) +HOURLY = quarters(lambda h: [.24, .22, .21, .20, .21, .22, .24, .27, .26, .22, + .18, .16, .15, .14, .16, .19, .23, .28, .33, .36, + .34, .29, .26, .24][int(h)], days=2) +HR_OPTS = dict(OFF, cycle_cost_threshold_eur=0.05) +s_hr = old_vs_new(HOURLY, HR_OPTS) +print(f" hourly shape on a 48 h plan: same {s_hr[0]}, changed {s_hr[1]}, " + f"no verdict {s_hr[2]}") +check("an hourly-shaped day-ahead feed with tomorrow known: both legs are " + "drawn at every probe with a verdict, as before", + s_hr[1] == 0 and s_hr[0] >= 20) +s_one = old_vs_new(quarters(lambda h: [.24, .22, .21, .20, .21, .22, .24, .27, + .26, .22, .18, .16, .15, .14, .16, .19, + .23, .28, .33, .36, .34, .29, .26, + .24][int(h)], days=1), HR_OPTS) +print(f" the same with today only: same {s_one[0]}, changed {s_one[1]}, " + f"no verdict {s_one[2]}") +check("...and with today only, the probes that change are the ones where " + "the cheapest quarter left is after the last dear one (F-2)", + s_one[1] > 0 and s_one[0] > 0) +c7 = make(dict(OFF, arbitrage_enabled=False), DN_48, at(12, 0, 0)) +p7 = build(c7, at(12, 0, 0)) +check("price trading off (a day/night site, modes off): no charge or sale " + "is drawn and no bar is marked, as before", + [r for r in p7["runs"] if r["kind"] in ("grid", "give")] == [] + and {b["kind"] for b in p7["prices"]} == {"mid"}) +c8 = make(dict(OFF, arbitrage_enabled=False, battery_modes_enabled=True), + DN_48, at(12, 0, 0), modes=packmode.SOLAR_FIRST) +c8._mode_roles(at(12, 0, 0), None) +p8 = build(c8, at(12, 0, 0)) +check("modes on, every battery Solar first (the owner's setting): no grid " + "charge is drawn, and the gate record changes nothing", + grid_blocks(p8) == [] and {b["kind"] for b in p8["prices"]} == {"mid"}) +c9 = make(dict(OFF, battery_modes_enabled=True), DN_48, at(12, 0, 0), + modes=packmode.SOLAR_FIRST) +check("a controller that never ran a cycle has no gate record, and the " + "default is 'no refusal' (None)", + getattr(c9, "mode_gate", "missing") is None) + +print(f"fails: {len(fails)}") +sys.exit(1 if fails else 0) -- 2.54.0 From 5dfe9720c5d9eac1af5159188e6e445ca0bfa122 Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Mon, 26 Oct 2026 13:10:49 +0100 Subject: [PATCH 02/49] UX-53 review: DECISIONS text, day/night weekend case and peak guard limit Co-Authored-By: Claude Opus 5.5 --- .../DECISIONS/UX-53-home-follows-the-gate.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/goodwe_controller/DECISIONS/UX-53-home-follows-the-gate.md b/goodwe_controller/DECISIONS/UX-53-home-follows-the-gate.md index bec28d3..998cf53 100644 --- a/goodwe_controller/DECISIONS/UX-53-home-follows-the-gate.md +++ b/goodwe_controller/DECISIONS/UX-53-home-follows-the-gate.md @@ -16,8 +16,9 @@ Home asks the add-on's own gate, and does not decide for itself. - **Modes off.** A charge the verdict puts after its last dear period is refused when its time comes: the quarters left are all equal. `leg_taken()` asks `trade_verdict()` (the control cycle's own call) at the leg's start and - keeps the leg only if the gate allows it there. A leg in progress is the verdict's own answer now. The - sale, "Tomorrow:" lines and chart marks use the same answer. + keeps the leg only if the gate allows it there. A leg in progress is the + verdict's own answer now. The sale, "Tomorrow:" lines and chart marks use + the same answer. - **Words.** "Lowest/Highest published price" becomes "Lowest/Highest price left" (the verdict reads only the periods not yet over). The flat-tariff refusal calls a site "a fully fixed product" only when its product is fixed. @@ -26,11 +27,14 @@ Home asks the add-on's own gate, and does not decide for itself. No control decision, command or option. Day/night and hourly sites with a second day of prices keep both legs at every half hour of a day -(`test_ux53.py` part 4); price-off sites draw nothing, as before. +(`test_ux53.py` part 4), except where every price left is equal: with an +all-night weekend, Friday's 22:00 charge is no longer drawn, as the add-on +does not take it. Price-off sites draw nothing, as before. ## Limits The leg's gate is asked on today's plan, as the control cycle will find it at that time. A plan that gets new prices in between is drawn again at the next -refresh. While the peak guard holds, a modes-on page shows no charge for that -time, as the modes-off page already did. +refresh. While the peak guard holds, a modes-on page shows no grid charge at +all, also not the rest of a run the add-on charges in once the guard clears, +as the modes-off page already did. -- 2.54.0 From b6b2d1b184353a9a72b8eea0d07960866e1f9fb5 Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Mon, 26 Oct 2026 19:38:59 +0100 Subject: [PATCH 03/49] UX-54: a Forecast tab with the sun, the house and each battery's level, per hour A new tab beside Home and Batteries for today and tomorrow: the solar forecast, the solar measured so far, the house's expected use and each battery's expected level, with the plan's own charge and give blocks under it. - Blocks are Home's: today's are the plan's `runs`, tomorrow's come from the same builders (`homeview.runs_for()`), in the plan lines' words. - The sun is what solarfc reads; a daily total is spread over the bell and the tab says so. The house is HouseProfile.estimator(), as the plan reads it. - The level is new: `daycurve.curve()` gives each battery the role `modetrade.decide()` gives it and moves it hour by hour inside the plan's floor and ceiling. It is an estimate and counts on `solar_trust_pct`. - Built on request (`GET forecast`), no Home Assistant call. Measured solar is kept per hour in memory from the reading control already takes. - A part not known yet is a note: modes off, no forecast, learning, no forecast for tomorrow, a monthly cycle, no level, stopped, shadow. No decision, command, timing or option key changes. Found, not changed: `dayplan.plan()` over-counts a state-only forecast (DECISIONS/UX-54). Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC --- .../DECISIONS/UX-54-forecast-tab.md | 68 ++ goodwe_controller/DOCS.md | 15 + goodwe_controller/app/daycurve.py | 216 ++++++ goodwe_controller/app/dayview.py | 251 ++++++ goodwe_controller/app/homeview.py | 32 + goodwe_controller/app/main.py | 26 +- goodwe_controller/app/static/app.css | 26 + goodwe_controller/app/static/app.js | 175 ++++- goodwe_controller/app/static/index.html | 27 + goodwe_controller/app/ui_text/en.json | 35 +- goodwe_controller/app/ui_text/nl.json | 35 +- goodwe_controller/app/web.py | 7 + goodwe_controller/test_home.py | 8 +- goodwe_controller/test_selfconfig.py | 8 +- goodwe_controller/test_ux54.py | 719 ++++++++++++++++++ goodwe_controller/tools/ux54_scenarios.py | 275 +++++++ 16 files changed, 1912 insertions(+), 11 deletions(-) create mode 100644 goodwe_controller/DECISIONS/UX-54-forecast-tab.md create mode 100644 goodwe_controller/app/daycurve.py create mode 100644 goodwe_controller/app/dayview.py create mode 100644 goodwe_controller/test_ux54.py create mode 100644 goodwe_controller/tools/ux54_scenarios.py diff --git a/goodwe_controller/DECISIONS/UX-54-forecast-tab.md b/goodwe_controller/DECISIONS/UX-54-forecast-tab.md new file mode 100644 index 0000000..b397810 --- /dev/null +++ b/goodwe_controller/DECISIONS/UX-54-forecast-tab.md @@ -0,0 +1,68 @@ +# UX-54 - a Forecast tab: the sun, the house and each battery's level, per hour + +**Status: DONE 2026-10-26 (owner go 2026-10-26: "Day curve", "New tab").** + +## Decision + +A tab beside Home and Batteries shows today and tomorrow, per hour: the solar +forecast, the solar measured so far, the house's expected use and each +battery's expected level with its blocks. It follows the add-on, as Home does +(UX-53), and decides nothing. + +- **Blocks are Home's.** Today's are the `runs` of the same plan Home draws + (grid, give, monthly cycle). Tomorrow's come from the same builders + (`homeview.runs_for()`). The words are the plan lines'. A block is on the tab + exactly where Home has one, so the UX-53 gate is followed too. +- **The sun** is what `solarfc` reads. An hourly split (Solcast + `detailedForecast`, Open-Meteo `wh_period`) is used as given. A daily total + (Forecast.Solar, and any other state-only sensor, the owner's three "today + remaining" and three "tomorrow") is spread over the 06:00-20:00 bell, and the + tab says so. +- **The house** is `HouseProfile.estimator()`, never below the night median, as + the plan reads it. +- **The level did not exist** (the plan holds targets and run blocks only), so + `daycurve.curve()` derives it: each battery has the role `modetrade.decide()` + gives (follow the house, hold, give, charge from the grid, rest), spare solar + goes to Solar first batteries first, a shortfall is met by those that may + give, both at `dayplan.EFF`, inside the plan's floor and ceiling, from the + level now. It counts on `solar_trust_pct` of the sun, as the plan does, and + says so. Grid charging goes to `modetrade.charge_to()`. It is an estimate. +- **Measured solar** is kept per local hour in memory (`SolarToday`), from the + reading the control cycle already takes; nothing is read back. +- **On request.** `GET forecast` builds the tab (`Controller.forecast_payload()` + reads `/status`' plan first, so today's blocks are the very ones Home has). No + Home Assistant call, nothing cached, nothing in the cycle but one in-memory + add. The page asks every 30 s while the tab shows. +- **Not known yet is a note, not an empty chart:** modes off, no forecast set, + forecast missing or old, learning, no tomorrow forecast, a monthly cycle, no + level reading, control stopped, shadow. + +## What does not change + +No decision, command, timing or option key. The one write in the control path +is `SolarToday.add()` after the solar read, in a `try`, in memory. + +## Limits + +- Prices are planned about a day ahead (`priceruns.HORIZON`), so tomorrow's + blocks appear as the day comes near. The tab says so. +- A give run drawn on a battery that is empty now has no block on Home (no + energy to give), but the curve gives in it if the sun fills it first. +- Tomorrow shows price blocks only. A monthly cycle is drawn today, where the + curve then stops for that battery. +- The curve uses the arbitrage floor and ceiling, as the plan does, not the + battery's own cut-out level. The house profile is a median per hour, net of + the solar the house used. +- Measured solar starts when the add-on starts; the tab says from when. + +## Found, not changed + +`dayplan.plan()` reads the forecast hour by hour with `Forecast.between(s, e)`. +For a state-only forecast `between()` takes its start as the moment the "left +today" figure was read, so each later hour is spread over what is left of the +bell from that hour: on the owner's shape 14.4 kWh left gives 57 kWh over the +hours to midnight (`test_ux54.py` part 1). The plan's sun is overcounted for +those sites. It does not show on a site whose batteries are all Solar first +(no level is planned for them). The tab measures both ends from now +(`dayview.sun_kwh()`), which keeps the total and gives the same hours for an +hourly split. A card should fix the plan. diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index 27b7b20..0ccf26c 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -3530,6 +3530,21 @@ the peak limit, the batteries' switches and modes, and shadow mode, each marked "Applies right away" or "Applies after a restart". **Installer** shows every setting. +### The Forecast screen + +The **Forecast** tab shows today and tomorrow, hour by hour: the solar forecast, +the solar measured so far today, the house's expected use, and each battery's +expected level. Under each battery's chart are its planned blocks, in the same +words as Home. A block is there only where the add-on plans one. + +The level is an estimate. It counts on `solar_trust_pct` of the forecast, as the +plan does, and stays between the battery's low and high levels. A forecast that +gives one total for the day (Forecast.Solar) is spread over the hours, and the +screen says so. Where a part is not known yet (battery modes off, no forecast, +still learning your house, no forecast for tomorrow) the screen says that +instead of drawing an empty chart. It changes nothing in control, and it asks +Home Assistant for nothing: it is built when you open it. + ### Setup, for a new install The **Setup** tab walks an installer through six steps: the meter, the diff --git a/goodwe_controller/app/daycurve.py b/goodwe_controller/app/daycurve.py new file mode 100644 index 0000000..fd4f320 --- /dev/null +++ b/goodwe_controller/app/daycurve.py @@ -0,0 +1,216 @@ +"""UX-54: the expected battery level through the day, for the Forecast tab. + +Pure: batteries, the sun and the house's use in; a level per battery over +time out. Display only: nothing here is read by control, and it never +decides a block. The blocks are the plan's (`homeview.mode_runs()`); this +only says where the level goes, hour by hour, if the add-on does what the +plan says. + +Each step gives every battery a role, as `modetrade.decide()` does (the +names are modetrade's): in a grid block it charges (CHARGE) and the others +rest (REST, no charge); else it follows the house (HOUSE) if it is Solar +first or no price plan is on; inside a give run it covers the house (GIVE); +else it holds (HOLD, may charge from spare solar, gives nothing). Spare +solar (sun less the house's use) goes to Solar first batteries first, then +the rest, by room. A shortfall comes from the batteries that may give, +those in a give run first, by what they hold above the floor. Charging and +giving lose `dayplan.EFF`, as the plan counts. A level stays inside the +floor and ceiling the plan uses, and a battery already outside them does +not move further out. + +`SolarToday` keeps the site's measured solar per hour, in memory, from the +reading the control cycle already has. It is shown and never read back +(TEL-11 keeps site solar out of control). +""" + +import math +from datetime import datetime, timedelta, timezone +from dataclasses import dataclass + +from .dayplan import EFF, _hour_slices, _local +from .modetrade import CHARGE, GIVE, HOLD, HOUSE, REST + +EPS = 1e-9 +GAP_S = 60.0 # a longer gap between readings is not counted +MIN_COVER = 0.5 # share of the elapsed hour a reading must cover + + +@dataclass(frozen=True) +class Batt: + """One battery that is on, with a level and a capacity.""" + name: str + capacity_kwh: float + rating_kw: float + soc: float # % now + solar_first: bool # Solar first: follows the house all day + priced: bool # a price plan is on and its mode is a price mode + grid: tuple = () # ((start, end, to_pct), ...) grid blocks + give: tuple = () # ((start, end), ...) give runs + stop_at: datetime | None = None # a monthly cycle starts: no level after + + +def _role(b, t, charging, own): + """The battery's role at instant `t`; `own`: it charges from the grid; + `charging`: some battery does. Returns `(role, may_charge, may_give)`.""" + in_give = any(s <= t < e for s, e in b.give) + if own: + return CHARGE, False, False + if charging: + return REST, False, b.solar_first or not b.priced or in_give + if b.solar_first or not b.priced: + return HOUSE, True, True + if in_give: + return GIVE, True, True + return HOLD, True, False + + +def _spread(amount, caps, weights): + """Split `amount` over the keys of `caps` by `weights`, none over its + cap. Returns `(taken, left)`: what each took and what no one could.""" + got = {k: 0.0 for k in caps} + left = float(amount) + live = {k for k, c in caps.items() if c > EPS and weights.get(k, 0.0) > 0} + while left > EPS and live: + total = sum(weights[k] for k in live) + used, full = 0.0, set() + for k in live: + part = left * weights[k] / total + room = caps[k] - got[k] + if part >= room - EPS: + part = room + full.add(k) + got[k] += part + used += part + left -= used + if not full: + break + live -= full + return got, max(0.0, left) + + +def curve(batts, start, end, sun_kwh, use_kwh, *, floor_pct, ceiling_pct): + """`{name: [(t, pct), ...]}` from `start` (each battery's level now) to + `end`, a point at every hour edge and every block edge. A battery's list + stops where `sun_kwh(a, b)` is None (the sun is not known) or its + `stop_at` comes. `sun_kwh` and `use_kwh` give kWh for the span `[a, b)`.""" + floor, ceil = float(floor_pct), float(ceiling_pct) + cuts = {end} + for a, _b, _h in _hour_slices(start, end): + cuts.add(a) + for b in batts: + for s, e, *_ in b.grid + b.give: + cuts.update(x for x in (s, e) if start < x < end) + cuts.add(start) + ts = sorted(cuts) + level = {b.name: float(b.soc) for b in batts} + out = {b.name: [(start, level[b.name])] for b in batts} + alive = {b.name for b in batts} + for a, z in zip(ts, ts[1:]): + if not alive: + break + sun = sun_kwh(a, z) + if sun is None: + break + for b in batts: + if b.name in alive and b.stop_at is not None and a >= b.stop_at: + alive.discard(b.name) + net = sun - use_kwh(a, z) + hrs = (z - a).total_seconds() / 3600.0 + want = {} # name -> grid target % + for b in batts: + if b.name in alive: + for s, e, to in b.grid: + if s <= a < e and level[b.name] < to - EPS: + want[b.name] = float(to) + roles = {b.name: _role(b, a, bool(want), b.name in want) + for b in batts if b.name in alive} + by = {b.name: b for b in batts} + for n, to in want.items(): # charging from the grid + b = by[n] + room = (to - level[n]) / 100.0 * b.capacity_kwh + level[n] += min(b.rating_kw * hrs * EFF, room) \ + / b.capacity_kwh * 100.0 + if net > EPS: + caps = {n: min(by[n].rating_kw * hrs, + max(0.0, ceil - level[n]) / 100.0 + * by[n].capacity_kwh / EFF) + for n, r in roles.items() if r[1]} + first = {n: c for n, c in caps.items() if by[n].solar_first} + rest = {n: c for n, c in caps.items() if n not in first} + left = net + for grp in (first, rest): + got, left = _spread(left, grp, dict(grp)) + for n, v in got.items(): + level[n] += v * EFF / by[n].capacity_kwh * 100.0 + elif net < -EPS: + caps = {n: min(by[n].rating_kw * hrs, + max(0.0, level[n] - floor) / 100.0 + * by[n].capacity_kwh * EFF) + for n, r in roles.items() if r[2]} + first = {n: c for n, c in caps.items() if roles[n][0] == GIVE} + rest = {n: c for n, c in caps.items() if n not in first} + left = -net + for grp in (first, rest): + got, left = _spread(left, grp, dict(grp)) + for n, v in got.items(): + level[n] -= v / EFF / by[n].capacity_kwh * 100.0 + for b in batts: + if b.name in alive: + out[b.name].append((z, level[b.name])) + return out + + +# --------------------------------------------------------------------------- # +# Measured solar, per local hour +# --------------------------------------------------------------------------- # +class SolarToday: + """The site's solar power, averaged per local hour of one local day. In + memory only: a restart starts it again, and `first` says from when it + has counted. Display only.""" + + def __init__(self): + self.day = None + self.first = None + self._hours = {} # hour start (UTC) -> [W x s, seconds] + self._last = None # (time, W) + + def add(self, now: datetime, w) -> None: + """One reading (`None`: no reading, which ends the interval).""" + day = _local(now).date() + if day != self.day: + self.day, self.first, self._hours, self._last = day, None, {}, None + try: + ok = w is not None and math.isfinite(float(w)) + except (TypeError, ValueError): + ok = False + if not ok: + self._last = None + return + w = max(0.0, float(w)) + if self.first is None: + self.first = now + if self._last is not None and now > self._last[0]: + dt = min(GAP_S, (now - self._last[0]).total_seconds()) + key = _hour_start(self._last[0]) + row = self._hours.setdefault(key, [0.0, 0.0]) + row[0] += (self._last[1] + w) / 2.0 * dt + row[1] += dt + self._last = (now, w) + + def kwh(self, start: datetime, end: datetime, now: datetime): + """kWh for the hour `[start, end)` up to `now`: the mean power it was + read at over the part of the hour that has passed, or None when the + readings cover under half of that part (or none has passed).""" + if self.day != _local(now).date(): + return None + over = (min(end, now) - start).total_seconds() + row = self._hours.get(start) + if over <= 0 or row is None or row[1] < MIN_COVER * over: + return None + return row[0] / row[1] * over / 3.6e6 + + +def _hour_start(t: datetime) -> datetime: + lt = _local(t) + return lt.replace(minute=0, second=0, microsecond=0).astimezone( + timezone.utc) diff --git a/goodwe_controller/app/dayview.py b/goodwe_controller/app/dayview.py new file mode 100644 index 0000000..fdbc761 --- /dev/null +++ b/goodwe_controller/app/dayview.py @@ -0,0 +1,251 @@ +"""UX-54: the Forecast tab, built on request (`GET forecast`). Reads only. + +Two days, today and tomorrow, per local hour: the solar forecast, the solar +measured so far, the house's expected use, and each battery's expected level +with its blocks. + +It follows the add-on, as Home does (UX-53), and decides nothing: +* the blocks are Home's own: today's are the `runs` of `/status`'s plan, and + tomorrow's come from the same builders (`homeview.runs_for()`), so a block + is on the tab exactly where the plan has one, with the plan's words; +* the sun is `solarfc.Forecast.between()` and the house's use is + `dayplan.HouseProfile.estimator()` read as `dayplan.plan()` reads it; +* the level is `daycurve.curve()`, fed the blocks, the give runs, the packs + and the plan's levels (`modetrade.charge_to()`), floor and ceiling. +A part that is not known yet is a plain note (`home_words.day`), never an +empty chart. DECISIONS/UX-54-forecast-tab.md has the record. +""" + +from datetime import datetime, timedelta + +from . import arbitrage, dayplan, homeview as hv, modetrade +from .daycurve import Batt, curve +from .packmode import SOLAR_FIRST +from .price import brussels + +BLOCKS = ("grid", "give", "cycle") # the kinds a battery's blocks carry + + +def _hours_between(a, b) -> float: + return (b - a).total_seconds() / 3600.0 + + +def build(c, status: dict, now: datetime, lang: str = "en") -> dict: + """The tab's JSON. `status` is the `/status` payload just built, whose + plan holds today's blocks.""" + with hv.in_lang(lang): + return _build(c, status, now) + + +def sun_kwh(fc, now, a, b): + """kWh the forecast gives in `[a, b)`, `a` at or after `now`, or None. + Both ends are measured from `now`: `Forecast.between()` reads its start + as the moment the "left today" figure was taken, so a span that starts + later would be spread over what is left of the bell, not over its own + hours (state-only forecasts, DECISIONS/UX-54-forecast-tab.md).""" + hi, lo = fc.between(now, b), fc.between(now, a) + return None if hi is None or lo is None else max(0.0, hi - lo) + + +def _times(run): + return (datetime.fromisoformat(run["start"]), + datetime.fromisoformat(run["end"])) + + +def _build(c, status, now): + plan, rows = status.get("plan"), status.get("batteries") + if not plan or rows is None: + return {"ok": False} + d0, d1 = hv.local_day(now) + n0, n1 = hv.local_day(d1 + timedelta(hours=2)) + shadow = bool(c.shadow) + key = plan.get("verdict_key") + running = key not in ("held", "stopped") + modes_on = bool(getattr(c, "modes_on", False)) + floor, ceil = arbitrage.soc_bounds(c.o) + fc = getattr(c, "solar_fc", None) + hp = getattr(c, "house_profile", None) + est = hp.estimator() if hp is not None and fc is not None else None + trust = max(0.0, min(1.0, float(getattr(c, "solar_trust_pct", 70)) + / 100.0)) + + # Why a part is not known (None: nothing is missing). + missing = ("modes_off" if not modes_on + else "not_set" if c.fc_entity is None + else "no_forecast" if fc is None + else "learning" if est is None else None) + + # The blocks of each day: today's from Home, tomorrow's by its builders. + runs = [plan["runs"], hv.runs_for(c, n0, n1, now, shadow) if running + else []] + blocks = [[[r for r in rs if r["battery"] == i and r["kind"] in BLOCKS] + for i in range(len(c.inverters))] for rs in runs] + + # The level curve of every battery that is on and known. + notes_b = {} + levels = {} + if running and missing is None: + levels = _levels(c, est, fc, trust, now, n1, runs, floor, ceil, + notes_b) + # Prices are planned about a day ahead (`priceruns.HORIZON`), so tomorrow's + # blocks may be missing now and appear later. + priced = bool(plan.get("has_prices")) and ( + any(c.pack_mode(i).use and c.pack_mode(i).mode in hv.PRICE_MODES + for i in c.inverters) if modes_on else arbitrage.enabled(c.o)) + days = [] + for k, (a, z) in enumerate(((d0, d1), (n0, n1))): + days.append(_day(c, k, a, z, now, fc, est, trust, levels, blocks[k], + rows, shadow, running, key, missing, notes_b, status, + priced)) + return {"ok": True, "running": running, "shadow": shadow, + "now_h": round(_hours_between(d0, now), 3), + "updated": None if fc is None else hv.hhmm(fc.at), + "days": days} + + +def _levels(c, est, fc, trust, now, end, runs, floor, ceil, notes_b): + """`{name: [(t, pct), ...]}` from `daycurve.curve()` for the batteries + that are on, have a level and are not in their monthly cycle.""" + packs = {p.name: p for p in c._mode_packs()} + targets = c.plan_targets() + rp = getattr(c, "mode_runs", None) + plan_on = rp is not None and rp.on + house_w, base_w = est + batts = [] + for i, inv in enumerate(c.inverters): + pm = c.pack_mode(inv) + if not pm.use: + continue + if inv.soc is None or not inv.capacity_kwh: + notes_b[inv.name] = hv.day_note("no_level", name=inv.name) + continue + mine = [r for rs in runs for r in rs + if r["battery"] == i and r["kind"] in BLOCKS] + cyc = [_times(r)[0] for r in mine if r["kind"] == "cycle"] + if cyc and min(cyc) <= now: + notes_b[inv.name] = hv.day_note("cycle", name=inv.name) + continue + if cyc: + notes_b[inv.name] = hv.day_note("cycle", name=inv.name) + to = modetrade.charge_to(packs[inv.name], ceil, targets) + grid = tuple((*_times(r), to) for r in mine + if r["kind"] == "grid" and to is not None) + priced = plan_on and pm.mode != SOLAR_FIRST + give = tuple((g.start, g.end) for g in rp.give) if priced else () + batts.append(Batt(inv.name, float(inv.capacity_kwh), + inv.max_w / 1000.0, float(inv.soc), + pm.mode == SOLAR_FIRST, priced, grid, give, + min(cyc) if cyc else None)) + + def sun(a, z): + kwh = sun_kwh(fc, now, a, z) + return None if kwh is None else kwh * trust + + def use(a, z): + return sum(max(house_w(h), base_w) * _hours_between(s, e) / 1000.0 + for s, e, h in dayplan._hour_slices(a, z)) + if not batts: + return {} + return curve(batts, now, end, sun, use, floor_pct=floor, ceiling_pct=ceil) + + +def _day(c, k, d0, d1, now, fc, house, trust, levels, blocks, rows, shadow, + running, key, missing, notes_b, status, priced): + day = (fc.today, fc.tomorrow)[k] if fc is not None else None + solar = getattr(c, "solar_today", None) + names = [inv.name for inv in c.inverters] + hours = [] + for s, e, h in dayplan._hour_slices(d0, d1): + f = None + if fc is not None and e > now and (k == 0 or fc.tomorrow is not None): + f = sun_kwh(fc, now, max(s, now), e) + got = solar.kwh(s, e, now) if k == 0 and solar is not None \ + and s < now else None + use = None if house is None else max(house[0](h), house[1]) \ + * _hours_between(s, e) / 1000.0 + row = {"start_h": round(_hours_between(d0, s), 3), + "end_h": round(_hours_between(d0, e), 3), "label": hv.hhmm(s), + "forecast_kwh": None if f is None else round(f, 3), + "measured_kwh": None if got is None else round(got, 3), + "house_kwh": None if use is None else round(use, 3), + "levels": []} + hours.append((s, row)) + batts = [] + for i, inv in enumerate(c.inverters): + row = rows[i] if i < len(rows) else {} + pts = [(t, v) for t, v in levels.get(inv.name, ()) if d0 <= t <= d1] + mine = blocks[i] + batts.append({ + "name": inv.name, "capacity_kwh": inv.capacity_kwh, + "off": row.get("use") is False, + "levels": [[round(_hours_between(d0, t), 3), round(v, 1)] + for t, v in pts], + "blocks": [{x: r[x] for x in ("kind", "from", "to", "start_h", + "end_h", "what", "why", "amount")} + for r in mine], + "line": _line(c, inv, row, mine, running, key, shadow)}) + at = {t: v for t, v in pts} + for s, hr in hours: + hr["levels"].append(None if s not in at else round(at[s], 1)) + has_levels = any(b["levels"] for b in batts) + fcs = [h["forecast_kwh"] for _s, h in hours] + notes = [] + if missing is not None: + notes.append(hv.day_note(missing)) + if not running: + notes.append(hv.why_words(key)) + if shadow: + notes.append(hv.why_words("shadow")) + line = "" + if fc is not None: + if k == 0: + line = hv.line_solar(fc.today.kwh, fc.today.kwh * trust, + hv.hhmm(fc.at)) + elif fc.tomorrow is not None: + line = hv.day_note("tomorrow_total", + kwh=hv._kwh(fc.tomorrow.kwh), + count=hv._kwh(fc.tomorrow.kwh * trust)) + if day is not None and not day.periods: + notes.append(hv.day_note("estimate")) + if k == 1 and fc.tomorrow is None: + notes.append(hv.day_note("no_tomorrow")) + if k == 0 and solar is not None and solar.first is not None \ + and solar.day == brussels(now).date() \ + and _hours_between(d0, solar.first) > 0.5: + notes.append(hv.day_note("measured_from", at=hv.hhmm(solar.first))) + if has_levels: + notes.append(hv.day_note("trust", pct=round(trust * 100))) + if k == 1 and _tomorrow_line(status): + notes.append(_tomorrow_line(status)) + if k == 1 and running and priced: + notes.append(hv.day_note("ahead")) + notes += [notes_b[n] for n in names if n in notes_b] + return {"key": ("today", "tomorrow")[k], "date": brussels(d0).date() + .isoformat(), + "length_h": int(round(_hours_between(d0, d1))), + "line": line, "notes": list(dict.fromkeys(notes)), + "sun": {"forecast": any(x is not None for x in fcs), + "estimate": day is not None and not day.periods, + "measured": any(h["measured_kwh"] is not None + for _s, h in hours), + "house": house is not None}, + "hours": [h for _s, h in hours], "batteries": batts} + + +def _tomorrow_line(status) -> str: + """Home's own line about tomorrow's prices, for the tomorrow tab.""" + return (status.get("plan") or {}).get("tomorrow_line") or "" + + +def _line(c, inv, row, mine, running, key, shadow) -> str: + """What to say for a battery with no block: the plan's own words.""" + if not running: + return hv.why_words(key) + if row.get("use") is False: + return hv.why_words("off") + if mine: + return "" + if getattr(c, "modes_on", False) and row.get("mode_choice") in \ + hv.PRICE_MODES: + return hv.day_note("no_blocks") + return hv.run_house_what(shadow) + " " + hv.run_house_why(shadow) diff --git a/goodwe_controller/app/homeview.py b/goodwe_controller/app/homeview.py index ae3e103..95a8ae6 100644 --- a/goodwe_controller/app/homeview.py +++ b/goodwe_controller/app/homeview.py @@ -16,6 +16,7 @@ them, so nothing here invents a plan. Every sentence is in the words section at the bottom and is held to STYLE.md by test_messages.py. """ +import contextlib from datetime import datetime, timedelta, timezone from . import arbitrage, messages, peakguard, uitext @@ -612,6 +613,19 @@ def region_of(opts): if rid in REGION_PRESETS else None} +def runs_for(c, day0, day1, now, shadow): + """UX-54: the price blocks of the window `[day0, day1)`, by the same + builders `build_plan()` uses for today: the batteries' price runs with + modes on, the verdict's legs with them off. Nothing is decided here.""" + if getattr(c, "modes_on", False): + return mode_runs(c, day0, day1, now, shadow)[0] + fresh = c.fresh_tariff_plan(now) + verdict, _why = trade_verdict(c, fresh, now) + if verdict is None: + return [] + return trade_runs(c, fresh, verdict, day0, day1, now, shadow)[0] + + def build_plan(c, now, shadow, running, held=False, settled=True): day0, day1 = local_day(now) fresh = c.fresh_tariff_plan(now) @@ -1001,6 +1015,16 @@ def state_of(c, level, shadow, waiting=False): return "running" +@contextlib.contextmanager +def in_lang(lang): + """UX-54: build sentences in `lang` for the length of a `with` block.""" + _LANG[0] = uitext.lang_of(lang) + try: + yield + finally: + _LANG[0] = "en" + + def build(c, *, level: str, checks: list, now: datetime | None = None, lang: str = "en", held: bool | None = None, waiting: bool = False) -> dict: @@ -1329,6 +1353,11 @@ def run_house_amount() -> str: return _t("run.house_amount") +def day_note(key, **values) -> str: + """UX-54: one sentence of the Forecast tab (`home_words.day`).""" + return _t("day." + key, **values) + + def line_solar(fc_kwh, count_kwh, at) -> str: return _t("solar.line", kwh=_kwh(fc_kwh), count=_kwh(count_kwh), at=at) @@ -1502,6 +1531,9 @@ def _samples() -> list: out += [("plan", today_words(k)) for k in words["today"]] out += [("plan", line_solar(9.2, 6.4, "10:15")), ("plan", _t("solar.no_forecast")), ("plan", _t("solar.learning"))] + out += [("plan", day_note(k, pct=70, name="Battery A", at="08:15", + kwh="9.2 kWh", count="6.4 kWh")) + for k in words["day"]] out += [("plan", line_no_trade(k)) for k in ("not_covered", "no_prices", "peak", "not_firm", "not_worth", "no_level")] diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 3b228bf..37cadaf 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -64,7 +64,8 @@ from .clamp import ( shortfall_note, write_clamp_note, ) from . import freshness -from . import homeview +from . import dayview, homeview +from .daycurve import SolarToday from . import messages from .freeze import ( FreezeWatchdog, FreezeWatchdogConfig, looks_frozen, rail_unreachable, rail_w, @@ -1261,6 +1262,9 @@ class Controller: # UX-13 P1: today's prices as the Home chart has seen them. Display # only; nothing reads it back into a decision. self.home_prices = homeview.DayPrices() + # UX-54: the site's measured solar per hour, for the Forecast tab. + # In memory, display only; nothing reads it back into a decision. + self.solar_today = SolarToday() # TARIFF-11. When the most recent cycle() STARTED. `arb_at < cycle_at` # is the exact, derived statement of "the latest cycle did not get as # far as the arbitrage step", which is the fact the *Arbitrage* row @@ -3381,6 +3385,11 @@ class Controller: *(self.hass.state(entity) for entity in roster)) self.pv_w, self.pv_why, self.pv_parts = pv_read.total( list(zip(roster, states)), pv_read.inverted(self.o)) + # UX-54: kept for the Forecast tab only. It must never cost a cycle. + try: + self.solar_today.add(datetime.now(timezone.utc), self.pv_w) + except Exception: # noqa: BLE001 + _LOG.debug("measured solar not kept", exc_info=True) def _peak_fc_unit_log(self, fault) -> None: """SAFETY-118. One WARNING when the peak forecast's unit cannot be read @@ -7635,6 +7644,21 @@ class Controller: out["home_error"] = True return out + def forecast_payload(self, lang: str = "en") -> dict: + """UX-54: the Forecast tab's data. Built on request, from what the + control cycle already holds; no Home Assistant call. Today's blocks are + Home's own, so `status_payload()` is built first and read.""" + try: + return dayview.build(self, self.status_payload(lang=lang), + datetime.now(timezone.utc), lang) + except Exception: # noqa: BLE001 + if not getattr(self, "_forecast_failed", False): + self._forecast_failed = True + _LOG.exception("The Forecast tab could not be built") + else: + _LOG.debug("The Forecast tab failed again", exc_info=True) + return {"ok": False} + async def handle_action(self, what: str, *, unit=None, value=None): now = datetime.now(timezone.utc) if what in ("pack_use", "pack_mode", "pack_sell"): diff --git a/goodwe_controller/app/static/app.css b/goodwe_controller/app/static/app.css index 2a38a93..faf35e5 100644 --- a/goodwe_controller/app/static/app.css +++ b/goodwe_controller/app/static/app.css @@ -246,3 +246,29 @@ select.sel:disabled{opacity:.6} .simple .card .lbl{font-weight:600;font-size:15px} .simple .inv .hd{border-bottom:0} .simple .inv .f{border-top:0;padding-top:0} +/* UX-54: the Forecast screen */ +.fcline{text-align:right} +.fcnote{background:var(--info-bg);border-radius:10px;padding:8px 10px;margin:0 0 8px} +.fcbars{display:flex;align-items:flex-end;gap:2px;height:110px} +.fcol{flex:1;position:relative;height:100%;min-width:0} +.fcol i{position:absolute;display:block} +.fcol .sun{bottom:0;left:0;right:0;background:var(--solar);border-radius:3px 3px 0 0;opacity:.85} +.fcol .sun.est{opacity:.5} +.fcol .got{bottom:0;left:30%;right:30%;background:repeating-linear-gradient(135deg,var(--ink) 0 3px,var(--card) 3px 5px);border:1px solid var(--ink);border-bottom:0} +.fcol .use{left:-1px;right:-1px;height:3px;background:var(--house)} +.fcols{position:absolute;inset:0;display:flex;gap:0} +.fcols>i{flex:1;min-width:0;display:block} +.lvlchart{position:relative;height:110px;margin:18px 0 4px} +.lvlchart svg{position:absolute;inset:0;width:100%;height:100%;display:block} +.lvlchart .yl{position:absolute;right:2px;font-size:10px;color:var(--faint);pointer-events:none} +.fcnow{position:absolute;top:-4px;bottom:0;width:2px;background:var(--ink);opacity:.7;pointer-events:none} +.fcnow span{position:absolute;top:-15px;left:-10px;font-size:10px} +.legend i.sun{background:var(--solar)} +.legend i.got{background:repeating-linear-gradient(135deg,var(--ink) 0 2px,var(--card) 2px 4px);border:1px solid var(--ink)} +.legend i.use{height:3px;background:var(--house)} +.legend i.lv{height:3px;background:var(--batt)} +table.fct{width:100%;border-collapse:collapse;font-size:12px;font-variant-numeric:tabular-nums} +table.fct th,table.fct td{padding:4px 3px;text-align:right;border-bottom:1px solid var(--line)} +table.fct th:first-child,table.fct td:first-child{text-align:left} +table.fct th{color:var(--muted);font-weight:600} +.ymax{position:absolute;top:-2px;left:2px;font-size:10px;color:var(--faint);pointer-events:none} diff --git a/goodwe_controller/app/static/app.js b/goodwe_controller/app/static/app.js index faa8b1b..529b878 100644 --- a/goodwe_controller/app/static/app.js +++ b/goodwe_controller/app/static/app.js @@ -448,6 +448,175 @@ }); } + /* ---------------- Forecast (UX-54) ---------------- */ + // Today and tomorrow, per hour: the solar forecast and what was measured, + // the house's expected use, and each battery's expected level with its + // blocks. The data is built by app/dayview.py when the tab asks (GET + // forecast), the sentences in the page's language. A part that is not + // known yet comes as a note, so no chart is ever drawn empty. + // The SVG namespace, read off the page's own icons: the page names no URL. + const SVGNS = document.querySelector("svg").namespaceURI; + function sv(tag, attrs) { + const e = document.createElementNS(SVGNS, tag); + for (const k in attrs || {}) e.setAttribute(k, attrs[k]); + return e; + } + let FC = null; + let fcDay = 0; + let fcBusy = false; + let fcTimer = null; + const fmtKwh = (x) => dec(Number(x).toFixed(1)); + const hoursOf = (d) => d.length_h || d.hours.length || 24; + function endLabel(d, i) { return i + 1 < d.hours.length ? d.hours[i + 1].label : "24:00"; } + function hourTip(d, i) { + const x = d.hours[i]; + const lines = []; + if (x.forecast_kwh != null) lines.push(t("forecast.forecast") + ": " + fmtKwh(x.forecast_kwh) + " kWh"); + if (x.measured_kwh != null) lines.push(t("forecast.measured") + ": " + fmtKwh(x.measured_kwh) + " kWh"); + if (x.house_kwh != null) lines.push(t("forecast.house") + ": " + fmtKwh(x.house_kwh) + " kWh"); + d.batteries.forEach((b, j) => { if (x.levels[j] != null) lines.push(b.name + ": " + dec(Math.round(x.levels[j]).toString()) + " %"); }); + return [x.label + " " + t("plan.time_to") + " " + endLabel(d, i)].concat(lines); + } + function hourItem(tag, cls, d, i, kids) { + const tip = hourTip(d, i); + const e = h(tag, { class: cls, tabindex: "-1", role: "img", "aria-label": tip.join(". "), "data-tip": JSON.stringify(tip) }, kids); + return e; + } + function renderSun(d) { + const card = $("#fc-sun"); + const any = d.sun.forecast || d.sun.measured || d.sun.house; + card.hidden = !any; + if (!any) return; + const mx = Math.max(0.5, ...d.hours.map((x) => Math.max(x.forecast_kwh || 0, x.measured_kwh || 0, x.house_kwh || 0))); + const pct = (v) => Math.max(0, Math.min(100, v / mx * 100)) + "%"; + const bars = $("#fc-bars"); + bars.setAttribute("aria-label", t("forecast.sun")); + bars.replaceChildren(...d.hours.map((x, i) => hourItem("div", "fcol", d, i, [ + x.forecast_kwh != null ? h("i", { class: "sun" + (d.sun.estimate ? " est" : ""), style: "height:" + pct(x.forecast_kwh) }) : null, + x.measured_kwh != null ? h("i", { class: "got", style: "height:" + pct(x.measured_kwh) }) : null, + x.house_kwh != null ? h("i", { class: "use", style: "bottom:calc(" + pct(x.house_kwh) + " - 1px)" }) : null]))); + roving(bars); + const chart = $("#fc-sunchart"); + chart.querySelectorAll(".fcnow, .ymax").forEach((e) => e.remove()); + chart.append(h("span", { class: "ymax", "aria-hidden": "true" }, fmtKwh(mx))); + if (fcDay === 0) chart.append(nowMark(d)); + const used = [["sun", d.sun.forecast, "forecast"], ["got", d.sun.measured, "measured"], ["use", d.sun.house, "house"]]; + $("#fc-legend").replaceChildren(...used.filter((u) => u[1]).map((u) => h("span", {}, h("i", { class: u[0], "aria-hidden": "true" }), t("forecast." + u[2])))); + } + function nowMark(d) { + return h("div", { class: "fcnow", "aria-hidden": "true", style: "left:" + Math.max(0, Math.min(100, FC.now_h / hoursOf(d) * 100)) + "%" }, h("span", {}, t("plan.now"))); + } + function levelChart(d, b, bi) { + const L = hoursOf(d), W = L * 10; + const x = (hr) => hr * 10; + const box = h("div", { class: "chart" }); + const area = h("div", { class: "lvlchart" }); + const svg = sv("svg", { viewBox: "0 0 " + W + " 100", preserveAspectRatio: "none", "aria-hidden": "true", focusable: "false" }); + [0, 50, 100].forEach((p) => svg.append(sv("line", { x1: 0, x2: W, y1: 100 - p, y2: 100 - p, stroke: "var(--line)", "stroke-width": 1, "vector-effect": "non-scaling-stroke" }))); + const fill = { grid: "var(--accent)", give: "var(--house)", cycle: "var(--warn)" }; + for (const r of b.blocks) { + if (r.end_h <= r.start_h) continue; + svg.append(sv("rect", { x: x(r.start_h), y: 0, width: x(r.end_h) - x(r.start_h), height: 100, fill: fill[r.kind] || "var(--mid)", opacity: 0.18 })); + } + const pts = b.levels.map((p) => x(p[0]) + "," + (100 - p[1])); + svg.append(sv("polygon", { points: x(b.levels[0][0]) + ",100 " + pts.join(" ") + " " + x(b.levels[b.levels.length - 1][0]) + ",100", fill: "var(--batt)", opacity: 0.12 })); + svg.append(sv("polyline", { points: pts.join(" "), fill: "none", stroke: "var(--batt)", "stroke-width": 2.5, "stroke-linejoin": "round", "vector-effect": "non-scaling-stroke" })); + area.append(svg); + [["100", 0], ["50", 50], ["0", 100]].forEach((y) => area.append(h("span", { class: "yl", "aria-hidden": "true", style: "top:" + y[1] + "%;transform:translateY(" + (y[1] === 100 ? "-100%" : y[1] === 0 ? "0" : "-50%") + ")" }, y[0] + " %"))); + area.append(h("div", { class: "fcols", role: "group", "aria-label": b.name }, ...d.hours.map((hx, i) => hourItem("i", "", d, i, null)))); + roving(area.querySelector(".fcols")); + if (fcDay === 0) area.append(nowMark(d)); + box.append(area); + // The blocks under the chart, drawn as Home draws them. + if (b.blocks.length) { + const lane = h("div", { class: "lane", role: "group", "aria-label": b.name }); + for (const r of b.blocks) { + if (r.end_h <= r.start_h) continue; + lane.append(chartItem("b", r.kind, "left:" + (r.start_h / L * 100) + "%;width:" + ((r.end_h - r.start_h) / L * 100) + "%", + b.name + ", " + r.from + " " + t("plan.time_to") + " " + r.to, [r.what, r.why, r.amount])); + } + box.append(lane); + roving(lane); + } + box.append(h("div", { class: "axis", "aria-hidden": "true" }, h("span", {}, "00:00"), h("span", {}, "06:00"), h("span", {}, "12:00"), h("span", {}, "18:00"), h("span", {}, "24:00"))); + return box; + } + function batteryCardFc(d, b, bi) { + const drawn = b.levels.length > 1; + const list = b.blocks.map((r) => h("li", {}, h("strong", {}, r.from + " " + t("plan.time_to") + " " + r.to + ": "), tipText([r.what, r.why, r.amount]))); + return h("div", { class: "card" }, + h("div", { class: "row spread" }, h("h2", {}, b.name), + h("span", { class: "tiny muted" }, (b.capacity_kwh ? fmtKwh(b.capacity_kwh) + " kWh · " : "") + t("forecast.levels"))), + drawn ? levelChart(d, b, bi) : null, + list.length ? h("ul", { class: "plain small" }, ...list) : null, + b.line ? h("p", { class: "small muted", style: "margin:6px 0 0" }, b.line) : null); + } + function renderTable(d) { + const more = $("#fc-more"); + const any = d.sun.forecast || d.sun.measured || d.sun.house; + more.hidden = !any; + if (!any) return; + const lv = d.batteries.filter((b) => b.levels.length > 1); + const head = h("tr", {}, h("th", { scope: "col" }, t("forecast.hour")), + d.sun.forecast ? h("th", { scope: "col" }, t("forecast.solar")) : null, + d.sun.measured ? h("th", { scope: "col" }, t("forecast.got")) : null, + d.sun.house ? h("th", { scope: "col" }, t("forecast.use")) : null, + ...lv.map((b) => h("th", { scope: "col" }, b.name + " %"))); + const cell = (v, f) => h("td", {}, v == null ? "–" : f(v)); + const rows = d.hours.map((x) => h("tr", {}, + h("td", {}, x.label), + d.sun.forecast ? cell(x.forecast_kwh, fmtKwh) : null, + d.sun.measured ? cell(x.measured_kwh, fmtKwh) : null, + d.sun.house ? cell(x.house_kwh, fmtKwh) : null, + ...lv.map((b) => cell(x.levels[d.batteries.indexOf(b)], (v) => dec(Math.round(v).toString()))))); + $("#fc-table").replaceChildren(h("thead", {}, head), h("tbody", {}, ...rows)); + } + function renderForecast() { + const loading = $("#fc-loading"); + if (!FC || FC.ok !== true) { + loading.hidden = false; loading.textContent = FC ? t("offline.name") : t("forecast.loading"); + $("#fc-notes").replaceChildren(); $("#fc-sun").hidden = true; $("#fc-batts").replaceChildren(); $("#fc-more").hidden = true; $("#fc-line").textContent = ""; + return; + } + loading.hidden = true; + const d = FC.days[fcDay]; + $("#fc-line").textContent = d.line || ""; + $("#fc-notes").replaceChildren(...d.notes.map((n) => h("p", { class: "fcnote small" }, n))); + renderSun(d); + $("#fc-batts").replaceChildren(...d.batteries.map((b, i) => batteryCardFc(d, b, i))); + renderTable(d); + document.querySelectorAll("#fc-day button").forEach((b) => { + const on = Number(b.dataset.d) === fcDay; + b.setAttribute("aria-checked", String(on)); b.tabIndex = on ? 0 : -1; + }); + } + async function loadForecast() { + if (fcBusy) return; + fcBusy = true; + try { + const r = await fetch("forecast?lang=" + LANG, { cache: "no-store" }); + if (!r.ok) throw new Error("forecast " + r.status); + FC = await r.json(); + } catch (e) { FC = { ok: false }; } + finally { fcBusy = false; } + renderForecast(); + } + document.querySelectorAll("#fc-day button").forEach((b, i, all) => { + b.addEventListener("click", () => { fcDay = Number(b.dataset.d); renderForecast(); }); + b.addEventListener("keydown", (e) => { + const j = { ArrowRight: i + 1, ArrowLeft: i - 1 + all.length }[e.key]; + if (j === undefined) return; + e.preventDefault(); fcDay = j % all.length; renderForecast(); all[fcDay].focus(); + }); + }); + // Every 30 s while the screen shows: the data moves slowly, and each load + // is built on request. + function scheduleForecast() { + const on = !document.hidden && $("#t-fc").getAttribute("aria-selected") === "true"; + if (on && !fcTimer) { loadForecast(); fcTimer = setInterval(loadForecast, 30000); } + if (!on && fcTimer) { clearInterval(fcTimer); fcTimer = null; } + } + /* ---------------- control buttons ---------------- */ // The action is a toggle, so the page reads the live state first and sends // nothing when control is already where the person asked it to be. @@ -511,11 +680,13 @@ } addEventListener("hashchange", fromHash); - /* ---------------- polling: every 3 s while Home or Batteries shows ---------------- */ + /* ---------------- polling: every 3 s while Home, Batteries or Forecast shows ---------------- */ let timer = null; function schedule() { + scheduleForecast(); const live = !document.hidden && ($("#t-home").getAttribute("aria-selected") === "true" || - $("#t-batt").getAttribute("aria-selected") === "true"); + $("#t-batt").getAttribute("aria-selected") === "true" || + $("#t-fc").getAttribute("aria-selected") === "true"); if (live && !timer) { refresh(); timer = setInterval(refresh, 3000); } if (!live && timer) { clearInterval(timer); timer = null; } } diff --git a/goodwe_controller/app/static/index.html b/goodwe_controller/app/static/index.html index 24a34eb..0566911 100644 --- a/goodwe_controller/app/static/index.html +++ b/goodwe_controller/app/static/index.html @@ -18,6 +18,7 @@ @@ -114,6 +115,32 @@ + +