From eb10537aee1f68cd02dcea555b06e88fb6a5ab33 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 19:29:32 +0200 Subject: [PATCH 1/4] OPT-11 P4: solar forecast and day plan A Home Assistant solar forecast (Forecast.Solar, Solcast or Open-Meteo shapes) and the add-on's own hourly house use decide, before each cheap run, whether the sun will fill each battery before the expensive hours. Leftover solar then buys from the grid only the shortfall; Cheap hours only skips the part the sun covers; Solar first never grid-charges. No, stale or failed forecast: P3 option A exactly (12 digests pinned from 5a4cdc2). Replay (16 recorded days, forecast errors from Elia's day-ahead BE solar): year-earlier prices P4 19.15 vs P2 19.33 / option A 19.44 / old trade 18.14; real 2026 prices P4 29.19 vs 32.26 / 32.01 / 31.40 EUR net. Co-Authored-By: Claude Opus 5.5 --- .../DECISIONS/OPT-11-battery-modes.md | 41 ++ goodwe_controller/DOCS.md | 15 +- goodwe_controller/app/dayplan.py | 256 +++++++ goodwe_controller/app/homeview.py | 87 ++- goodwe_controller/app/main.py | 121 ++- goodwe_controller/app/messages.py | 5 + goodwe_controller/app/modetrade.py | 63 +- goodwe_controller/app/packmode.py | 9 +- goodwe_controller/app/solarfc.py | 251 +++++++ goodwe_controller/app/static/app.js | 3 + goodwe_controller/app/static/index.html | 1 + goodwe_controller/app/static/settings.js | 11 + goodwe_controller/app/ui_text/en.json | 8 +- goodwe_controller/app/ui_text/nl.json | 8 +- goodwe_controller/app/uilayout.py | 9 +- goodwe_controller/app/validate.py | 47 +- goodwe_controller/config.yaml | 10 + ...-be-solar-price-2026-09-01_2026-09-17.json | 1 + goodwe_controller/test_batteries_screen.py | 9 + goodwe_controller/test_modes_p4.py | 692 ++++++++++++++++++ goodwe_controller/tools/opt11p4_identity.py | 112 +++ goodwe_controller/tools/opt11p4_replay.py | 242 ++++++ goodwe_controller/translations/en.yaml | 9 + goodwe_controller/translations/nl.yaml | 9 + 24 files changed, 1984 insertions(+), 35 deletions(-) create mode 100644 goodwe_controller/app/dayplan.py create mode 100644 goodwe_controller/app/solarfc.py create mode 100644 goodwe_controller/scenarios/energy-charts-be-solar-price-2026-09-01_2026-09-17.json create mode 100644 goodwe_controller/test_modes_p4.py create mode 100644 goodwe_controller/tools/opt11p4_identity.py create mode 100644 goodwe_controller/tools/opt11p4_replay.py diff --git a/goodwe_controller/DECISIONS/OPT-11-battery-modes.md b/goodwe_controller/DECISIONS/OPT-11-battery-modes.md index 5c58558..b5d970c 100644 --- a/goodwe_controller/DECISIONS/OPT-11-battery-modes.md +++ b/goodwe_controller/DECISIONS/OPT-11-battery-modes.md @@ -75,3 +75,44 @@ P1 is its section 6 P1, and the pull request lists what was measured. P2's 19.33; Solar first + Cheap hours only 19.57. - Not done in P3: the forecast (P4), and a headroom too short for the run moving the rest to the next cheap periods. + +## P4: the solar forecast and the day plan (2026-09-29) +- Input: a Home Assistant forecast sensor (`pv_forecast_entity`, kWh left + today; `pv_forecast_tomorrow_entity` optional), read by `app/solarfc.py`. + Supported shapes: Forecast.Solar (state only), Solcast (`detailedForecast`) + and Open-Meteo Solar Forecast (`wh_period`). The unit is read, not guessed; + older than 3 h is stale (one missed hourly poll must not flag it). +- Trust: `solar_trust_pct`, default 70 (owner answer 5). +- House use: nothing the add-on recorded fitted. `tariff.db` holds grid + import per quarter, which the batteries shape. So `dayplan.HouseProfile` + keeps grid + battery per local hour for 7 days in the store + (`house_profile`), median per hour. It is net of the solar the house used, + so the solar sum never takes it below the night median. Solar readings are + not used (TEL-11). The first plan needs one day of it. +- Verdict (`dayplan.plan()`): trust x sum max(0, forecast - house) x 0.95 + until the expensive run the cheap run is for. Solar first takes it first. + - Leftover solar may then charge from the grid up to min(ceiling, floor + + the house's use in the expensive runs / 0.95), less its sun share: only + the shortfall. + - Cheap hours only charges up to the ceiling less its sun share. + - Solar first never charges from the grid (owner, 2026-09-29). +- The level is kept unless it moves by min(1 kWh, 10 %). With no plan + (no, stale or failed forecast, no house profile, no capacity) every mode + is P3 option A exactly: `test_modes_p4.py` part 1 pins 12 digests from + 5a4cdc2 (modes on, with and without an unavailable forecast). +- The plan is made with the runs (every 15 min, or at once when the forecast + comes or goes). A lost forecast drops the levels at the next cycle. +- Not done: the design's scaling by measured solar from 10:00 (it needs the + solar readings TEL-11 keeps out), the HA `goodwe__plan` sensor, and + FCST-01. +- Replay (`tools/opt11p4_replay.py`, 16 recorded days, Solar first + Leftover + solar, net EUR, lower is better; site solar rebuilt from the recording, + forecast errors from Elia's day-ahead Belgian solar forecast): + - year-earlier hourly prices: P2 19.33, P3 option A 19.44, old one-period + trade 18.14, P4 19.15 (perfect forecast 19.14, 1.5 x too high 19.16, + missing 19.44); + - the days' real 2026 quarter-hourly prices (held out): P2 32.26, option A + 32.01, old trade 31.40, P4 29.19 (perfect 29.21, too high 29.28, missing + 32.01). + No threshold was tuned to these days. The 2026-09-24 PV recording could not + be replayed: it is only in the live Home Assistant. diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index 2da5da8..9b139fa 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -184,6 +184,9 @@ The label and description each setting shows on the Configuration tab, in tab or | Hardware · Inverter list | Usable battery capacity (kWh) (optional) | `inverters.capacity_kwh` | This battery's usable capacity from its datasheet. Fill it in on every inverter or on none. The add-on then shares the work by the energy each battery has left. | | Hardware | RS485 emergency stop is fitted | `estop_fitted` | Switch this on if the emergency stop is installed. It only changes a warning on the status page. | | Hardware | Battery modes | `battery_modes_enabled` | Give each battery an on and off switch and a mode, set on the Batteries screen. | +| Hardware | Solar forecast, today (kWh) | `pv_forecast_entity` | Optional. A forecast sensor with today's remaining solar energy, such as Forecast.Solar or Solcast. A battery then buys only what the sun will not give. | +| Hardware | Solar forecast, tomorrow (kWh) | `pv_forecast_tomorrow_entity` | Optional. The same forecast for tomorrow. It helps plan a cheap hour before midnight. | +| Hardware | Trust in the forecast (%) | `solar_trust_pct` | Forecasts are often too sunny. At 70 % the add-on plans with 7 of every 10 kWh forecast. | | Hardware | Solar production entity (W) | `pv_entity` | Optional. Your solar inverter's power sensor, positive while producing. The add-on records it and does not use it for control. | | Hardware | Solar production entities, per inverter (W) | `pv_entities` | Optional. Use one row per solar inverter instead of “Solar production entity” when you have several. If one stops reporting, no total is recorded. | | Hardware | Reversed-sign solar entities | `pv_invert_entities` | Optional. Add each solar entity above that reads negative while producing, spelled the same way. Its sign is flipped before adding up. | @@ -522,7 +525,7 @@ Off by default. Turn it on, restart the add-on, and the Batteries screen (and Se - **On or off.** Off asks the battery for 0 W and starts no monthly cycle on it. A cycle that is running stops, and the add-on log says so. The peak limit may still use it, but only for what the other batteries cannot cover. - **Mode.** - *Solar first* charges from spare solar before the other batteries and covers the house at any hour. It never charges from the grid and never sells. - - *Leftover solar* charges from the solar the others cannot take, never from the grid (until the solar forecast arrives). It saves its power for the expensive hours and covers the house then. + - *Leftover solar* charges from the solar the others cannot take. With a solar forecast it may also charge from the grid, but only what the sun will not give (see below). It saves its power for the expensive hours and covers the house then. - *Cheap hours only* charges from the grid in the cheapest hours, saves its power for the expensive hours and covers the house then. - **Selling** is off until it can be judged on the price the grid pays (owner decision 2026-09-29, option A). @@ -535,6 +538,16 @@ With battery modes on, these choices replace the *Charging and selling on price* - A carried (not yet published) price is never used. A run that has started runs to its end. - With no prices, or too small a gap, every battery follows the house. +**Solar forecast** (`pv_forecast_entity`, optional). A Home Assistant sensor with the solar energy still to come today, in kWh. These work: Forecast.Solar (`sensor.energy_production_today_remaining`), Solcast PV Forecast (`sensor.solcast_pv_forecast_forecast_remaining_today`) and Open-Meteo Solar Forecast (`sensor.energy_production_today_remaining`). Set `pv_forecast_tomorrow_entity` to the matching *tomorrow* sensor to plan a cheap run before midnight. +- Before each cheap run the add-on asks: will the sun fill the batteries before the expensive hours? It counts on `solar_trust_pct` of the forecast (70 %), less what the house uses. Solar first batteries get that sun first. +- *Leftover solar* then charges from the grid only the shortfall: what the house will draw from it in the expensive hours, less its share of the sun. Not up to the top level. +- *Cheap hours only* fills to the top level less its share of the sun. +- *Solar first* never charges from the grid. +- The house use is the add-on's own measurement (grid + battery power) per hour, over the last 7 days. It needs one day of it before the first plan. +- The plan is checked every 15 minutes. A planned level moves only by 1 kWh or 10 %, so a wobbling forecast does not flip it. +- No forecast, one older than 3 hours, or no house use yet: *Leftover solar* takes solar only and *Cheap hours only* fills to the top level. The Batteries screen says so. +- A battery needs its capacity (`inverters.capacity_kwh`) for a plan. + **What always comes first.** - The peak limit, the safety stops and the monthly cycle come before a battery's mode. - A grid charge is sized so the 15-minute period stays under the peak limit less its margin (`peak_margin_w`). It starts only at 500 W or more. Once the peak limit stops it, it stays off for the rest of that 15-minute period. diff --git a/goodwe_controller/app/dayplan.py b/goodwe_controller/app/dayplan.py new file mode 100644 index 0000000..89ac1e7 --- /dev/null +++ b/goodwe_controller/app/dayplan.py @@ -0,0 +1,256 @@ +"""OPT-11 P4: the day plan. Will the sun fill each battery before the +expensive hours? If not, how much should it take from the grid? + +Pure: the forecast (`solarfc`), the house profile, the price runs and the +batteries in; a level per battery (`Plan.targets`) out. `modetrade.charge_to()` +turns the level into permission to charge from the grid; main.py rebuilds the +plan with the price runs (at most every 15 minutes) and keeps a level unless +it moves by `HYST_KWH` or `HYST_FRAC` (`hold()`). + +The sums (OPT-11-design section 1, "Excess solar", with the owner's answers): +* `sun`: trust x the solar the house will not use, between now and the + expensive run the cheap run is for, x the charge efficiency: + `trust x sum_h max(0, forecast_h - house_h) x EFF`. +* Solar first batteries take it first, up to the ceiling. What is left is + shared by the Leftover solar and Cheap hours batteries by their room. +* A Cheap hours battery charges from the grid up to `ceiling - its share`. +* A Leftover solar battery charges only up to what the house will draw from + it in the expensive runs before the next cheap run, less its share: + `min(ceiling, floor + house use / EFF) - share`. So it buys only the + shortfall, not up to the ceiling. +* Solar first never charges from the grid (owner, 2026-09-29). + +The house profile is the add-on's own measurement, grid + battery power, per +local hour, over the last `PROFILE_DAYS` days; the estimate for an hour is the +median of that hour. It is net of the solar the house used itself, so for +the solar sum it is never taken below the night-time median (`base()`). +The site's own solar readings are not used: TEL-11 keeps them out of every +control decision. +""" + +import statistics +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone + +from .packmode import CHEAP_HOURS, LEFTOVER_SOLAR, SOLAR_FIRST +from .tariff import BILLING_TZ + +EFF = 0.95 # charge (and discharge) efficiency +HYST_KWH = 1.0 # a kept level moves only by this much... +HYST_FRAC = 0.10 # ...or this share of the battery's shortfall +PROFILE_DAYS = 7 +MIN_HOURS = 20 # hours of a day recorded before it counts +NIGHT_HOURS = (0, 1, 2, 3, 4, 5, 22, 23) +DEFAULT_TRUST_PCT = 70 + +# Site verdicts (the page's forecast line). +SUNNY, PARTLY, DARK = "sunny", "partly", "dark" + + +# --------------------------------------------------------------------------- # +# The house profile +# --------------------------------------------------------------------------- # +def _local(t: datetime): + return t.astimezone(BILLING_TZ) + + +class HouseProfile: + """Hourly means of the house's net use (grid + battery power), kept per + local day and hour: `{"YYYY-MM-DD": {hour: [mean_w, samples]}}`. The + store holds `dump()`; `load()` takes it back and drops what is bad.""" + + def __init__(self, data=None): + self.days = {} + self._cur = None # (day, hour, sum, n) + if isinstance(data, dict): + self.load(data) + + def load(self, data): + for day, hours in data.items(): + if not isinstance(hours, dict): + continue + row = {} + for h, v in hours.items(): + try: + hi, w = int(h), float(v) + except (TypeError, ValueError): + continue + if 0 <= hi < 24 and abs(w) < 1e6: + row[hi] = w + if row: + self.days[str(day)] = row + + def dump(self) -> dict: + return {d: {str(h): round(w, 1) for h, w in sorted(r.items())} + for d, r in sorted(self.days.items())} + + def add(self, now: datetime, house_w) -> bool: + """One reading. Returns True when an hour closed (then store it).""" + lt = _local(now) + key = (lt.strftime("%Y-%m-%d"), lt.hour) + closed = False + if self._cur is not None and self._cur[:2] != key: + day, hour, s, n = self._cur + if n >= 60: # about 5 minutes of readings + self.days.setdefault(day, {})[hour] = s / n + closed = True + self._cur = None + self._prune(lt) + if house_w is None: + return closed + if self._cur is None: + self._cur = (key[0], key[1], 0.0, 0) + d, h, s, n = self._cur + self._cur = (d, h, s + float(house_w), n + 1) + return closed + + def _prune(self, lt): + keep = (lt.date() - timedelta(days=PROFILE_DAYS)).isoformat() + for d in [d for d in self.days if d < keep]: + del self.days[d] + + def ready(self) -> bool: + """At least one day with `MIN_HOURS` hours recorded.""" + return any(len(r) >= MIN_HOURS for r in self.days.values()) + + def hour_w(self, hour: int): + vals = [r[hour] for r in self.days.values() if hour in r] + return statistics.median(vals) if vals else None + + def base(self): + vals = [w for h in NIGHT_HOURS if (w := self.hour_w(h)) is not None] + return statistics.median(vals) if vals else None + + def estimator(self): + """`f(hour) -> W`, or None when not ready. Fixed at call time.""" + if not self.ready(): + return None + base = self.base() or 0.0 + table = {} + for h in range(24): + w = self.hour_w(h) + table[h] = base if w is None else w + return lambda h: table[h], max(0.0, base) + + +# --------------------------------------------------------------------------- # +# The plan +# --------------------------------------------------------------------------- # +@dataclass(frozen=True) +class Plan: + verdict: str # SUNNY / PARTLY / DARK + sun_kwh: float # solar the batteries can count on + fc_kwh: float # the forecast over the same span + until: datetime # the expensive run the plan is for + targets: dict # name -> level % (LS and CH only) + need_kwh: dict # name -> kWh from the grid (0: none) + share_kwh: dict # name -> kWh of solar counted for it + + +def _hour_slices(t0, t1): + """Local-hour slices of [t0, t1): (start, end, local hour).""" + out, t = [], t0 + while t < t1: + lt = _local(t) + nxt = (lt.replace(minute=0, second=0, microsecond=0) + + timedelta(hours=1)).astimezone(timezone.utc) + e = min(nxt, t1) + out.append((t, e, lt.hour)) + t = e + return out + + +def _room(p, to_pct): + if p.capacity_kwh is None or p.soc is None: + return 0.0 + return max(0.0, float(to_pct) - float(p.soc)) / 100.0 * float( + p.capacity_kwh) + + +def plan(forecast, house, runs, now, packs, *, trust_pct, floor_pct, + ceiling_pct): + """The day plan, or None when there is none to make: no forecast, no + house profile yet, no price plan, or no expensive run after the cheap + run. `house` is `HouseProfile.estimator()`'s answer. `packs` are + `modetrade.Pack`s.""" + if forecast is None or house is None or runs is None or not runs.on \ + or runs.cheap is None: + return None + house_w, base_w = house + until = next((g for g in runs.give if g.start >= runs.cheap.end), None) + if until is None: + return None + trust = max(0.0, min(1.0, float(trust_pct) / 100.0)) + sun = fc_sum = 0.0 + for s, e, h in _hour_slices(now, until.start): + pv = forecast.between(s, e) + if pv is None: + return None # the span reaches a day with no forecast + hrs = (e - s).total_seconds() / 3600.0 + use = max(house_w(h), base_w) * hrs / 1000.0 + sun += max(0.0, pv - use) + fc_sum += pv + sun *= trust * EFF + + usable = [p for p in packs if p.use and p.live and p.idle + and p.soc is not None and p.capacity_kwh] + sf = [p for p in usable if p.mode == SOLAR_FIRST] + price = [p for p in usable if p.mode in (LEFTOVER_SOLAR, CHEAP_HOURS)] + sf_room = sum(_room(p, ceiling_pct) for p in sf) + excess = max(0.0, sun - sf_room) + rooms = {p.name: _room(p, ceiling_pct) for p in price} + total_room = sum(rooms.values()) + + # What the house draws in the expensive runs from the one this plan is + # for until the next cheap run (or a day), from the profile. + horizon = until.start + timedelta(hours=24) + use_kwh = 0.0 + for g in runs.give: + if g.start < until.start or g.start >= horizon: + continue + for s, e, h in _hour_slices(g.start, g.end): + use_kwh += max(0.0, house_w(h)) * ( + e - s).total_seconds() / 3600.0 / 1000.0 + caps = {p.name: float(p.capacity_kwh) for p in price} + cap_sum = sum(caps.values()) or 1.0 + + targets, need, share = {}, {}, {} + for p in price: + sh = excess * rooms[p.name] / total_room if total_room > 0 else 0.0 + sh_pct = sh / caps[p.name] * 100.0 + if p.mode == CHEAP_HOURS: + top = float(ceiling_pct) + else: + want = use_kwh * caps[p.name] / cap_sum / EFF + top = min(float(ceiling_pct), + float(floor_pct) + want / caps[p.name] * 100.0) + level = max(0.0, top - sh_pct) + targets[p.name] = round(level, 2) + need[p.name] = round(_room(p, level), 3) + share[p.name] = round(sh, 3) + if sun < sf_room: + verdict = DARK + elif any(v > 0.05 for v in need.values()): + verdict = PARTLY + else: + verdict = SUNNY + return Plan(verdict, round(sun, 3), round(fc_sum, 3), until.start, + targets, need, share) + + +def hold(old: dict | None, new: dict, capacity: dict) -> dict: + """Hysteresis: a battery keeps its old level unless the new one differs + by at least `HYST_KWH`, or by `HYST_FRAC` of the larger level's energy + (whichever is less). A battery new to the plan takes its level at once; + one that left the plan is dropped at once.""" + out = {} + for name, level in new.items(): + was = (old or {}).get(name) + cap = float(capacity.get(name) or 0.0) + if was is None or cap <= 0: + out[name] = level + continue + moved = abs(level - was) / 100.0 * cap + step = min(HYST_KWH, HYST_FRAC * max(was, level) / 100.0 * cap) + out[name] = level if moved >= step else was + return out diff --git a/goodwe_controller/app/homeview.py b/goodwe_controller/app/homeview.py index 72520d4..923a66c 100644 --- a/goodwe_controller/app/homeview.py +++ b/goodwe_controller/app/homeview.py @@ -345,7 +345,10 @@ def mode_runs(c, day0, day1, now, shadow): else (soc - floor)) / 100.0 * cap) return kwh blocks = [] - if rp.charge_useful() and pm.mode in GRID_CHARGE_MODES: + # P4: with a day plan, the grid block is the planned shortfall. + need = plan_need(c, inv.name) + if rp.charge_useful() and (need > 0.0 if need is not None + else pm.mode in GRID_CHARGE_MODES): blocks.append(("grid", rp.cheap)) sell = rp.sell if pm.sell and pm.can_sell else None for g in rp.give: @@ -358,6 +361,8 @@ def mode_runs(c, day0, day1, now, shadow): continue hrs = (run.end - run.start).total_seconds() / 3600.0 kwh = room(kind, hrs) + if kind == "grid" and need is not None: + kwh = min(kwh, need) if kwh < MIN_KWH: continue price = run.mean_eur_kwh @@ -382,6 +387,56 @@ def mode_runs(c, day0, day1, now, shadow): return out, None +def plan_need(c, name): + """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).""" + 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: + return None + return max(0.0, targets[name] - float(inv.soc)) / 100.0 * float( + inv.capacity_kwh) + + +def plan_state(c): + """P4: `"plan"` (a day plan is made), `"learning"` (no house profile + yet), `"no_forecast"` (set but missing or stale), or None (not set, or + battery modes off).""" + if not getattr(c, "modes_on", False) or \ + getattr(c, "fc_entity", None) is None: + return None + if getattr(c, "solar_fc", None) is None: + return "no_forecast" + hp = getattr(c, "house_profile", None) + if hp is not None and not hp.ready(): + return "learning" + return "plan" if getattr(c, "day_plan", None) is not None else None + + +def solar_block(c, now): + """P4: the solar forecast line for the Batteries screen and the Simple + settings view: `{state, fc_kwh, count_kwh, updated, line}`.""" + st = plan_state(c) + out = {"state": st, "fc_kwh": None, "count_kwh": None, "updated": None, + "line": ""} + if st is None and getattr(c, "solar_fc", None) is None: + return out + fc = getattr(c, "solar_fc", None) + if fc is not None: + trust = float(getattr(c, "solar_trust_pct", 70)) / 100.0 + out.update(fc_kwh=round(fc.today.kwh, 1), + count_kwh=round(fc.today.kwh * trust, 1), + updated=hhmm(fc.at)) + if st in ("no_forecast", "learning"): + out["line"] = _t("solar." + st) + elif fc is not None: + out["line"] = line_solar(fc.today.kwh, out["count_kwh"], + out["updated"]) + return out + + def maintenance_runs(c, day0, day1, now, shadow): """The monthly battery cycle: under way now, or starting later today. The cycle starts in the hour `maintenance_start_hour` names, read as the @@ -655,7 +710,9 @@ def battery_rows(c, shadow, running, held=False): phase=phase, shadow=shadow, running=running, held=held, refused=refusing(c, inv), peak=peak, giving=flow(inv.batt) == "giving", other_refuses=other, - sf_ahead=sf_ahead, role=role, plan=plan, has_at=bool(at)) + sf_ahead=sf_ahead, role=role, plan=plan, has_at=bool(at), + day=None if pm is None or pm.mode not in PRICE_MODES + else day_key(c, inv.name)) rows.append({ "name": inv.name, "level_pct": None if inv.soc is None else float(inv.soc), @@ -689,10 +746,25 @@ def battery_rows(c, shadow, running, held=False): return rows +def day_key(c, name): + """P4: the day plan's word for one Leftover solar or Cheap hours + battery: `sun_fills`, `tops_up`, `no_forecast`, or None.""" + st = plan_state(c) + if st == "no_forecast": + mode = c.pack_mode(next(i for i in c.inverters if i.name == name)) + return "no_forecast" if mode.mode == LEFTOVER_SOLAR else None + if st != "plan": + return None + need = plan_need(c, name) + if need is None: + return None + return "tops_up" if need >= MIN_KWH else "sun_fills" + + def battery_why(*, modes_on, mode, off, reading, phase, shadow, running, held, refused, peak, sf_ahead, giving=False, other_refuses=None, role=None, plan=None, - has_at=False) -> str: + has_at=False, day=None) -> str: """The key of one battery's "why" line (`home_words.why`), most urgent first. Pure, so every branch is tested without a controller.""" if not reading: @@ -717,6 +789,8 @@ def battery_why(*, modes_on, mode, off, reading, phase, shadow, running, if not modes_on: return "modes_off" # OPT-11 P3: the battery's role in the price runs this cycle. + if role == "hold" and day is not None: + return day # P4: what the day plan decided if role in ROLE_WHY: if role == "hold" and not has_at: return "saves_later" @@ -864,6 +938,7 @@ def _build(c, *, level, checks, now): modes_on=bool(getattr(c, "modes_on", False)), running=running, held=held, shadow=shadow) return { + "solar": solar_block(c, now), "state": {"kind": kind, "pill": pill(kind), "ran": ctl.get("text", "") if ctl else "", "sub": banner_sub(kind)}, @@ -1160,6 +1235,10 @@ def run_house_amount() -> str: return _t("run.house_amount") +def line_solar(fc_kwh, count_kwh, at) -> str: + return _t("solar.line", kwh=_kwh(fc_kwh), count=_kwh(count_kwh), at=at) + + def line_no_trade(key) -> str: return "" if key == "off" else _t("no_trade." + key) @@ -1302,6 +1381,8 @@ def _samples() -> list: off=off, peak=peak))) out += [("plan", why_words(k, name="Battery A")) for k in words["why"]] 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", 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 ac2962b..5b20a46 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -41,9 +41,11 @@ from .arbiter import ( P_SAFETY, SET, Claim, Resolution, actuator_for, pre_resolve, resolve, ) from . import allocator +from . import dayplan from . import modetrade from . import packmode from . import priceruns +from . import solarfc from . import control as _ctl from .control import Tuning, compute, maintenance_charge_floor, peak_at_risk from .delivery import ( @@ -234,6 +236,11 @@ def load_options() -> dict: # valued at (`priceruns.plan(ref_eur_kwh=)`): the rest of that day. REF_CHEAP_KEEP = timedelta(hours=12) +# OPT-11 P4: the store key of the house profile (dayplan.HouseProfile), and +# how often the forecast sensors are read (they change hourly at most). +HOUSE_PROFILE_KEY = "house_profile" +FORECAST_READ_S = 60.0 + def why_raised(resolution, claims) -> bool: """F2 (OPT-11 P1 test): did the limit that bound the site target push it @@ -942,6 +949,25 @@ class Controller: self._mode_trade_was = False self._charge_blocked_q = None self._ref_cheap = None + # OPT-11 P4: the solar forecast and the day plan. Only with battery + # modes on AND `pv_forecast_entity` set; otherwise none of this runs + # and every mode acts as in P3 option A. + self.fc_entity = (opts.get("pv_forecast_entity") or None) \ + if self.modes_on else None + self.fc_tomorrow_entity = opts.get("pv_forecast_tomorrow_entity") \ + or None + self.solar_trust_pct = opts.get("solar_trust_pct", + dayplan.DEFAULT_TRUST_PCT) + self.solar_fc = None + self.solar_fc_why = (solarfc.NOT_SET if self.fc_entity is None + else solarfc.MISSING) + self._fc_read_mono = None + self.day_plan = None + self._plan_targets = None + self.house_profile = None + if self.fc_entity is not None: + self.house_profile = dayplan.HouseProfile( + (getattr(store, "data", None) or {}).get(HOUSE_PROFILE_KEY)) # OPT-11 P2: (topic, reason) of MQTT commands refused and logged once. self._mqtt_refused = set() self._mqtt_refused_more = 0 @@ -2073,10 +2099,14 @@ class Controller: plan = self.fresh_tariff_plan(now) if plan is None or not plan.has_prices: self.mode_runs, self._runs_key = None, None + self.day_plan = None return None slot = int(now.timestamp() // 900) key = (id(plan), slot, tuple((p.name, p.use, p.mode, p.sell) for p in packs)) + if self.fc_entity is not None: + # P4: a forecast arriving or going rebuilds the plan at once. + key += (self.solar_fc is None,) if key != self._runs_key: done_c = self._run_done.get("charge", (None, set()))[1] done_s = self._run_done.get("sell", (None, set()))[1] @@ -2094,6 +2124,9 @@ class Controller: charge_kwh=e[0], charge_kw=e[1], sell_kwh=e[2], sell_kw=e[3], ref_eur_kwh=None if ref is None else ref.mean_eur_kwh) + if self.fc_entity is not None: + new = self._day_plan(now, packs, floor_pct, ceiling_pct, + new, plan, required, ref, done_c, e) self.mode_runs = priceruns.keep_started(self.mode_runs, new, now) self._runs_key = key runs = self.mode_runs @@ -2108,6 +2141,42 @@ class Controller: self._ref_cheap = runs.cheap return runs + def _day_plan(self, now, packs, floor_pct, ceiling_pct, runs, plan, + required, ref, done_c, e): + """OPT-11 P4: the day plan (`dayplan.plan()`) on the runs just made, + its levels held by the hysteresis (`dayplan.hold()`), then the runs + made again with each battery's shortfall as the charge, so the cheap + run is only as long as the shortfall needs. With no plan (no or stale + forecast, no house profile yet) the levels are dropped: P3 option A. + Returns the runs to keep.""" + dp = dayplan.plan( + self.solar_fc, + None if self.house_profile is None + else self.house_profile.estimator(), + priceruns.keep_started(self.mode_runs, runs, now), now, packs, + trust_pct=self.solar_trust_pct, floor_pct=floor_pct, + ceiling_pct=ceiling_pct) + self.day_plan = dp + if dp is None: + self._plan_targets = None + return runs + self._plan_targets = dayplan.hold( + self._plan_targets, dp.targets, + {p.name: p.capacity_kwh for p in packs}) + e2 = modetrade.energy(packs, floor_pct, ceiling_pct, done_c, (), + targets=self._plan_targets) + return priceruns.plan( + plan.read.points, now, required_eur_kwh=required, + charge_kwh=e2[0], charge_kw=e2[1], sell_kwh=e[2], sell_kw=e[3], + ref_eur_kwh=None if ref is None else ref.mean_eur_kwh) + + def plan_targets(self): + """P4: each battery's planned level, or None (no day plan). Dropped + at once when the forecast goes (stale or missing).""" + if self.solar_fc is None: + return None + return self._plan_targets + def _mode_roles(self, now, maint_results): """OPT-11 P3: each battery's role and range this tick. Grid charging and selling need a fresh plan that `TariffPlan.arbitrage()` allows @@ -2146,9 +2215,22 @@ class Controller: price_now = None house = (None if self.grid is None or self.site_batt_w is None else float(self.grid) + float(self.site_batt_w)) - done_c = self._run_mark("charge", runs and runs.cheap, packs, - lambda p: p.soc is not None - and p.soc >= ceiling_pct) + if self.house_profile is not None and \ + self.house_profile.add(now, house): + self.store.set(HOUSE_PROFILE_KEY, self.house_profile.dump()) + targets = self.plan_targets() + if targets is None: + done_c = self._run_mark("charge", runs and runs.cheap, packs, + lambda p: p.soc is not None + and p.soc >= ceiling_pct) + else: + # P4: out of the run at its planned level. A level the + # hysteresis moved opens the run to it again (a new mark key). + def full(p): + to = modetrade.charge_to(p, ceiling_pct, targets) + return p.soc is not None and (to is None or p.soc >= to) + done_c = self._run_mark("charge", runs and runs.cheap, packs, + full, tuple(sorted(targets.items()))) done_s = self._run_mark("sell", runs and runs.sell, packs, lambda p: p.soc is not None and p.soc <= floor_pct) @@ -2161,17 +2243,20 @@ class Controller: headroom_w=self.arb_headroom_w, floor_pct=floor_pct, ceiling_pct=ceiling_pct, house_w=house, charging_now=was, blocked=self._charge_blocked_q == quarter, - done_charge=done_c, done_sell=done_s) + done_charge=done_c, done_sell=done_s, targets=targets) # A charge the guard or the headroom stopped stays off this period. if was and self.mode_tick.action != modetrade.CHARGE and \ self.mode_tick.why in ("peak", "headroom"): self._charge_blocked_q = quarter return self.mode_tick - def _run_mark(self, kind, run, packs, out) -> set: + def _run_mark(self, kind, run, packs, out, extra=None) -> set: """The batteries that left run `run` (full, or at the floor); they - stay out until it ends, so a level edge does not re-arm them.""" + stay out until it ends, so a level edge does not re-arm them. + `extra` (P4: the planned levels) is part of the run's key.""" key = None if not run else (run.start, run.end) + if key is not None and extra is not None: + key += (extra,) was_key, done = self._run_done.get(kind, (None, set())) if key != was_key: done = set() @@ -2487,6 +2572,22 @@ class Controller: self.pv_w, self.pv_why, self.pv_parts = pv_read.total( list(zip(roster, states)), pv_read.inverted(self.o)) + async def _read_forecast(self) -> None: + """OPT-11 P4: the solar forecast sensors (`solarfc.read()`). A + failed or stale read is no forecast, never a zero.""" + today = await self.hass.state(self.fc_entity) + tomorrow = (await self.hass.state(self.fc_tomorrow_entity) + if self.fc_tomorrow_entity else None) + now = datetime.now(timezone.utc) + fc, why = solarfc.read(today, tomorrow, now) + if (fc is None) != (self.solar_fc is None) or why != self.solar_fc_why: + self.log_event( + "solar forecast: " + ("read" if fc is not None else + f"none ({why})"), + hint=messages.hint("forecast_read" if fc is not None + else "forecast_none")) + self.solar_fc, self.solar_fc_why = fc, why + async def read_inputs(self, now_mono: float | None = None) -> None: """Every input this cycle needs, and SAFETY-37's freshness verdict. @@ -2538,6 +2639,14 @@ class Controller: _reads = [self._read_unit(inv, now_mono) for inv in self.inverters] if self.pv_roster: _reads.append(self._read_pv()) + # OPT-11 P4: the solar forecast, in the same gather (a slow sensor + # must not delay control), at most once per FORECAST_READ_S. + if self.fc_entity is not None and ( + self._fc_read_mono is None + or now_mono - self._fc_read_mono >= FORECAST_READ_S + or now_mono < self._fc_read_mono): + self._fc_read_mono = now_mono + _reads.append(self._read_forecast()) await asyncio.gather(*_reads) if o.get("peak_forecast_entity"): raw = await self.hass.number(o["peak_forecast_entity"]) diff --git a/goodwe_controller/app/messages.py b/goodwe_controller/app/messages.py index 4445a60..b3cfef3 100644 --- a/goodwe_controller/app/messages.py +++ b/goodwe_controller/app/messages.py @@ -906,6 +906,8 @@ HINTS = { "trade_charge": "Trading: charging while the price is low", "trade_give": "Trading: giving power while the price is high", "trade_off": "Trading: waiting for a better price", + "forecast_read": "Solar forecast read, the day plan uses it", + "forecast_none": "No solar forecast, so Leftover solar takes solar only", "split": "The power split between inverters changed", "clash": "Two limits clash, inverters set to 0 W", "no_setpoint": "An inverter has no “Setpoint entity”, inverters stay at 0 W", @@ -1172,6 +1174,9 @@ LABELS = { "batt_entity": "Battery power entity", "pv_entity": "Solar production entity", "pv_entities": "Solar production entities, per inverter", + "pv_forecast_entity": "Solar forecast, today", + "pv_forecast_tomorrow_entity": "Solar forecast, tomorrow", + "solar_trust_pct": "Trust in the forecast", "p1_phase_import_entities": "Import entities per phase", "p1_phase_export_entities": "Export entities per phase", } diff --git a/goodwe_controller/app/modetrade.py b/goodwe_controller/app/modetrade.py index 81fa61d..5dcbc06 100644 --- a/goodwe_controller/app/modetrade.py +++ b/goodwe_controller/app/modetrade.py @@ -23,7 +23,11 @@ Roles (the page's Why line names them): Rules that keep this safe (tests: test_modes_p3.py): * every range holds 0 W (a mode may forbid a direction, never force one); - * a Solar first battery never charges from the grid (no forecast yet, P4); + * a Solar first battery never charges from the grid; + * P4: with a day plan (`targets`), a Leftover solar or Cheap hours battery + charges from the grid only up to its planned level, never above the + ceiling; with none, Cheap hours fills to the ceiling and Leftover solar + takes solar only (P3 option A, `charge_to()`); * grid charging needs a measured peak guard that is clear and a measured headroom (SAFETY-45); the trade is sized to that headroom, whose aim is `peak_cap_w - peak_margin_w` (SAFETY-79). It starts only at START_W or @@ -45,7 +49,7 @@ Rules that keep this safe (tests: test_modes_p3.py): from dataclasses import dataclass from .packmode import (CHEAP_HOURS, GRID_CHARGE_MODES, LEFTOVER_SOLAR, - SOLAR_FIRST) + PLAN_CHARGE_MODES, SOLAR_FIRST) OFF, HOUSE, HOLD, GIVE, SELL, CHARGE, REST = ( "off", "house", "hold", "give", "sell", "charge", "rest") @@ -88,13 +92,34 @@ def _room_kwh(p: Pack, pct: float) -> float: return max(0.0, abs(pct - p.soc)) / 100.0 * float(p.capacity_kwh) -def chargers(packs, ceiling_pct, done=()) -> list: - """Batteries that may charge from the grid in a cheap run: the modes in - `packmode.GRID_CHARGE_MODES` (Cheap hours only until P4's forecast).""" - return [p for p in packs - if p.use and p.idle and p.live and p.mode in GRID_CHARGE_MODES - and p.soc is not None and p.soc < ceiling_pct - and p.refused >= 0 and p.name not in done] +def charge_to(p: Pack, ceiling_pct, targets=None): + """The level this battery may charge to from the grid, or None. + + `targets` is the day plan (`dayplan.plan()`: name -> level), or None + when there is none (no forecast, stale, or not configured). With no plan + this is P3 option A exactly: Cheap hours to the ceiling, the others never. + With a plan, a Leftover solar or Cheap hours battery charges only to its + planned level (the sun fills the rest), and a battery the plan leaves out + falls back to the no-plan answer. Solar first never charges.""" + if targets is None or p.name not in targets: + return float(ceiling_pct) if p.mode in GRID_CHARGE_MODES else None + if p.mode not in PLAN_CHARGE_MODES: + return None + t = targets[p.name] + return None if t is None else min(float(ceiling_pct), float(t)) + + +def chargers(packs, ceiling_pct, done=(), targets=None) -> list: + """Batteries that may charge from the grid in a cheap run: below the + level `charge_to()` allows them (P4: the day plan's).""" + out = [] + for p in packs: + to = charge_to(p, ceiling_pct, targets) + if (to is not None and p.use and p.idle and p.live + and p.soc is not None and p.soc < to + and p.refused >= 0 and p.name not in done): + out.append(p) + return out def sellers(packs, floor_pct, done=()) -> list: @@ -106,11 +131,15 @@ def sellers(packs, floor_pct, done=()) -> list: and p.refused <= 0 and p.name not in done] -def energy(packs, floor_pct, ceiling_pct, done_charge=(), done_sell=()): - """`(charge_kwh, charge_kw, sell_kwh, sell_kw)` for `priceruns.plan()`.""" - ch = chargers(packs, ceiling_pct, done_charge) +def energy(packs, floor_pct, ceiling_pct, done_charge=(), done_sell=(), + targets=None): + """`(charge_kwh, charge_kw, sell_kwh, sell_kw)` for `priceruns.plan()`. + With a day plan the charge is each battery's shortfall, so the cheap run + is only as long as the shortfall needs.""" + ch = chargers(packs, ceiling_pct, done_charge, targets) se = sellers(packs, floor_pct, done_sell) - return (sum(_room_kwh(p, ceiling_pct) for p in ch), + return (sum(_room_kwh(p, charge_to(p, ceiling_pct, targets)) + for p in ch), sum(abs(p.rating_w) for p in ch) / 1000.0, sum(_room_kwh(p, floor_pct) for p in se), sum(abs(p.rating_w) for p in se) / 1000.0) @@ -118,7 +147,8 @@ def energy(packs, floor_pct, ceiling_pct, done_charge=(), done_sell=()): def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now, headroom_w, floor_pct, ceiling_pct, house_w, charging_now=False, - blocked=False, done_charge=(), done_sell=()) -> Tick: + blocked=False, done_charge=(), done_sell=(), + targets=None) -> Tick: """This tick's ranges, roles and site ask. `runs` is the kept `priceruns.RunPlan` or None (no fresh prices). @@ -129,7 +159,7 @@ def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now, `house_w` is the measured house (grid + batteries), or None. `charging_now` says last tick charged (then any headroom above 0 keeps it going); `blocked` that the guard or the headroom stopped a charge in - this 15-minute period. + this 15-minute period. `targets` is the day plan (`charge_to()`). """ plan_on = runs is not None and runs.on in_cheap = plan_on and runs.cheap.holds(now) and runs.charge_useful() @@ -137,7 +167,8 @@ def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now, in_sell = plan_on and runs.sell is not None and runs.sell.holds(now) why = None - ch = chargers(packs, ceiling_pct, done_charge) if in_cheap else [] + ch = chargers(packs, ceiling_pct, done_charge, targets) if in_cheap \ + else [] charging = False if ch: need = 0.0 if charging_now else START_W diff --git a/goodwe_controller/app/packmode.py b/goodwe_controller/app/packmode.py index 5fd71fe..39f79db 100644 --- a/goodwe_controller/app/packmode.py +++ b/goodwe_controller/app/packmode.py @@ -46,10 +46,13 @@ SELL_KEY = "pack_sell" # OPT-11 P3: sell in the sell run # refused; the sell code in modetrade/priceruns stays, unreachable. SELLING_ENABLED = False -# The modes that may charge from the grid in a cheap run. Leftover solar joins -# when the solar forecast exists (P4); until then it takes solar only (owner -# decision 2026-09-29, option A). +# The modes that may charge from the grid in a cheap run with no day plan. +# Leftover solar takes solar only then (owner decision 2026-09-29, option A). GRID_CHARGE_MODES = (CHEAP_HOURS,) +# OPT-11 P4: with a fresh solar forecast, the day plan (dayplan.py) may also +# let Leftover solar charge from the grid, by the shortfall only. Solar first +# never charges from the grid. +PLAN_CHARGE_MODES = (LEFTOVER_SOLAR, CHEAP_HOURS) def enabled(opts) -> bool: diff --git a/goodwe_controller/app/solarfc.py b/goodwe_controller/app/solarfc.py new file mode 100644 index 0000000..a2b262d --- /dev/null +++ b/goodwe_controller/app/solarfc.py @@ -0,0 +1,251 @@ +"""OPT-11 P4: the solar forecast, read from a Home Assistant sensor. + +Pure: HA state dicts and the time in, a `Forecast` (or None and why) out. +main.py reads the entities; dayplan.py uses the answer. FCST-01 replaces +this later with the add-on's own forecast (owner answer, 2026-09-27). + +Two options name the sensors: `pv_forecast_entity`, the solar energy still +to come TODAY, and `pv_forecast_tomorrow_entity`, TOMORROW's (optional; it +is only needed for a cheap run before midnight). Supported, by the entities +they create (DOCS.md lists them): + +* Forecast.Solar (HA core): `sensor.energy_production_today_remaining` and + `sensor.energy_production_tomorrow`. The state only; no hourly split. +* Solcast PV Forecast (HACS `solcast_solar`): + `sensor.solcast_pv_forecast_forecast_remaining_today` and + `..._forecast_tomorrow`, with `detailedForecast` (or `detailedHourly`): + `[{period_start, pv_estimate}]`, kW per period. +* Open-Meteo Solar Forecast (HACS `open_meteo_solar_forecast`): + `sensor.energy_production_today_remaining` / `..._tomorrow`, with + `wh_period`: `{period start: Wh}`. + +Any other sensor whose state is energy in Wh, kWh or MWh works too, as a +state only. The unit is read, never guessed (the rule `pv.watts()` has). +Without an hourly split, the day's energy is spread over a fixed bell from +06:00 to 20:00 local time (`BELL_FROM_H`, `BELL_TO_H`). + +A reading older than `FRESH_S` is stale, and a stale or missing forecast is +no forecast: the day plan is off and every mode acts as in P3 option A. +""" + +import math +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone + +from .hass import BAD_STATES +from .tariff import BILLING_TZ + +KWH_PER = {"Wh": 0.001, "kWh": 1.0, "MWh": 1000.0} + +# Stale after 3 h. The integrations above refresh at least hourly, so a +# 2 h limit would flag a site whose hourly poll was missed once. +FRESH_S = 3 * 3600.0 + +# The bell a state-only forecast is spread over, in local hours. +BELL_FROM_H, BELL_TO_H = 6.0, 20.0 + +# Why there is no forecast (the page words are in ui_text `home_words`). +NOT_SET, MISSING, UNIT, STALE, BAD = ( + "not_set", "missing", "unit", "stale", "bad") + + +@dataclass(frozen=True) +class Day: + """One day's forecast: `kwh` still to come (today) or in all (tomorrow), + and the hourly split if the sensor has one: `((start, end, kwh), ...)`.""" + day0: datetime # local midnight, as UTC + kwh: float + periods: tuple = () + + +@dataclass(frozen=True) +class Forecast: + today: Day + tomorrow: Day | None + at: datetime # when the sensor last reported + + def between(self, t0: datetime, t1: datetime) -> float | None: + """kWh the sun should give between `t0` (now or later) and `t1`. + None when the span reaches the sunny hours of a day with no + forecast (tomorrow, when its sensor is not set).""" + if t1 <= t0: + return 0.0 + last = self.tomorrow or self.today + if t1 > last.day0 + timedelta(days=1, hours=BELL_FROM_H): + return None + total = 0.0 + for day in (self.today, self.tomorrow): + if day is None: + continue + a = max(t0, day.day0) + b = min(t1, day.day0 + timedelta(days=1)) + if b > a: + total += _day_between(day, a, b, t0) + return total + + +def _local_hour(t: datetime) -> float: + lt = t.astimezone(BILLING_TZ) + return lt.hour + lt.minute / 60.0 + lt.second / 3600.0 + + +def _bell(h: float) -> float: + """Cumulative share of a day's sun by local hour `h` (0..1).""" + if h <= BELL_FROM_H: + return 0.0 + if h >= BELL_TO_H: + return 1.0 + x = (h - BELL_FROM_H) / (BELL_TO_H - BELL_FROM_H) + return (1.0 - math.cos(math.pi * x)) / 2.0 + + +def _hours_since(day0, t): + return (t - day0).total_seconds() / 3600.0 + + +def _day_between(day: Day, a: datetime, b: datetime, now: datetime) -> float: + if day.periods: + out = 0.0 + for s, e, kwh in day.periods: + lo, hi = max(a, s), min(b, e) + if hi > lo: + out += kwh * (hi - lo).total_seconds() / (e - s).total_seconds() + return out + # State only: `kwh` is what is left from `max(now, day0)` to the day's + # end, spread over the bell. + h_from = _hours_since(day.day0, max(now, day.day0)) + left = 1.0 - _bell(h_from) + if left <= 1e-9: + return 0.0 + share = (_bell(_hours_since(day.day0, b)) + - _bell(_hours_since(day.day0, a))) / left + return day.kwh * max(0.0, share) + + +def _parse_time(v): + try: + t = datetime.fromisoformat(str(v).replace("Z", "+00:00")) + except (TypeError, ValueError): + return None + return t if t.tzinfo else t.replace(tzinfo=timezone.utc) + + +def _periods(attrs: dict, day0: datetime) -> tuple: + """The hourly split, if the sensor has one, for the day at `day0`.""" + d1 = day0 + timedelta(days=1) + out = [] + for key in ("detailedForecast", "detailedHourly"): + rows = attrs.get(key) + if isinstance(rows, list) and rows: + starts = [] + for r in rows: + if not isinstance(r, dict): + continue + t = _parse_time(r.get("period_start")) + try: + kw = float(r.get("pv_estimate")) + except (TypeError, ValueError): + continue + if t is not None and math.isfinite(kw) and kw >= 0: + starts.append((t, kw)) + starts.sort() + for i, (t, kw) in enumerate(starts): + step = (starts[i + 1][0] - t if i + 1 < len(starts) + else timedelta(minutes=30 if key == + "detailedForecast" else 60)) + if step <= timedelta(0) or step > timedelta(hours=1): + step = timedelta(minutes=30 if key == "detailedForecast" + else 60) + if day0 <= t < d1: + out.append((t, t + step, + kw * step.total_seconds() / 3600.0)) + if out: + return tuple(out) + wh = attrs.get("wh_period") + if isinstance(wh, dict) and wh: + rows = [] + for k, v in wh.items(): + t = _parse_time(k) + try: + e = float(v) + except (TypeError, ValueError): + continue + if t is not None and math.isfinite(e) and e >= 0: + rows.append((t, e / 1000.0)) + rows.sort() + for i, (t, kwh) in enumerate(rows): + nxt = rows[i + 1][0] if i + 1 < len(rows) else t + timedelta( + hours=1) + if nxt - t > timedelta(hours=1) or nxt <= t: + nxt = t + timedelta(hours=1) + if day0 <= t < d1: + out.append((t, nxt, kwh)) + return tuple(out) + + +def _day(state, day0, now, *, today): + """`(Day, None)` or `(None, why)` from one sensor's state dict.""" + if state is None: + return None, MISSING + raw = str(state.get("state", "")).strip() + if raw.lower() in BAD_STATES: + return None, MISSING + try: + v = float(raw) + except ValueError: + return None, BAD + attrs = state.get("attributes") or {} + unit = str(attrs.get("unit_of_measurement") or "").strip() + if unit not in KWH_PER: + return None, UNIT + kwh = v * KWH_PER[unit] + if not math.isfinite(kwh) or kwh < 0: + return None, BAD + periods = _periods(attrs, day0) + if periods and today: + # The split is the whole day; keep what is still to come, scaled so + # it adds up to the state (the state is what the integration says + # is left). + left = sum(k * max(0.0, min(1.0, (e - max(s, now)).total_seconds() + / (e - s).total_seconds())) + for s, e, k in periods) + if left > 1e-6: + f = kwh / left + periods = tuple((s, e, k * f) for s, e, k in periods) + return Day(day0, kwh, periods), None + + +def _reported(state): + ts = [_parse_time(state.get(k)) for k in + ("last_reported", "last_updated", "last_changed")] + ts = [t for t in ts if t is not None] + return max(ts) if ts else None + + +def read(today_state, tomorrow_state, now: datetime, *, + configured=True): + """`(Forecast, None)` or `(None, why)`. `tomorrow_state` may be None + (the option is not set, or the sensor is missing): the forecast then + covers today only.""" + if not configured: + return None, NOT_SET + here = now.astimezone(BILLING_TZ) + d0 = datetime(here.year, here.month, here.day, tzinfo=BILLING_TZ) + nd = d0.date() + timedelta(days=1) + d1 = datetime(nd.year, nd.month, nd.day, tzinfo=BILLING_TZ) + day0, day1 = d0.astimezone(timezone.utc), d1.astimezone(timezone.utc) + today, why = _day(today_state, day0, now, today=True) + if today is None: + return None, why + at = _reported(today_state) + if at is None or (now - at).total_seconds() > FRESH_S \ + or (at - now).total_seconds() > 300: + return None, STALE + tomorrow = None + if tomorrow_state is not None: + t, _w = _day(tomorrow_state, day1, now, today=False) + t_at = _reported(tomorrow_state) + if t is not None and t_at is not None \ + and (now - t_at).total_seconds() <= FRESH_S: + tomorrow = t + return Forecast(today, tomorrow, at), None diff --git a/goodwe_controller/app/static/app.js b/goodwe_controller/app/static/app.js index 8c3fca9..e5354b8 100644 --- a/goodwe_controller/app/static/app.js +++ b/goodwe_controller/app/static/app.js @@ -403,6 +403,9 @@ else $("#batt-banner").replaceChildren(); keepFocus(() => { $("#bcards").replaceChildren(...S.batteries.map(batteryCard)); + // OPT-11 P4: the solar forecast line under the cards. + const sl = $("#batt-solar"), line = modes && S.solar ? S.solar.line : ""; + if (sl) { sl.textContent = line || ""; sl.hidden = !line; } if (simpleHost && simpleHost.isConnected) { simpleHost.replaceChildren(...(modes ? S.batteries.map(simpleRow) : [h("p", { class: "small muted", style: "margin:4px 0" }, t("batteries.modes_off.name") + ". " + t("batteries.description"))])); diff --git a/goodwe_controller/app/static/index.html b/goodwe_controller/app/static/index.html index a915a09..24a34eb 100644 --- a/goodwe_controller/app/static/index.html +++ b/goodwe_controller/app/static/index.html @@ -101,6 +101,7 @@
+