From b6b2d1b184353a9a72b8eea0d07960866e1f9fb5 Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Mon, 26 Oct 2026 19:38:59 +0100 Subject: [PATCH 1/7] 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 @@ + +