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..e0ff423 --- /dev/null +++ b/goodwe_controller/DECISIONS/UX-54-forecast-tab.md @@ -0,0 +1,146 @@ +# 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. +- **Tomorrow is read ahead.** Home sizes a block from the battery's level now + and draws none when there is no room or energy (`room()` and `plan_need()` + in `homeview.mode_runs()`, the leg sizing in `trade_runs()`). For a day that + has not started that level says nothing, so tomorrow's blocks are built with + `ahead` (Home never passes it): `mode_runs()` reads each block at the level + the curve has when it starts, with its own filter and `MIN_KWH`, so there is + no give block where the battery is at the floor and no grid block where it + is at its planned level. Where the curve has no level yet, a charge is read + from the floor and a give from the ceiling. The blocks and the curve follow + each other and are taken to a fixed point. The gate (`mode_gate`, UX-53) is + not bypassed. +- **Modes off has no curve** (no forecast is read, no house profile kept), so + `trade_runs(ahead=f)` reads each leg at Home's own count + (`dayview._counted()`): the level now, plus what the blocks before it put in, + less what they take out. No sun is read and the house only takes, so no give + block where the emptiest battery is at the floor by that count, and control + does not give there. A charge is read from the floor: a full battery covers + the house before the cheap hour, and control charges it back then. Walked + every 15 minutes on a cloudy day, the packs moved by control and a 400 W + house (`test_ux54.py` part 7, cases A-D, one price quarter per leg), + control charges and gives in exactly the quarters the tab has a block. +- **A block lasts as long as control acts.** A cheap run is sized for all the + batteries charging together, so a slower one is still below + `modetrade.charge_to()` when it ends, and control plans the next cheap slots + (`Controller._price_runs()`). `dayview._levels()` asks the same functions, + `modetrade.energy()` and `priceruns.plan()`, with the packs at the curve's + level, and draws the grid block to where control stops. Each battery's block + then ends where the curve has it at its own level, and a give block where + it reaches the floor: the curve puts a point at both (`daycurve.curve()` + cuts the step there), and the block ends at the first point where the level + the tab shows (0.1 %) is there. What is proven: control walked every 15 + minutes through tomorrow with its own model of the packs, not the curve's + levels (`_mode_roles()` each quarter; the house from the profile, the sun + at 100 % trust, a GIVE role counted only where control's own discharge + envelope `packshare.envelope()` is open, the cut-out at the floor), charges + and gives in exactly the quarters the tab has a block in 13 cases + (`test_ux54.py` part 7, `own_walk()`), e.g. 25/45 % with the peak guard: + control gives 17:00-20:15, the block is 17:00-20:06; 30/80 % with a 7 kW + evening house, B held to its rating: control 17:00-19:45, the block + 17:00-19:32. The floor point is placed in one cut, where the battery runs + out at the rate it gives (`_floor_cut()`, checked to the second against + times worked out by hand), so a build stays small: at most 14 floor cuts + and 28 level points per battery and day in those cases. With a cut-out under + the floor, or a house or sun that differs from the forecast and profile, + control differs (Limits). +- **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, shared as control shares it (by what each holds above the floor, + `packshare.weights()`, each at most its rating, the rest passed on), both + at `dayplan.EFF`, inside the plan's floor and ceiling, from the + level now. Spare solar is `trust x max(0, sun - house)` and what is stored + loses `EFF`, the plan's own `trust x EFF x max(0, sun - house)` (10 kWh of + sun, 2 kWh of house, 70 %: 5.32 kWh stored). A shortfall is not scaled by + trust. The tab says it counts on `solar_trust_pct`. 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 a block of + tomorrow later than 24 h from now, or one that needs prices not yet + published, appears as the day comes near. The tab says so. +- Today's blocks are Home's, and are not extended or shortened: control may + plan more once they end, and Home draws it then. The curve stops charging + at its level (`charge_to()`) and giving at the floor. +- Today, a give run in the plan on a battery that is empty now has no block on + Home (no energy to give). The tab equals Home, and says so: "The add-on plans + to give power. No block shows while a battery has no energy to give." The + curve gives in that run if the sun fills the battery first. +- With no forecast for tomorrow the curve stops at the first hour of the sun, + `solarfc.BELL_FROM_H` (06:00): `Forecast.between()` has no answer past it. The + note names that hour from the constant. +- 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. Control stops a pack at its cut-out, so with a + cut-out under the floor a battery in a give run covers the house below the + floor; Home draws no block there either. The house profile is a median per + hour, net of the solar the house used. +- Modes off: the count knows no sun and no house. A battery at the floor that + the sun fills before a give gets no block until then (the tab says "The + add-on plans to give power. No block shows while a battery has no energy to + give."), and a small charge may be used by the house before the give. The + other way round, a charge is drawn even when the sun fills the batteries + before it: at the ceiling control then takes none (a cheap quarter at 13:00 + on a sunny day: the tab draws the charge, control charges nothing at 95 % + or 100 %). + A leg is the verdict's as it stands now, one price period, as on Home; with + several equal prices in a row control takes the next one as each passes. +- Measured solar starts when the add-on starts; the tab says from when. +- Clock changes: the tab walks local hours with `dayplan._hour_slices()`, + which steps in UTC (SAFETY-123), and keys measured solar by the start of + the local hour taken back from the UTC instant, so the spring day has 23 + hours, the autumn day 25 (two of them 02:00), and a request always returns + (`test_ux54_dst.py`, every quarter of both change days, with and without + tzdata). The solar bell is spread over hours from local midnight, so on + those two days it is an hour off the clock (SAFETY-123, not changed here). + +## 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..b662fb1 --- /dev/null +++ b/goodwe_controller/app/daycurve.py @@ -0,0 +1,293 @@ +"""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, each at most +its rating, the rest passed on (as control shares it, `packshare.weights()` +and the allocator's fill). A battery that empties inside a step gets a +point there (`_floor_cut()`), as one that reaches its grid level does. +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 +MIN_CUT = timedelta(seconds=1) # no step is cut closer to its ends +MAX_FLOOR_CUTS = 400 # a bound on the floor cuts of one curve +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 _give_caps(roles, level, by, floor, hrs): + """`(first, rest, held)`: what each battery that may give can give in a + step of `hrs` hours (its rating, or what it holds above the floor), those + in a give run first, and what each holds above the floor (as given).""" + held = {n: max(0.0, level[n] - floor) / 100.0 * by[n].capacity_kwh * EFF + for n, r in roles.items() if r[2]} + caps = {n: min(by[n].rating_kw * hrs, h) for n, h in held.items()} + first = {n: c for n, c in caps.items() if roles[n][0] == GIVE} + return first, {n: c for n, c in caps.items() if n not in first}, held + + +def _floor_cut(a, z, net, roles, level, by, floor, hrs): + """Where in the step `[a, z)` the first battery that gives reaches the + floor, or None. The shortfall comes at a steady rate, `-net / hrs` kW. + It is shared as control shares it (`packshare.weights()`: by the energy + each holds above its cut-out, here the floor), each at most its rating, + what one cannot take passed to the others, those in a give run first. + A battery giving `v` kW that holds `h` kWh is empty at `h / v` hours; a + cut there makes the step come out exact. A battery already at the floor + holds nothing, gives nothing and never sets a cut.""" + rate, cut = -net / hrs, None + *groups, held = _give_caps(roles, level, by, floor, hrs) + for grp in groups: + rates, rate = _spread(rate, {n: by[n].rating_kw for n in grp}, + {n: held[n] for n in grp}) + for n, v in rates.items(): + if held[n] > EPS and v > EPS: + t = a + timedelta(hours=held[n] / v) + if a + MIN_CUT < t < z - MIN_CUT and (cut is None + or t < cut): + cut = t + return cut + + +def curve(batts, start, end, sun_kwh, use_kwh, *, floor_pct, ceiling_pct, + trust=1.0, stats=None): + """`{name: [(t, pct), ...]}` from `start` (each battery's level now) to + `end`, a point at every hour edge, every block edge and where a battery + reaches its grid level or, giving, the floor. 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)`. + Spare solar is `trust x max(0, sun - use)`, and what is stored loses + `EFF`: `dayplan.plan()`'s own `trust x EFF x max(0, sun - house)`. A + shortfall is the house's use less the whole sun. `stats`, a dict or + None: gets `floor_cuts` (the floor points placed) and `capped` (True when + `MAX_FLOOR_CUTS` stopped one).""" + 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} + floor_cuts, capped = 0, False + i = 0 + while i < len(ts) - 1: + a, z = ts[i], ts[i + 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) + if net > 0.0: + net *= trust + 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) + by = {b.name: b for b in batts} + # A battery that reaches its level inside the step gets a point + # there: the step is cut and read again, so the line is exact. + cut = None + for n, to in want.items(): + b = by[n] + room = (to - level[n]) / 100.0 * b.capacity_kwh + rate = b.rating_kw * hrs * EFF + if EPS < room < rate * (1.0 - 1e-9): + t_r = a + (z - a) * (room / rate) + if a < t_r < z and (cut is None or t_r < cut): + cut = t_r + if cut is not None: + ts.insert(i + 1, cut) + continue + roles = {b.name: _role(b, a, bool(want), b.name in want) + for b in batts if b.name in alive} + # A battery that gives all it holds inside the step gets a point + # where it reaches the floor, the same way. + cut = _floor_cut(a, z, net, roles, level, by, floor, hrs) \ + if net < -EPS else None + if cut is not None and floor_cuts >= MAX_FLOOR_CUTS: + capped, cut = True, None + if cut is not None: + floor_cuts += 1 + ts.insert(i + 1, cut) + continue + 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: + first, rest, held = _give_caps(roles, level, by, floor, hrs) + left = -net + for grp in (first, rest): + got, left = _spread(left, grp, {n: held[n] for n in 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])) + i += 1 + if stats is not None: + stats["floor_cuts"] = stats.get("floor_cuts", 0) + floor_cuts + stats["capped"] = stats.get("capped", False) or capped + 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: + """The start of `t`'s local hour, in UTC. Taken back from the UTC instant, + never from the wall clock, so each of the two 02:00 hours of the autumn + change has its own (`dayplan._hour_slices()`, SAFETY-123).""" + lt = _local(t) + return t.astimezone(timezone.utc) - timedelta( + minutes=lt.minute, seconds=lt.second, microseconds=lt.microsecond) diff --git a/goodwe_controller/app/dayview.py b/goodwe_controller/app/dayview.py new file mode 100644 index 0000000..4319d7b --- /dev/null +++ b/goodwe_controller/app/dayview.py @@ -0,0 +1,448 @@ +"""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. +""" + +import dataclasses +from datetime import datetime, timedelta + +from . import arbitrage, dayplan, homeview as hv, modetrade, priceruns +from . import solarfc +from .daycurve import Batt, curve +from .packmode import SOLAR_FIRST +from .price import Uncertainty, brussels, required_spread, uncertainty_of + +BLOCKS = ("grid", "give", "cycle") # the kinds a battery's blocks carry +LEVEL_DP = 1 # the tab shows a level to 0.1 % + + +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. + # Tomorrow has not started, so the level now says nothing about it: the + # builders read each block at the level it starts at (`ahead`), with + # their own room and energy filter. With modes on that level is the + # curve's (`_levels()`); with modes off there is no curve, and it is + # Home's own count (`_counted()`). + runs = [plan["runs"], []] + if running and not modes_on: + runs[1] = _off_tomorrow(c, runs[0], now, shadow, n0, n1) + elif running: + runs[1] = hv.runs_for(c, n0, n1, now, shadow, ahead=True) + + # The level curve of every battery that is on and known. It also makes + # tomorrow's blocks again at the level each has when its run starts, and + # draws a grid block as long as control keeps charging (`_levels()`). + notes_b = {} + levels = {} + if running and missing is None: + levels, runs = _levels(c, est, fc, trust, now, n1, runs, floor, ceil, + notes_b, shadow, n0, n1) + 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] + # 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, shadow, + n0, n1): + """`({name: [(t, pct), ...]}, runs)`: `daycurve.curve()` for the batteries + that are on, have a level and are not in their monthly cycle, and `runs` + with tomorrow's blocks made as control will meet them. + + Tomorrow's blocks are `homeview.mode_runs()`'s, read at the level the + curve has when each block starts (its `ahead` function), so its own room + and energy filter and `MIN_KWH` decide: no give block where the battery + is at the floor when the run starts, no grid block where it is at its + planned level. The curve in turn follows the blocks, so the two are taken + to a fixed point. + + A cheap run is sized for all the batteries charging together, so a slower + one is still below `modetrade.charge_to()` when it ends, and control then + plans the next cheap slots (`Controller._price_runs()`). That is asked of + the same functions, `modetrade.energy()` and `priceruns.plan()`, with the + packs at the curve's level, and the block is drawn to where control + stops charging. Today's blocks are Home's, as they are.""" + 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 + + def make(rs): + 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 day in rs for r in day + 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)) + return batts + + def sun(a, z): + return sun_kwh(fc, now, a, z) + + 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)) + + def run(rs): + batts = make(rs) + return curve(batts, now, end, sun, use, floor_pct=floor, + ceiling_pct=ceil, trust=trust) if batts else {} + + def settle(ext, lv): + """Tomorrow's blocks read at the levels of `lv`, and the curve they + make, again until the blocks stop changing.""" + seen = None + for _ in range(4): + def at(name, _kind, start, lv=lv): + return _level_at(lv.get(name, ()), start) + rt = hv.runs_for(c, n0, n1, now, shadow, ahead=at, rp=ext) + lv = run([runs[0], rt]) + sig = [(r["battery"], r["kind"], r["start"], r["end"]) + for r in rt] + if sig == seen: + break + seen = sig + return rt, lv + + lv0 = run([runs[0], []]) + ext = rp + plan = c.fresh_tariff_plan(now) + cheap = None if rp is None else rp.cheap + end_t = None + if plan_on and cheap is not None and rp.charge_useful() \ + and n0 <= cheap.start < n1 and plan is not None: + required = required_spread( + arbitrage.threshold_eur_kwh(c.o), + uncertainty_of(plan.read.product) | {Uncertainty.NO_WEATHER}) + end_t = cheap.end + for _ in range(24): + ext = dataclasses.replace(rp, cheap=priceruns.Run( + cheap.start, end_t, cheap.mean_eur_kwh)) + rt, lv = settle(ext, lv0) + at_end = [dataclasses.replace(packs[n], soc=v) + for n, v in ((n, _level_at(lv.get(n, ()), end_t)) + for n in lv) if v is not None] + e = modetrade.energy(at_end, floor, ceil, targets=targets) + if e[0] <= 1e-6: + break + new = priceruns.plan( + plan.read.points, end_t, required_eur_kwh=required, + charge_kwh=e[0], charge_kw=e[1], sell_kwh=0.0, sell_kw=0.0) + nxt = new.cheap + if nxt is None or not new.charge_useful() or nxt.start > end_t \ + or nxt.end <= end_t: + break + end_t = nxt.end + ext = dataclasses.replace(rp, cheap=priceruns.Run( + cheap.start, end_t, cheap.mean_eur_kwh)) + rt, lv = settle(ext, lv0) + # A run is for all the batteries charging together; each stops when it is + # at its own level (control's `chargers()`), so its block ends there. + names = [inv.name for inv in c.inverters] + rt = [_stops(r, names, lv, packs, targets, floor, ceil, n0) for r in rt] + return lv, [runs[0], rt] + + +def _off_tomorrow(c, today, now, shadow, n0, n1): + """Modes off: tomorrow's blocks by `homeview.trade_runs()`, each read at + the level `_counted()` gives when it starts. A charge may come before a + give, so this repeats until the blocks stop changing.""" + sig, rt = None, [] + for _ in range(4): + rt = hv.runs_for(c, n0, n1, now, shadow, + ahead=_counted(c, [today, rt])) + new = [(r["battery"], r["kind"], r["start"], r["end"]) for r in rt] + if new == sig: + break + sig = new + return rt + + +def _counted(c, rs): + """Modes off reads no forecast and keeps no house profile, so there is no + curve. A give is read at Home's own count (`trade_runs()`): the level now, + plus what the blocks in `rs` that start before it put in, less what they + take out. The house can only take from that and no sun is read, so a + battery at the floor by the count is at the floor then. A charge is read + from the floor (None): the house uses some of a full battery before the + cheap hour, and control charges it back then.""" + _floor, ceil = arbitrage.soc_bounds(c.o) + + def at(name, kind, start): + i = next(k for k, inv in enumerate(c.inverters) if inv.name == name) + inv = c.inverters[i] + if kind == "grid" or inv.soc is None or not inv.capacity_kwh: + return None + v = float(inv.soc) + mine = sorted((r for day in rs for r in day if r["battery"] == i + and r["kind"] in ("grid", "give") + and r.get("kwh") is not None + and _times(r)[0] < start), + key=lambda r: _times(r)[0]) + for r in mine: + d = float(r["kwh"]) / float(inv.capacity_kwh) * 100.0 + v = min(max(v, ceil), v + d) if r["kind"] == "grid" else v - d + return v + return at + + +def _stops(r, names, lv, packs, targets, floor, ceil, day0): + """`r` ended where its battery stops, if that is before the run ends: a + grid block where the curve has it at the level `modetrade.charge_to()` + allows (the curve has a point there), a give block at the first point + where the level the tab shows (`LEVEL_DP`) is at the floor. A run is for + all the batteries together; each leaves it on its own.""" + if r["kind"] not in ("grid", "give"): + return r + name = names[r["battery"]] + start, end = _times(r) + if r["kind"] == "grid": + to = modetrade.charge_to(packs[name], ceil, targets) + if to is None: + return r + hit = lambda v: v >= to - 1e-6 # noqa: E731 + else: + hit = lambda v: round(v, LEVEL_DP) <= floor # noqa: E731 + done = next((t for t, v in lv.get(name, ()) if start < t <= end + and hit(v)), None) + if done is None or done >= end: + return r + return dict(r, end=done.isoformat(), to=hv.hhmm(done), + end_h=round(_hours_between(day0, done), 3)) + + +def _level_at(pts, t): + """The curve's level at `t` (between two points, straight), or None when + the curve does not reach `t`.""" + for (t0, v0), (t1, v1) in zip(pts, pts[1:]): + if t0 <= t <= t1: + span = (t1 - t0).total_seconds() + return v0 if span <= 0 else v0 + (v1 - v0) * ( + (t - t0).total_seconds() / span) + return None + + +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, LEVEL_DP)] + 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, d0, d1, + now)}) + 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], LEVEL_DP)) + 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", at=_sun_from())) + 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 _sun_from() -> str: + """When the sun of a day starts for `solarfc` (its bell's first hour): + with no forecast for tomorrow the sun is unknown from then, and so the + curve stops there (`Forecast.between()` answers None past it).""" + return f"{int(solarfc.BELL_FROM_H):02d}:00" + + +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 _give_planned(c, d0, d1, now) -> bool: + """Does the add-on plan a give in `[d0, d1)` not yet over? Modes on: a + give run of the run plan. Modes off: a give leg of the verdict that the + gate takes (`homeview.trade_legs()`), at a price that is not negative.""" + if not getattr(c, "modes_on", False): + plan = c.fresh_tariff_plan(now) + verdict, _why = hv.trade_verdict(c, plan, now) + return verdict is not None and any( + k == "give" and pt.eur_per_kwh >= 0 and pt.start < d1 + and hv._point_end(plan, pt) > max(now, d0) + for k, pt in hv.trade_legs(c, plan, verdict, now)) + rp = getattr(c, "mode_runs", None) + return rp is not None and rp.on and any( + g.end > max(now, d0) and g.start < d1 for g in rp.give) + + +def _line(c, inv, row, mine, running, key, shadow, d0, d1, now) -> str: + """What to say for a battery with no block: the plan's own words. Home + draws no give block for a battery with no energy to give, so a give that + the add-on plans with no block gets its own sentence.""" + if not running: + return hv.why_words(key) + if row.get("use") is False: + return hv.why_words("off") + modes_on = getattr(c, "modes_on", False) + gives = not modes_on or row.get("mode_choice") in hv.PRICE_MODES + if gives and not any(r["kind"] == "give" for r in mine) \ + and _give_planned(c, d0, d1, now): + return hv.day_note("give_later") + if mine: + return "" + if modes_on and gives: + 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..eea7b97 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 @@ -332,18 +333,30 @@ def trade_legs(c, plan, verdict, now): MIN_KWH = 0.05 -def trade_runs(c, plan, verdict, day0, day1, now, shadow): +def trade_runs(c, plan, verdict, day0, day1, now, shadow, ahead=False): """A charge block and a give block on each battery, where the verdict put them. The site trades as one; each battery's share is capped by its own rating and by the room its level leaves (arbitrage's own floor/ceiling). Only what `arbitrage.decide()` would still do is drawn: no block on a battery with no room, no sale at a negative price, and no sale while the emptiest battery is at the floor (the cycle's `soc` is the emptiest). - With a level unknown the cycle does not trade, so nothing is drawn.""" + With a level unknown the cycle does not trade, so nothing is drawn. + UX-54 `ahead`: the window has not started, so the level now says nothing + about it. The same rules then read each battery at the level a leg starts + at: `ahead(name, kind, start)`, a level in % or None, as in `mode_runs()`; + for None (and for `ahead=True`) a charge is read from the floor and a give + from the ceiling (the most it could be). The leg itself is still the + gate's (`trade_legs()`). Home never passes it.""" floor, ceiling = arbitrage.soc_bounds(c.o) - if any(inv.soc is None for inv in c.inverters): + if not ahead and any(inv.soc is None for inv in c.inverters): return [], [], "no_level" - level = [float(inv.soc) for inv in c.inverters] + level = [] if ahead else [float(inv.soc) for inv in c.inverters] + + def start_pct(kind, start): + got = [ahead(inv.name, kind, start) if callable(ahead) else None + for inv in c.inverters] + return [(floor if kind == "grid" else ceiling) if v is None + else float(v) for v in got] runs, later = [], [] for kind, pt in trade_legs(c, plan, verdict, now): end = _point_end(plan, pt) @@ -352,6 +365,8 @@ def trade_runs(c, plan, verdict, day0, day1, now, shadow): if not (day0 <= pt.start < day1): later.append((kind, pt)) continue + if ahead: + level = start_pct(kind, pt.start) if kind == "give" and (pt.eur_per_kwh < 0 or min(level) <= floor): continue hrs = (end - pt.start).total_seconds() / 3600.0 @@ -366,7 +381,8 @@ def trade_runs(c, plan, verdict, day0, day1, now, shadow): kwh = min(kwh, max(0.0, room) / 100.0 * cap) # A later give may use what this charge put in: an upper # bound, like every amount here ("up to"). - level[i] += (kwh if kind == "grid" else -kwh) / cap * 100.0 + if not ahead: + level[i] += (kwh if kind == "grid" else -kwh) / cap * 100.0 if kwh < MIN_KWH: continue eur = kwh * pt.eur_per_kwh @@ -386,15 +402,22 @@ def trade_runs(c, plan, verdict, day0, day1, now, shadow): PRICE_MODES = (LEFTOVER_SOLAR, CHEAP_HOURS) -def mode_runs(c, day0, day1, now, shadow): +def mode_runs(c, day0, day1, now, shadow, ahead=False, rp=None): """OPT-11 P3: each battery's price runs today, as the control cycle keeps 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. 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) + control cycle found). Returns `(runs, no_trade_key)`. + UX-54 `ahead`: the window has not started, so the level now says nothing + about it. The room and energy filter (`room()` and `plan_need()` below) + then reads the level a block starts at: `ahead(name, kind, start)`, a + function giving the expected level in % or None, or for None (and for + `ahead=True`) a charge from the floor and a give from the ceiling. The + same filter, the same `MIN_KWH`. `rp`: a run plan to read instead of + `c.mode_runs`. Home never passes either.""" + rp = rp if rp is not None else getattr(c, "mode_runs", None) if getattr(c, "mode_tick", None) is None: return [], None # no cycle yet: nothing is known if rp is None: @@ -410,15 +433,23 @@ def mode_runs(c, day0, day1, now, shadow): continue soc, cap = inv.soc, inv.capacity_kwh - def room(kind, hrs): + def start_pct(kind, start): + """The level a block of `kind` is read at when `ahead`.""" + got = ahead(inv.name, kind, start) if callable(ahead) else None + return got if got is not None else ( + floor if kind == "grid" else ceiling) + + def room(kind, hrs, start): kwh = inv.max_w / 1000.0 * hrs - if cap and soc is not None: - kwh = min(kwh, max(0.0, (ceiling - soc) if kind == "grid" - else (soc - floor)) / 100.0 * cap) + lvl = start_pct(kind, start) if ahead else soc + if cap and lvl is not None: + kwh = min(kwh, max(0.0, (ceiling - lvl) if kind == "grid" + else (lvl - floor)) / 100.0 * cap) return kwh blocks = [] # P4: with a day plan, the grid block is the planned shortfall. - need = plan_need(c, inv.name) + need = plan_need(c, inv.name, start_pct("grid", rp.cheap.start) + if ahead and rp.cheap is not None else None) if gate is None and rp.charge_useful() and ( need > 0.0 if need is not None else pm.mode in GRID_CHARGE_MODES): @@ -433,7 +464,7 @@ def mode_runs(c, day0, day1, now, shadow): if run.end <= now or not (run.start < day1 and run.end > day0): continue hrs = (run.end - run.start).total_seconds() / 3600.0 - kwh = room(kind, hrs) + kwh = room(kind, hrs, run.start) if kind == "grid" and need is not None: kwh = min(kwh, need) if kwh < MIN_KWH: @@ -460,16 +491,18 @@ def mode_runs(c, day0, day1, now, shadow): return out, None -def plan_need(c, name): +def plan_need(c, name, soc=None): """P4: kWh this battery buys from the grid by the day plan, or None when - there is no plan for it (then P3 option A applies).""" + there is no plan for it (then P3 option A applies). `soc`: UX-54, a level + to read it from instead of the one now (`mode_runs(ahead=True)`).""" targets = c.plan_targets() if hasattr(c, "plan_targets") else None if not targets or name not in targets: return None inv = next((i for i in c.inverters if i.name == name), None) - if inv is None or inv.soc is None or not inv.capacity_kwh: + at = None if inv is None else (inv.soc if soc is None else soc) + if inv is None or at is None or not inv.capacity_kwh: return None - return max(0.0, targets[name] - float(inv.soc)) / 100.0 * float( + return max(0.0, targets[name] - float(at)) / 100.0 * float( inv.capacity_kwh) @@ -612,6 +645,20 @@ def region_of(opts): if rid in REGION_PRESETS else None} +def runs_for(c, day0, day1, now, shadow, ahead=False, rp=None): + """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. + `ahead`: see `mode_runs()`; for a day that has not started.""" + if getattr(c, "modes_on", False): + return mode_runs(c, day0, day1, now, shadow, ahead, rp)[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, ahead)[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 +1048,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 +1386,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 +1564,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 @@ + +