From 9b1fa758ad7ca7d0fe2f80366496d739698b2926 Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Sun, 11 Oct 2026 02:55:58 +0200 Subject: [PATCH] OPT-17: the solar forecast can come from several entities, summed New list options pv_forecast_entities and pv_forecast_tomorrow_entities (same shape as pv_entities); the single-entity keys are unchanged. - Each member is read in its own unit (Wh/kWh/MWh) in one gather and the day is summed. Hourly splits are merged when every member has one, else the total goes on the bell. - A member missing, unavailable, stale, non-energy or bad makes that day's forecast unusable (no partial sum). A caution row names it by position; the action log names the entity once per change. - Single key and list both set for one day: refused at start. - A one-member list behaves exactly like the single key (test_opt17.py); the single-key path is byte-identical to e36565f over 24 scenarios. - Supervisor shape measured: tools/supervisor_shape_probe.py section J. DECISIONS/OPT-17-several-forecast-entities.md has the rules. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC --- .../OPT-17-several-forecast-entities.md | 78 +++ goodwe_controller/DOCS.md | 5 + goodwe_controller/app/main.py | 57 +- goodwe_controller/app/messages.py | 14 + goodwe_controller/app/solarfc.py | 130 ++++- goodwe_controller/app/uilayout.py | 4 + goodwe_controller/app/validate.py | 40 +- goodwe_controller/config.yaml | 12 + goodwe_controller/test_modes_p4.py | 14 +- goodwe_controller/test_opt17.py | 487 ++++++++++++++++++ .../tools/supervisor_shape_probe.py | 73 +++ goodwe_controller/translations/en.yaml | 6 + goodwe_controller/translations/nl.yaml | 6 + 13 files changed, 878 insertions(+), 48 deletions(-) create mode 100644 goodwe_controller/DECISIONS/OPT-17-several-forecast-entities.md create mode 100644 goodwe_controller/test_opt17.py diff --git a/goodwe_controller/DECISIONS/OPT-17-several-forecast-entities.md b/goodwe_controller/DECISIONS/OPT-17-several-forecast-entities.md new file mode 100644 index 0000000..d18c830 --- /dev/null +++ b/goodwe_controller/DECISIONS/OPT-17-several-forecast-entities.md @@ -0,0 +1,78 @@ +# OPT-17 - the solar forecast from several entities, summed + +**Status: for review.** Tests: `test_opt17.py`. Two options added. + +A site whose forecast comes as several Home Assistant sensors (one per +inverter or roof side) had to build a template sensor to sum them. Now each +day can list them. + +## Options + +- `pv_forecast_entities` and `pv_forecast_tomorrow_entities`: lists of + sensors, default `[]`, the same shape as `pv_entities`. +- `pv_forecast_entity` and `pv_forecast_tomorrow_entity` are unchanged. An + install that uses them needs no edit. +- **Why new keys, not "string or list" in the old ones.** Measured on + Supervisor 2026.08.0 by `tools/supervisor_shape_probe.py` §J, as TEL-12's + §G did for `pv_entity`. Declared as a list, each forecast key refuses the + string an install already stores (`Invalid list for option + 'pv_forecast_entity'`), and a `[]` default does not rescue it. The one + spelling that takes both, `match(.*)?`, also takes the integer 417 and + shows a plain text box, so a second sensor cannot be added in the UI. +- §J also shows: a 0.17.0 blob validates unchanged with the lists at `[]`; + the lists render as repeatable fields; a downgrade drops them and keeps + the single keys. +- `[]` in `options:` is load-bearing, as for `pv_entities`. + +## Rules + +1. **One key per day.** Setting the single key and the list for the same day + is refused at start, with one message: keep the list, clear the single one. + The list can hold that entity too. (TEL-12 lets `pv_entities` replace + `pv_entity` with a caution row. Here the brief asked for a refusal: a + doubled or dropped forecast changes what a battery buys, and no install + has both keys yet, so the refusal breaks nothing.) +2. **Each member in its own unit.** Every member is read as one forecast + sensor is today (`solarfc._day()`): Wh, kWh or MWh, never guessed. Then the + day's kWh are added up. A repeated id counts once. +3. **One member not usable means the day is not usable.** Missing, + unavailable, unknown, stale, not an energy unit, or a bad number: that + day's sum is not used, exactly as when the single sensor reads that way. + No today means no forecast (P3 option A). No tomorrow means today only. +4. **Hourly split.** If every member has a split, the splits are joined + (overlapping periods add up). If any member has none, the sum is spread + over the bell, as for a state-only sensor. +5. **Time.** The forecast is as old as its oldest member. +6. **One read.** All members of both days are read in one gather, inside the + gather that reads the inverters, so a slow sensor does not delay control. +7. **Shown and logged once.** With two or more members, each one not usable + is named on the status page by its place in the list (STYLE.md: no entity + ids on the page), as a caution row. The action log names the entity id, + once when the set of failing members changes. +8. **One member is the single key.** A one-member list reads, plans, shows + and logs exactly as the single key with that entity. + +## Why + +- A partial sum is a smaller number that looks real. Two of three inverters' + forecasts read as a duller day, and Leftover solar would then buy grid + energy the sun was going to give. TEL-12 took the same rule for the same + reason. No forecast is a known state with a tested fallback. +- The bell for a mixed list: joining one member's hours with another's total + has no honest shape, and the bell is the existing state-only rule. + +## Cost + +- One flaky member costs the whole day's forecast. A site with three + sensors loses the plan more often than one with a template sum that hides + gaps. +- Two more options (85). + +## What this does not cover + +- No weighting or calibration per member; FCST-01 is still the own forecast. +- Members on different integrations with different refresh times: each must + be fresh (3 h) on its own; there is no wait for a lagging one. +- The day plan itself, the trust and the house profile are unchanged. +- The Batteries screen still says only "no solar forecast now". The member + is named on the status page. diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index 0d00d11..cab7a8c 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -186,6 +186,8 @@ The label and description each setting shows on the Configuration tab, in tab or | 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 | Several solar forecasts, today (kWh) | `pv_forecast_entities` | Optional. One row per forecast sensor, such as one per inverter, used instead of the single one above. They are added up. | +| Hardware | Several solar forecasts, tomorrow (kWh) | `pv_forecast_tomorrow_entities` | Optional. The same list for tomorrow. If one sensor has no reading, that day's forecast is not used. | | 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. | @@ -539,6 +541,9 @@ With battery modes on, these choices replace the *Charging and selling on price* - 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. +- **Several forecasts.** If your forecast comes as several sensors (one per inverter or roof side), list them in `pv_forecast_entities` and, for tomorrow, `pv_forecast_tomorrow_entities`, instead of the single key. They are added up, each in its own unit (Wh, kWh or MWh). Set the list or the single key for a day, not both. +- If one of them has no reading, is stale or is not in an energy unit, that day's forecast is not used at all. A part of the sun is not counted as all of it. The status page names the source by its place in the list. +- The hourly shape is kept only if every sensor has one. Otherwise the total is spread over the day as for Forecast.Solar. - 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* ignores the forecast and fills to the top level. diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index a85adce..e79fd32 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -992,12 +992,17 @@ class Controller: 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 + # modes on AND a forecast for today set; otherwise none of this runs + # and every mode acts as in P3 option A. OPT-17: each day may name + # several entities, summed (`solarfc.entities()`); `fc_entity` is + # the first, and None means no forecast. + self.fc_entities = (solarfc.entities(opts) if self.modes_on + else ()) + self.fc_tomorrow_entities = solarfc.entities(opts, tomorrow=True) + self.fc_entity = self.fc_entities[0] if self.fc_entities else None + # OPT-17: the members that are not usable, as `solarfc.read_sum()` + # gives them, for the status page and the action log. + self.solar_fc_gaps = () self.solar_trust_pct = opts.get("solar_trust_pct", dayplan.DEFAULT_TRUST_PCT) self.solar_fc = None @@ -3246,18 +3251,31 @@ class Controller: 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) + # OPT-17: every member of both days in one gather. + ents = self.fc_entities + self.fc_tomorrow_entities + states = await asyncio.gather(*(self.hass.state(e) for e in ents)) + n = len(self.fc_entities) 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: + fc, why, gaps = solarfc.read_sum( + list(zip(self.fc_entities, states[:n])), + list(zip(self.fc_tomorrow_entities, states[n:])), now) + # A member is named only in a sum of several; one entity reads as + # before. Logged once per episode: on a change, never every read. + gaps = tuple(g for g in gaps if len( + self.fc_entities if g[0] == "today" + else self.fc_tomorrow_entities) > 1) + if (fc is None) != (self.solar_fc is None) \ + or why != self.solar_fc_why or gaps != self.solar_fc_gaps: 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")) + f"none ({why})") + + "".join(f", {d} {e} not usable ({w})" + for d, _i, e, w in gaps), + hint=messages.hint( + "forecast_none" if fc is None else + "forecast_no_tomorrow" if gaps else "forecast_read")) self.solar_fc, self.solar_fc_why = fc, why + self.solar_fc_gaps = gaps async def read_inputs(self, now_mono: float | None = None) -> None: """Every input this cycle needs, and SAFETY-37's freshness verdict. @@ -6998,6 +7016,17 @@ class Controller: # is both is a check nobody reads. out.append({"ok": False, "warn": True, "id": "peak_forecast", "text": messages.row_no_peak_forecast()}) + # OPT-17: a forecast summed from several entities names, by position, + # each member with no usable reading. A caution: control goes on. + gaps = getattr(self, "solar_fc_gaps", ()) + for day, ents in (("today", getattr(self, "fc_entities", ())), + ("tomorrow", + getattr(self, "fc_tomorrow_entities", ()))): + gone = [i for d, i, _e, _w in gaps if d == day] + if gone: + out.append({"ok": False, "warn": True, "id": "solar_forecast", + "text": messages.row_forecast_gap( + day, gone, len(ents))}) # --- TEL-11 ---------------------------------------------------------- # ⚠️ NO ROW AT ALL WHEN THE ROSTER IS EMPTY, AND THAT IS A DELIBERATE # DIFFERENCE FROM THE PEAK-FORECAST CAUTION DIRECTLY ABOVE. That one diff --git a/goodwe_controller/app/messages.py b/goodwe_controller/app/messages.py index 45e4a74..27f959d 100644 --- a/goodwe_controller/app/messages.py +++ b/goodwe_controller/app/messages.py @@ -754,6 +754,15 @@ def row_pv_partial(missing, count) -> str: f"reading") +def row_forecast_gap(day, missing, count) -> str: + """OPT-17. `day` is "today" or "tomorrow"; `missing` are the 1-based + positions of the forecast entities with no usable reading.""" + which = names([str(n) for n in missing]) + many = len(missing) > 1 + return (f"Solar forecast for {day}: not used, source{'s' if many else ''} " + f"{which} of {count} {'have' if many else 'has'} no reading") + + def row_pv_single_ignored() -> str: return f"Solar production: clear {PV_ONE}, the per-inverter list replaces it" @@ -953,6 +962,7 @@ HINTS = { "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", + "forecast_no_tomorrow": "Tomorrow's solar forecast is not used, a source has no reading", "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", @@ -1114,6 +1124,8 @@ def status_samples() -> list: "entity - it cannot be read in watts")), ("row_pv_partial", AMBER, row_pv_partial([2, 3], 3)), ("row_pv_partial", AMBER, row_pv_partial([2], 2)), + ("row_forecast_gap", AMBER, row_forecast_gap("today", [2], 3)), + ("row_forecast_gap", AMBER, row_forecast_gap("tomorrow", [1, 2], 2)), ("row_pv_single_ignored", AMBER, row_pv_single_ignored()), ("row_pv_negative", AMBER, row_pv_negative(2, 3, -1500, False)), ("row_pv_negative", AMBER, row_pv_negative(1, 1, -1500, True)), @@ -1232,6 +1244,8 @@ LABELS = { "pv_entities": "Solar production entities, per inverter", "pv_forecast_entity": "Solar forecast, today", "pv_forecast_tomorrow_entity": "Solar forecast, tomorrow", + "pv_forecast_entities": "Several solar forecasts, today", + "pv_forecast_tomorrow_entities": "Several solar forecasts, 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/solarfc.py b/goodwe_controller/app/solarfc.py index 89977cd..4ebbf6f 100644 --- a/goodwe_controller/app/solarfc.py +++ b/goodwe_controller/app/solarfc.py @@ -27,6 +27,11 @@ Without an hourly split, the day's energy is spread over a fixed bell from A reading older than `FRESH_S`, or from before local midnight, 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. + +OPT-17: each day may instead name a list (`pv_forecast_entities`, +`pv_forecast_tomorrow_entities`), summed by `read_sum()`. One member that is +not usable makes that day's sum unusable, never a partial sum. +DECISIONS/OPT-17-several-forecast-entities.md. """ import math @@ -49,6 +54,12 @@ BELL_FROM_H, BELL_TO_H = 6.0, 20.0 NOT_SET, MISSING, UNIT, STALE, BAD = ( "not_set", "missing", "unit", "stale", "bad") +# OPT-17: (single key, list key) per day. A set list replaces the single key; +# the validator refuses both set for the same day. +TODAY_KEYS = ("pv_forecast_entity", "pv_forecast_entities") +TOMORROW_KEYS = ("pv_forecast_tomorrow_entity", + "pv_forecast_tomorrow_entities") + @dataclass(frozen=True) class Day: @@ -223,6 +234,52 @@ def _reported(state): return max(ts) if ts else None +def entities(opts: dict, *, tomorrow=False) -> tuple: + """The forecast entities one day names, in configured order: the list + if it has any entry, else the single key, else (). A repeated id is + dropped, as `pv.entities()` does: it would count one forecast twice.""" + one, many = TOMORROW_KEYS if tomorrow else TODAY_KEYS + listed = [e for e in (str(x or "").strip() + for x in (opts.get(many) or [])) if e] + if listed: + return tuple(dict.fromkeys(listed)) + e = str(opts.get(one) or "").strip() + return (e,) if e else () + + +def _bounds(now): + 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) + return d0.astimezone(timezone.utc), d1.astimezone(timezone.utc) + + +def _today(state, day0, now): + """`(Day, at, None)` or `(None, None, why)` for one today sensor.""" + today, why = _day(state, day0, now, today=True) + if today is None: + return None, None, why + at = _reported(state) + # A reading from before local midnight is yesterday's "left today" (0 + # after sunset) until the integration refreshes: stale, not a dark day. + if at is None or at < day0 or (now - at).total_seconds() > FRESH_S \ + or (at - now).total_seconds() > 300: + return None, None, STALE + return today, at, None + + +def _tomorrow(state, day0, day1, now): + """`(Day, None)` or `(None, why)` for one tomorrow sensor.""" + t, why = _day(state, day1, now, today=False) + if t is None: + return None, why + t_at = _reported(state) + if t_at is None or t_at < day0 or (now - t_at).total_seconds() > FRESH_S: + return None, STALE + return t, None + + def read(today_state, tomorrow_state, now: datetime, *, configured=True): """`(Forecast, None)` or `(None, why)`. `tomorrow_state` may be None @@ -230,25 +287,64 @@ def read(today_state, tomorrow_state, now: datetime, *, 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) + day0, day1 = _bounds(now) + today, at, why = _today(today_state, day0, now) if today is None: return None, why - at = _reported(today_state) - # A reading from before local midnight is yesterday's "left today" (0 - # after sunset) until the integration refreshes: stale, not a dark day. - if at is None or at < day0 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 t_at >= day0 \ - and (now - t_at).total_seconds() <= FRESH_S: - tomorrow = t + tomorrow, _w = _tomorrow(tomorrow_state, day0, day1, now) return Forecast(today, tomorrow, at), None + + +def _sum(days): + """One Day from several members' Days. The hourly split is kept only if + every member has one: the splits are then joined (`_day_between()` + adds overlapping periods). Else the total goes on the bell.""" + if len(days) == 1: + return days[0] + periods = () + if all(d.periods for d in days): + periods = tuple(sorted(p for d in days for p in d.periods)) + return Day(days[0].day0, sum(d.kwh for d in days), periods) + + +def read_sum(today, tomorrow, now: datetime): + """OPT-17: `(Forecast or None, why, gaps)` over lists of + `(entity, state)`, one per configured member, in configured order. + + Each member is read exactly as `read()` reads one sensor, in its own + unit. A member that is missing, unavailable, stale, not in an energy + unit or bad makes that DAY unusable, as one such sensor does today: no + today means no forecast, no tomorrow means today only. A partial sum + would under-count the sun and mislead the day plan. + + `gaps` is `((day, position, entity, why), ...)` for every unusable + member (`day` "today" or "tomorrow", `position` 1-based), so the page + and the log can name it. With one member per day the forecast is what + `read()` gives (test_opt17.py proves it).""" + if not today: + return None, NOT_SET, () + day0, day1 = _bounds(now) + gaps, days, ats, why0 = [], [], [], None + for i, (ent, st) in enumerate(today, 1): + d, at, why = _today(st, day0, now) + if d is None: + gaps.append(("today", i, ent, why)) + why0 = why0 or why + else: + days.append(d) + ats.append(at) + tdays = [] + for i, (ent, st) in enumerate(tomorrow, 1): + t, why = _tomorrow(st, day0, day1, now) + if t is None: + gaps.append(("tomorrow", i, ent, why)) + else: + tdays.append(t) + gaps = tuple(gaps) + if why0 is not None: + return None, why0, gaps + tday = _sum(tdays) if tomorrow and len(tdays) == len(tomorrow) else None + # The oldest member's report is when the sum was last fully known. + return Forecast(_sum(days), tday, min(ats)), None, gaps diff --git a/goodwe_controller/app/uilayout.py b/goodwe_controller/app/uilayout.py index 04a8e80..4317993 100644 --- a/goodwe_controller/app/uilayout.py +++ b/goodwe_controller/app/uilayout.py @@ -135,6 +135,10 @@ GROUPS = ( Field("pv_forecast_entity", ENTITY, kind="energy", simple=True, note="forecast"), Field("pv_forecast_tomorrow_entity", ENTITY, kind="energy"), + # OPT-17: several forecast sensors per day, summed. + Field("pv_forecast_entities", ENTITY_LIST, kind="energy"), + Field("pv_forecast_tomorrow_entities", ENTITY_LIST, + kind="energy"), Field("solar_trust_pct", SLIDER, "%", step=5), )), )), diff --git a/goodwe_controller/app/validate.py b/goodwe_controller/app/validate.py index e49e0ca..d8a56bf 100644 --- a/goodwe_controller/app/validate.py +++ b/goodwe_controller/app/validate.py @@ -1802,12 +1802,13 @@ def _entity_notes(notes, opts: dict, entities: dict, bad=()) -> None: _advise(notes, "warn", (key,), f"{entity} does not read in kWh, so pick a solar " "forecast sensor in kWh.") - if any(opts.get(k) for k in FORECAST_KEYS) and \ + today = solarfc.entities(opts) + tomorrow = solarfc.entities(opts, tomorrow=True) + if (today or tomorrow) and \ opts.get("battery_modes_enabled") is not True: _advise(notes, "info", ("pv_forecast_entity",), "The solar forecast is used only with battery modes on.") - if opts.get("pv_forecast_tomorrow_entity") and \ - not opts.get("pv_forecast_entity"): + if tomorrow and not today: _advise(notes, "warn", ("pv_forecast_tomorrow_entity",), "Tomorrow's forecast is used only with today's, so set " f"{S('pv_forecast_entity')} too.") @@ -2069,12 +2070,13 @@ def _margin_problems(opts: dict) -> list[str]: TRUST_RANGE_PCT = (30, 100) # A Home Assistant sensor id; it goes into the /api/states/ path. _SENSOR_ID = re.compile(r"sensor\.[a-z0-9_]+") -FORECAST_KEYS = ("pv_forecast_entity", "pv_forecast_tomorrow_entity") +FORECAST_KEYS = solarfc.TODAY_KEYS + solarfc.TOMORROW_KEYS def _forecast_problems(opts: dict) -> list[str]: """Rule 13 (OPT-11 P4): the trust is a whole percentage in range, and - each forecast entity set is a sensor.""" + each forecast entity set is a sensor. OPT-17: a day names its forecast + in the single key or in the list, not both.""" out = [] raw = opts.get("solar_trust_pct") trust = _number(opts, "solar_trust_pct", dayplan.DEFAULT_TRUST_PCT) @@ -2082,14 +2084,30 @@ def _forecast_problems(opts: dict) -> list[str]: if trust is None or trust != int(trust) or not lo <= trust <= hi: out.append(f"{S('solar_trust_pct')} must be a whole number from {lo} " f"to {hi} %. It is {messages.shown(raw)}.") - for key in FORECAST_KEYS: - e = opts.get(key) - if e is None or e == "": - continue - if not isinstance(e, str) or not _SENSOR_ID.fullmatch(e): - out.append(f"{S(key)} must be a sensor, like " + for one, many in (solarfc.TODAY_KEYS, solarfc.TOMORROW_KEYS): + e = opts.get(one) + if e is not None and e != "" and ( + not isinstance(e, str) or not _SENSOR_ID.fullmatch(e)): + out.append(f"{S(one)} must be a sensor, like " f"sensor.energy_production_today_remaining. It is " f"{messages.shown(e)}.") + raw = opts.get(many) + if raw is None: + raw = [] + if not isinstance(raw, list): + out.append(f"{S(many)} must be a list of sensors. It is " + f"{messages.shown(raw)}.") + continue + listed = [x for x in raw if x is not None + and not (isinstance(x, str) and not x.strip())] + for x in listed: + if not isinstance(x, str) or not _SENSOR_ID.fullmatch(x): + out.append(f"{S(many)} must list only sensors, like " + f"sensor.energy_production_today_remaining. " + f"It has {messages.shown(x)}.") + if e and listed: + out.append(f"Set {S(one)} or {S(many)}, not both. Keep the " + f"list and clear the single one.") return out diff --git a/goodwe_controller/config.yaml b/goodwe_controller/config.yaml index 3ccdcc1..85919d6 100644 --- a/goodwe_controller/config.yaml +++ b/goodwe_controller/config.yaml @@ -157,6 +157,11 @@ options: # integrations whose sensors fit. pv_forecast_entity: "" pv_forecast_tomorrow_entity: "" + # OPT-17: several forecast sensors per day (one per inverter or string), + # summed. Use the list OR the single key above, not both. `[]` is + # load-bearing, as for `pv_entities`. + pv_forecast_entities: [] + pv_forecast_tomorrow_entities: [] solar_trust_pct: 70 # TEL-11 - THE SITE'S PV PRODUCTION, IN WATTS, AND IT IS READ BY NOTHING THAT @@ -961,6 +966,13 @@ schema: battery_modes_enabled: bool pv_forecast_entity: str? pv_forecast_tomorrow_entity: str? + # OPT-17: new list keys beside the single ones. A list schema on the old + # keys refuses the string an install stores (tools/supervisor_shape_probe.py + # section J); DECISIONS/OPT-17-several-forecast-entities.md. + pv_forecast_entities: + - str + pv_forecast_tomorrow_entities: + - str solar_trust_pct: int(30,100) # TEL-11. Optional, and OPTIONAL WITHOUT A COMPANION RULE - unlike # `price_now_entity`/`price_avg_entity`, which are legal only while diff --git a/goodwe_controller/test_modes_p4.py b/goodwe_controller/test_modes_p4.py index b0f1234..bcb4343 100644 --- a/goodwe_controller/test_modes_p4.py +++ b/goodwe_controller/test_modes_p4.py @@ -113,19 +113,21 @@ check("the harness bites: Leftover solar grid-charging with no plan moves " lambda: setattr(modetrade, "GRID_CHARGE_MODES", (packmode.LEFTOVER_SOLAR, packmode.CHEAP_HOURS)), lambda: setattr(modetrade, "GRID_CHARGE_MODES", _gcm))) -_read = solarfc.read +# OPT-17: the controller reads through `read_sum()`, so that is the seam. +_read = solarfc.read_sum def _as_dark(today, tomorrow, now, **kw): - return _read({"state": "0", "attributes": {"unit_of_measurement": - "kWh"}, - "last_updated": now.isoformat()}, None, now) + return _read([("sensor.f", {"state": "0", "attributes": { + "unit_of_measurement": "kWh"}, "last_updated": now.isoformat()})], + [], now) check("...and an unavailable forecast read as a dark one moves " "n2_sf_ls_fc_down (Leftover solar would buy its shortfall)", - _moved("n2_sf_ls_fc_down", lambda: setattr(solarfc, "read", _as_dark), - lambda: setattr(solarfc, "read", _read))) + _moved("n2_sf_ls_fc_down", + lambda: setattr(solarfc, "read_sum", _as_dark), + lambda: setattr(solarfc, "read_sum", _read))) # ============================================================================ # print("2. the forecast sensors") diff --git a/goodwe_controller/test_opt17.py b/goodwe_controller/test_opt17.py new file mode 100644 index 0000000..d0f4521 --- /dev/null +++ b/goodwe_controller/test_opt17.py @@ -0,0 +1,487 @@ +"""Runnable check for OPT-17, the solar forecast summed from several entities. +`python3 test_opt17.py` + +1. `solarfc.read_sum()`: sums in mixed units, refusals, a member down or + stale makes that day unusable (never a partial sum), hourly splits, today + and tomorrow, an empty list, and one member equal to `read()`. +2. `solarfc.entities()`: which key a day uses. +3. Options: the validator, the settings notes and config.yaml. +4. The REAL controller (tools/opt11p3_replay.py): a one-member list runs + exactly like the single key, two halves like one whole, a member down + like a forecast down; the status row and the action log name the member + once; every member is read in one gather; modes off reads nothing. + +DECISIONS/OPT-17-several-forecast-entities.md has the rules. Mutations +(MUTATION.md, purged copies) are listed in the pull request. +""" + +import asyncio +import logging +import os +import re +import sys +from datetime import datetime, timedelta, timezone + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) +sys.path.insert(0, os.path.join(HERE, "tools")) +logging.disable(logging.WARNING) + +import opt11p3_replay as rp # noqa: E402 +from app import dayplan, messages, solarfc, validate # noqa: E402 + +fails = [] + + +def check(name, cond): + print(f" {'ok ' if cond else 'FAIL'} {name}") + if not cond: + fails.append(name) + + +L = timezone(timedelta(hours=2)) +UTC0 = datetime(2026, 9, 21, tzinfo=L).astimezone(timezone.utc) +TOM = UTC0 + timedelta(days=1) +NOW = datetime(2026, 9, 21, 9, 0, tzinfo=L).astimezone(timezone.utc) +END = UTC0 + timedelta(days=1) +OLD = NOW - timedelta(seconds=solarfc.FRESH_S + 1) + + +def st(state, unit="kWh", at=NOW, **attrs): + a = dict(attrs) + if unit is not None: + a["unit_of_measurement"] = unit + return {"state": state, "attributes": a, + "last_updated": at.isoformat(), "last_reported": at.isoformat()} + + +def members(*states): + return [(f"sensor.fc_{i}", s) for i, s in enumerate(states, 1)] + + +def near(a, b, eps=1e-6): + return a is not None and b is not None and abs(a - b) < eps + + +# ============================================================================ # +print("1. read_sum") +fc, why, gaps = solarfc.read_sum(members(st("5"), st("7500", "Wh")), [], NOW) +check("two members, kWh + Wh: 5 + 7.5 = 12.5 kWh", + fc is not None and near(fc.today.kwh, 12.5) and why is None + and gaps == ()) +check("...and the rest of the day adds up to the sum", + near(fc.between(NOW, END), 12.5)) +fc, why, gaps = solarfc.read_sum( + members(st("5"), st("2500", "Wh"), st("0.005", "MWh")), [], NOW) +check("three members, kWh + Wh + MWh: 5 + 2.5 + 5 = 12.5 kWh", + fc is not None and near(fc.today.kwh, 12.5)) +for unit in ("W", None, "kW", "%"): + fc, why, gaps = solarfc.read_sum(members(st("5"), st("5", unit)), [], + NOW) + check(f"a member in {unit or 'no unit'} makes today unusable (unit), " + f"and is named", + fc is None and why == solarfc.UNIT + and gaps == (("today", 2, "sensor.fc_2", solarfc.UNIT),)) +for bad, w in (("unavailable", solarfc.MISSING), ("unknown", solarfc.MISSING), + (None, solarfc.MISSING), ("-1", solarfc.BAD), + ("lots", solarfc.BAD)): + s2 = None if bad is None else st(bad) + fc, why, gaps = solarfc.read_sum(members(st("5"), s2, st("4")), [], NOW) + check(f"a member that reads {bad!r}: no forecast at all ({w}), not " + f"the 9 kWh of the others", + fc is None and why == w + and gaps == (("today", 2, "sensor.fc_2", w),)) +fc, why, gaps = solarfc.read_sum(members(st("5"), st("4", at=OLD)), [], NOW) +check("a stale member (older than 3 h): no forecast (stale), named", + fc is None and why == solarfc.STALE + and gaps == (("today", 2, "sensor.fc_2", solarfc.STALE),)) +EVE = datetime(2026, 9, 20, 23, 30, tzinfo=L).astimezone(timezone.utc) +MID = datetime(2026, 9, 21, 0, 10, tzinfo=L).astimezone(timezone.utc) +fc, why, gaps = solarfc.read_sum(members(st("5", at=MID), st("0", at=EVE)), + [], MID) +check("a member from before local midnight is stale, not a dark member", + fc is None and why == solarfc.STALE) +fc, why, gaps = solarfc.read_sum(members(st("unavailable"), st("4", at=OLD)), + [], NOW) +check("two members down: both named, the first reason is the day's", + fc is None and why == solarfc.MISSING and [g[1] for g in gaps] == [1, 2]) +check("an empty list is not set", solarfc.read_sum([], [], NOW) + == (None, solarfc.NOT_SET, ()) + and solarfc.read_sum([], members(st("5")), NOW)[1] == solarfc.NOT_SET) +fc, _w, _g = solarfc.read_sum(members(st("5", at=NOW - timedelta(hours=2)), + st("4")), [], NOW) +check("the forecast's time is the oldest member's report", + fc.at == NOW - timedelta(hours=2)) + +# Tomorrow. +fc, why, gaps = solarfc.read_sum(members(st("5"), st("4")), + members(st("12"), st("8000", "Wh")), NOW) +check("tomorrow: 12 + 8 = 20 kWh, and today is 9", + fc is not None and near(fc.tomorrow.kwh, 20.0) and near(fc.today.kwh, + 9.0) + and near(fc.between(TOM, TOM + timedelta(days=1)), 20.0)) +for name, t2, w in (("unavailable", st("unavailable"), solarfc.MISSING), + ("stale", st("8", at=OLD), solarfc.STALE), + ("in W", st("8", "W"), solarfc.UNIT)): + fc, why, gaps = solarfc.read_sum(members(st("5"), st("4")), + members(st("12"), t2), NOW) + check(f"a tomorrow member {name}: tomorrow is not used (not 12 kWh), " + f"today is kept, the member is named", + fc is not None and fc.tomorrow is None and near(fc.today.kwh, 9.0) + and why is None + and gaps == (("tomorrow", 2, "sensor.fc_2", w),) + and fc.between(NOW, TOM + timedelta(hours=9)) is None) +fc, _w, gaps = solarfc.read_sum(members(st("5"), st("4")), [], NOW) +check("no tomorrow list: today only, nothing named", + fc.tomorrow is None and gaps == ()) + +# Hourly splits. +sol = [{"period_start": (UTC0 + timedelta(minutes=30 * i)).isoformat(), + "pv_estimate": 2.0 if 20 <= i < 36 else 0.0} for i in range(48)] +wh = {(UTC0 + timedelta(hours=h)).isoformat(): (1000.0 if 8 <= h < 16 + else 0.0) + for h in range(24)} +fc, _w, _g = solarfc.read_sum(members(st("8.0", detailedForecast=sol), + st("7.0", wh_period=wh)), [], NOW) +one_s = solarfc.read(st("8.0", detailedForecast=sol), None, NOW)[0] +one_w = solarfc.read(st("7.0", wh_period=wh), None, NOW)[0] +spans = [(NOW, END)] + [ + (datetime(2026, 9, 21, h, tzinfo=L), datetime(2026, 9, 21, h + 1, + tzinfo=L)) + for h in range(9, 20)] +check("every member has a split: the splits are merged (hour by hour, the " + "sum of each member's own split)", + fc.today.periods and near(fc.today.kwh, 15.0) + and all(near(fc.between(a, b), one_s.between(a, b) + + one_w.between(a, b)) for a, b in spans)) +check("...so 10:00-12:00 is 2 (Solcast) + 2 (Open-Meteo) = 4 kWh", + near(fc.between(datetime(2026, 9, 21, 10, tzinfo=L), + datetime(2026, 9, 21, 12, tzinfo=L)), 4.0)) +fc, _w, _g = solarfc.read_sum(members(st("8.0", detailedForecast=sol), + st("7.0")), [], NOW) +bell = solarfc.read(st("15.0"), None, NOW)[0] +check("one member without a split: the sum goes on the bell, as one " + "state-only sensor of 15 kWh would", + fc.today.periods == () and near(fc.today.kwh, 15.0) + and all(near(fc.between(a, b), bell.between(a, b)) for a, b in spans)) +wht = {(TOM + timedelta(hours=h)).isoformat(): (500.0 if 8 <= h < 16 else 0) + for h in range(24)} +fc, _w, _g = solarfc.read_sum(members(st("5")), + members(st("4", wh_period=wht), + st("4", wh_period=wht)), NOW) +check("tomorrow's splits are merged too (8 x 0.5 kWh each, 8 in all)", + fc.tomorrow.periods and near(fc.between(TOM, TOM + timedelta(days=1)), + 8.0) + and near(fc.between(TOM + timedelta(hours=8), + TOM + timedelta(hours=10)), 2.0)) +fc, _w, _g = solarfc.read_sum(members(st("5")), + members(st("4", wh_period=wht), st("4")), NOW) +check("...and fall back to the bell when one has none", + fc.tomorrow.periods == () and near(fc.tomorrow.kwh, 8.0)) + +# One member is read(). +TODAYS = [st("12.5"), st("12500", "Wh"), st("0.0125", "MWh"), st("12.5", "W"), + st("12.5", None), st("unavailable"), st("unknown"), None, st("-1"), + st("lots"), st("12.5", at=OLD), st("12.5", at=NOW + timedelta( + hours=1)), st("8.0", detailedForecast=sol), + st("7.0", wh_period=wh), st("0")] +TOMS = [None, st("20"), st("20", at=OLD), st("unavailable"), st("20", "W"), + st("4", wh_period=wht)] +WHENS = [NOW, MID, datetime(2026, 9, 21, 19, 30, tzinfo=L)] +same, n = True, 0 +for now in WHENS: + for a in TODAYS: + for b in TOMS: + n += 1 + one = solarfc.read(a, b, now) + got = solarfc.read_sum([("sensor.f", a)], + [("sensor.t", b)] if b is not None + else [], now) + same = same and got[:2] == one +check(f"one member per day gives exactly what read() gives ({n} cases: " + f"units, refusals, stale, splits, tomorrow)", same and n == 270) + +# ============================================================================ # +print("2. entities") +E = solarfc.entities +check("the single key alone", E({"pv_forecast_entity": "sensor.a"}) + == ("sensor.a",)) +check("the list alone, in order", E({"pv_forecast_entities": [ + "sensor.b", "sensor.a"]}) == ("sensor.b", "sensor.a")) +check("a list with an entry replaces the single key", + E({"pv_forecast_entity": "sensor.a", + "pv_forecast_entities": ["sensor.b"]}) == ("sensor.b",)) +check("an empty list or blank rows use the single key", + E({"pv_forecast_entity": "sensor.a", "pv_forecast_entities": []}) + == ("sensor.a",) + and E({"pv_forecast_entity": "sensor.a", + "pv_forecast_entities": ["", " "]}) == ("sensor.a",)) +check("a repeated id counts once (it would double that forecast)", + E({"pv_forecast_entities": ["sensor.a", "sensor.b", "sensor.a"]}) + == ("sensor.a", "sensor.b")) +check("tomorrow reads its own keys", + E({"pv_forecast_entities": ["sensor.a"], + "pv_forecast_tomorrow_entities": ["sensor.t1", "sensor.t2"]}, + tomorrow=True) == ("sensor.t1", "sensor.t2") + and E({"pv_forecast_tomorrow_entity": "sensor.t"}, tomorrow=True) + == ("sensor.t",)) +check("nothing set: ()", E({}) == () and E({}, tomorrow=True) == ()) + +# ============================================================================ # +print("3. options") +F = validate._forecast_problems +for one, many in (solarfc.TODAY_KEYS, solarfc.TOMORROW_KEYS): + p = F({one: "sensor.a", many: ["sensor.b", "sensor.c"]}) + check(f"{one} and {many} both set: refused, one plain message that " + f"says which to keep", + len(p) == 1 and "not both" in p[0] and "Keep the list" in p[0] + and one in p[0] and many in p[0] and len(p[0].split()) <= 30) + check(f"...{many} alone is fine, and so is {one} alone", + not F({many: ["sensor.b", "sensor.c"]}) and not F({one: "sensor.a"}) + and not F({one: "sensor.a", many: []}) + and not F({one: "", many: ["sensor.b"]})) + p = F({many: ["sensor.b", "number.x"]}) + check(f"...an entry in {many} that is not a sensor is refused", + len(p) == 1 and "only sensors" in p[0] and "number.x" in p[0]) + check(f"...and {many} that is not a list is refused", + F({many: "sensor.b"}) and "list of sensors" in F({many: "x"})[0]) +with open(os.path.join(HERE, "translations", "en.yaml"), + encoding="utf-8") as fh: + EN = fh.read() +for k in solarfc.TODAY_KEYS + solarfc.TOMORROW_KEYS: + name = re.search(rf"^ {k}:\n name: \"Hardware · ([^\"(]+?)" + rf"(?: \(kWh\))?\"$", EN, re.M) + check(f"the refusal names {k} by its label on the tab", + name is not None + and f"“{name.group(1)}” ({k})" in validate.S(k)) +print(" " + F({"pv_forecast_entity": "sensor.a", + "pv_forecast_entities": ["sensor.b"]})[0]) +VBASE = {"setpoint_entity": "number.goodwe_setpoint", "max_w": 2000, + "soc_entity": "sensor.soc", "batt_entity": "sensor.batt", + "meter_source": "off", "maintenance_charge_w": 2000, + "maintenance_discharge_w": 2000, "battery_modes_enabled": True} +check("the start-up check refuses both keys set for a day", + any("not both" in p for p in validate.validate_startup_config( + dict(VBASE, pv_forecast_tomorrow_entity="sensor.a", + pv_forecast_tomorrow_entities=["sensor.b"]))) + and validate.validate_startup_config( + dict(VBASE, pv_forecast_entities=["sensor.a", "sensor.b"], + pv_forecast_tomorrow_entities=["sensor.c", "sensor.d"])) + == []) +notes = [] +validate._entity_notes(notes, dict(VBASE, pv_forecast_entities=[ + "sensor.a", "sensor.b", "sensor.gone"]), + {"sensor.a": {"unit": "kWh"}, "sensor.b": {"unit": "W"}}) +check("settings notes: a listed sensor not in kWh, and one Home Assistant " + "does not have, are each named", + any("sensor.b" in n.text and "kWh" in n.text for n in notes) + and any("sensor.gone" in n.text for n in notes) + and not any("sensor.a " in n.text for n in notes)) +notes = [] +validate._entity_notes(notes, dict(VBASE, pv_forecast_tomorrow_entities=[ + "sensor.t"]), {"sensor.t": {"unit": "kWh"}}) +check("a tomorrow list without today's forecast gets the same warning as " + "the single key", any("used only with today's" in n.text + for n in notes)) +notes = [] +validate._entity_notes(notes, {"pv_forecast_entities": ["sensor.a"]}, + {"sensor.a": {"unit": "kWh"}}) +check("a list with battery modes off gets the 'unused' note", + any("battery modes on" in n.text for n in notes)) +with open(os.path.join(HERE, "config.yaml"), encoding="utf-8") as fh: + CFG = fh.read() +for k in ("pv_forecast_entities", "pv_forecast_tomorrow_entities"): + check(f"config.yaml: {k} defaults to [] (load-bearing) and is a list " + f"of str", + re.search(rf"^ {k}: \[\]$", CFG, re.M) is not None + and re.search(rf"^ {k}:\n - str$", CFG, re.M) is not None) +for k in solarfc.TODAY_KEYS[:1] + solarfc.TOMORROW_KEYS[:1]: + check(f"config.yaml: {k} is unchanged (str?, default \"\")", + re.search(rf"^ {k}: \"\"$", CFG, re.M) is not None + and re.search(rf"^ {k}: str\?$", CFG, re.M) is not None) + +# ============================================================================ # +print("4. the real controller") +A, B = "inverter-a", "inverter-b" +# Prices as in test_modes_p4: cheap 01:00-03:00, dear 19:00-20:00 local. +DAYW = [0.20] * 96 * 2 +for i in list(range(4, 12)) + list(range(100, 108)): + DAYW[i] = 0.02 +for i in list(range(76, 80)) + list(range(172, 176)): + DAYW[i] = 0.40 +WPTS = [(UTC0 + timedelta(minutes=15 * i), 900, p) + for i, p in enumerate(DAYW)] +SF_LS = {"a": (True, "solar_first", False), + "b": (True, "leftover_solar", False)} +# Each fake sensor: (share of the day's sun, has a split, status). +SENSORS = {} +FC_KWH = [1.0] +START = [UTC0] +CALLS = [] + + +async def _sensor(self, e): + CALLS.append(("start", e)) + await asyncio.sleep(0) + CALLS.append(("end", e)) + now = START[0] + timedelta(seconds=self.t) + share, split, status = SENSORS[e] + if status == "down": + return {"state": "unavailable", "attributes": {}, + "last_updated": now.isoformat()} + at = now - timedelta(hours=4) if status == "stale" else now + loc = now.astimezone(L) + d0 = loc.replace(hour=0, minute=0, second=0, microsecond=0) + if e.startswith("sensor.tom"): + d0 += timedelta(days=1) + wh = {(d0 + timedelta(hours=h)).astimezone(timezone.utc).isoformat(): + (FC_KWH[0] * 100.0 * share if 8 <= h < 18 else 0.0) + for h in range(24)} + left = sum(v for k, v in wh.items() + if datetime.fromisoformat(k) >= now) / 1000.0 + attrs = {"unit_of_measurement": "kWh"} + if split: + attrs["wh_period"] = wh + return {"state": f"{left:.6f}", "attributes": attrs, + "last_updated": at.isoformat(), "last_reported": at.isoformat()} + + +rp.Hass.state = _sensor + + +def seed(c): + if c.house_profile is not None: + hp = dayplan.HouseProfile() + t = UTC0 - timedelta(days=1) + while t < UTC0: + h = t.astimezone(L).hour + hp.add(t, 3000.0 if 17 <= h < 22 else 600.0) + t += timedelta(seconds=5) + hp.add(t, None) + c.house_profile = hp + + +def run(opts, hours=4.0, modes=True): + start = UTC0 + timedelta(hours=0.75) + START[0] = start + rows = [] + + def cb(c, h, now, pr): + sh = dict(c.allocation.shares) if c.allocation is not None and \ + c.allocation.shares else {} + rows.append((now, round(h.grid(), 6), tuple(sorted(sh.items())), + getattr(c.arb, "action", None), + tuple(sorted(c.mode_tick.roles.items())) + if c.mode_tick else (), + tuple(sorted((u, round(p.soc, 6)) + for u, p in h.plants.items())), + tuple(sorted((c.plan_targets() or {}).items())), + None if c.day_plan is None else c.day_plan.verdict)) + _st, c = rp.run("p3_ls" if modes else "modes_off", [(UTC0.timestamp() + - 10, 800.0)], + WPTS, start, 3600 * hours, check=cb, + soc0={"a": 60.0, "b": 30.0}, choice=SF_LS, + extra=dict({"cycle_cost_threshold_eur": 0.20}, **opts), + patch=seed) + return rows, c + + +def fc_rows(c): + return [r for r in c.checks() if r["id"] == "solar_forecast"] + + +def fc_events(c): + return [e for e in c.events if "solar forecast" in e] + + +SENSORS.update({ + "sensor.all": (1.0, True, "ok"), "sensor.h1": (0.5, True, "ok"), + "sensor.h2": (0.5, True, "ok"), "sensor.h2_flat": (0.5, False, "ok"), + "sensor.down": (0.5, True, "down"), "sensor.stale": (0.5, True, "stale"), + "sensor.tom_all": (1.0, True, "ok"), "sensor.tom_h1": (0.5, True, "ok"), + "sensor.tom_down": (0.5, True, "down"), "sensor.none": (1.0, True, "down"), +}) +single, c_single = run({"pv_forecast_entity": "sensor.all"}) +listed, c_listed = run({"pv_forecast_entities": ["sensor.all"]}) +plans = [r for r in single if r[6]] +check(f"the run makes a day plan with a level ({len(plans)} of " + f"{len(single)} cycles), so the forecast steers something", + plans and any(r[7] is not None for r in single)) +check("a one-member list runs exactly like the single key: every grid " + "figure, share, action, role, level and plan, cycle by cycle", + single == listed and len(single) > 2000) +check("...and says the same on the status page and in the action log", + fc_rows(c_single) == fc_rows(c_listed) == [] + and fc_events(c_single) == fc_events(c_listed)) +halves, c_halves = run({"pv_forecast_entities": ["sensor.h1", "sensor.h2"]}) +check("two members of half the sun each run like one sensor of all of it", + [r[:6] + (r[7],) for r in halves] == [r[:6] + (r[7],) for r in single] + and all(len(a[6]) == len(b[6]) and all( + k1 == k2 and abs(v1 - v2) < 1e-6 for (k1, v1), (k2, v2) + in zip(a[6], b[6])) for a, b in zip(halves, single))) +none_, c_none = run({"pv_forecast_entity": "sensor.none"}) +check("(the comparison bites: no forecast runs differently)", + [r[:6] for r in none_] != [r[:6] for r in single]) +for name, mem in (("unavailable", "sensor.down"), ("stale", "sensor.stale")): + rows, c = run({"pv_forecast_entities": ["sensor.h1", mem]}) + check(f"a member {name}: the controller runs exactly as with the single " + f"forecast unavailable (no partial sum)", rows == none_) + check(f"...the status page names it by place, in one short line", + [r["text"] for r in fc_rows(c)] == [ + "Solar forecast for today: not used, source 2 of 2 has no " + "reading"] and fc_rows(c)[0]["warn"]) + ev = fc_events(c) + check(f"...and the action log names it once in {len(single)} cycles", + len(ev) == 1 and mem in ev[0] and "today" in ev[0]) +one_down, c_one_down = run({"pv_forecast_entities": ["sensor.none"]}) +check("a one-member list that is down runs, shows and logs exactly like the " + "single key down", one_down == none_ + and fc_rows(c_one_down) == fc_rows(c_none) + and fc_events(c_one_down) == fc_events(c_none)) +# A forecast missing from the start logs nothing (the start state is +# already "missing"); that is release/1.0's behaviour, kept. +check("the single key down: no new row and no new log line", + fc_rows(c_none) == [] and fc_events(c_none) == []) +CALLS.clear() +rows, c = run({"pv_forecast_entities": ["sensor.h1", "sensor.h2"], + "pv_forecast_tomorrow_entities": ["sensor.tom_h1", + "sensor.tom_down"]}, + hours=0.2) +check("a tomorrow member down: today is used, tomorrow is not, and the row " + "says so", + c.solar_fc is not None and c.solar_fc.tomorrow is None + and [r["text"] for r in fc_rows(c)] == [ + "Solar forecast for tomorrow: not used, source 2 of 2 has no " + "reading"] + and len(fc_events(c)) == 1 and "sensor.tom_down" in fc_events(c)[0]) +first = CALLS[:8] +check("every member of both days is read in one gather (all four start " + "before any ends)", + [k for k, _e in first] == ["start"] * 4 + ["end"] * 4 + and sorted(e for _k, e in first[:4]) == sorted( + ["sensor.h1", "sensor.h2", "sensor.tom_h1", "sensor.tom_down"])) +rows, c = run({"pv_forecast_entities": ["sensor.h1"], + "pv_forecast_tomorrow_entities": ["sensor.tom_all"]}, + hours=0.2) +check("tomorrow: a one-member list is read and counted", + c.solar_fc is not None and c.solar_fc.tomorrow is not None) +rows, c = run({"pv_forecast_entities": ["sensor.h1", "sensor.h2_flat"]}, + hours=0.2) +check("a member without a split: the controller's forecast is on the bell", + c.solar_fc is not None and c.solar_fc.today.periods == ()) +CALLS.clear() +run({"pv_forecast_entities": ["sensor.h1", "sensor.h2"]}, hours=0.2, + modes=False) +check("battery modes off: no forecast member is read", CALLS == []) +check("the status row is a STYLE status row (<= 15 words, no entity id)", + all(len(t.split()) <= 15 and "sensor." not in t for t in ( + messages.row_forecast_gap("today", [2, 3], 3), + messages.row_forecast_gap("tomorrow", [1], 2)))) + +print() +if fails: + print(f"FAILED: {len(fails)}") + for f in fails: + print(" -", f) + sys.exit(1) +print("all checks passed") diff --git a/goodwe_controller/tools/supervisor_shape_probe.py b/goodwe_controller/tools/supervisor_shape_probe.py index a03026c..426d2a2 100644 --- a/goodwe_controller/tools/supervisor_shape_probe.py +++ b/goodwe_controller/tools/supervisor_shape_probe.py @@ -767,6 +767,79 @@ ok_i4, out_i4 = validate(dict(defaults, **{_mk: 24})) show("the range is unchanged: 24 is refused (`at most 23`)", (not ok_i4) and "at most 23" in str(out_i4), out_i4) +print("\n== J. OPT-17: several solar forecast entities per day ==") +# The same question as §G, for `pv_forecast_entity` / +# `pv_forecast_tomorrow_entity` (`str?` since 0.15.0). Each refusal is +# varied from the accepted baseline and asserted on the key's name. +# DECISIONS/OPT-17-several-forecast-entities.md. +FC_STORED = {"pv_forecast_entity": "sensor.energy_production_today_remaining", + "pv_forecast_tomorrow_entity": "sensor.energy_production_tomorrow"} +_fc_old = {k: v for k, v in defaults.items() + if k not in ("pv_forecast_entities", "pv_forecast_tomorrow_entities")} +_fc_base = MERGER.merge(copy.deepcopy(defaults), + dict(_fc_old, **FC_STORED)) +ok_j, out_j = validate(_fc_base) +show("a 0.17.0 blob (single forecast keys set, no list keys) validates " + "unchanged, and both new keys default to []", + ok_j and out_j.get("pv_forecast_entity") == FC_STORED["pv_forecast_entity"] + and out_j.get("pv_forecast_entities") == [] + and out_j.get("pv_forecast_tomorrow_entities") == [], + "" if ok_j else out_j) +for _k in ("pv_forecast_entity", "pv_forecast_tomorrow_entity"): + _s1 = dict(schema, **{_k: ["str"]}) + ok_j1, out_j1 = validate_with(_s1, _fc_base) + show(f"`{_k}` declared as a LIST refuses the string an install stores", + (not ok_j1) and f"Invalid list for option '{_k}'" in str(out_j1), + out_j1) + _m1 = MERGER.merge(copy.deepcopy(dict(defaults, **{_k: []})), + copy.deepcopy(FC_STORED)) + ok_j2, out_j2 = validate_with(_s1, _m1) + show("...and a `[]` default does not rescue it (list merge is override)", + _m1[_k] == FC_STORED[_k] and not ok_j2, out_j2) + _sm = dict(schema, **{_k: "match(.*)?"}) + _ok_s, _ = validate_with(_sm, _fc_base) + _ok_l, _ = validate_with(_sm, dict(_fc_base, **{_k: ["sensor.a", + "sensor.b"]})) + _ok_n, _ = validate_with(_sm, dict(_fc_base, **{_k: 417})) + _ui_m = _ui_of(_sm, _k) + show(f"...`match(.*)?` on `{_k}` takes a string AND a list, but also the " + "integer 417, and renders as a plain text box (no second row)", + _ok_s and _ok_l and _ok_n and _ui_m + and _ui_m[0].get("multiple") is not True, + f"string={_ok_s} list={_ok_l} integer-417={_ok_n} " + f"ui={json.dumps(_ui_m)}") +_two = ["sensor.fc_east", "sensor.fc_west"] +ok_j3, out_j3 = validate(MERGER.merge(copy.deepcopy(defaults), dict( + _fc_old, pv_forecast_entities=_two, + pv_forecast_tomorrow_entities=_two))) +show("⭐ SHIPPED: two-entity lists for today and tomorrow validate", + ok_j3 and out_j3.get("pv_forecast_entities") == _two + and out_j3.get("pv_forecast_tomorrow_entities") == _two, + "" if ok_j3 else out_j3) +for _k in ("pv_forecast_entities", "pv_forecast_tomorrow_entities"): + ok_j4, out_j4 = validate({k: v for k, v in defaults.items() if k != _k}) + show(f"...and the `options:` block with ONLY `{_k}` removed is refused " + "by name, so its `[]` default is load-bearing", + (not ok_j4) and f"Missing option '{_k}' in root" in str(out_j4), + out_j4) + _ui_j = _ui_of(schema, _k) + show(f"...and `{_k}` renders as a repeatable string field", + len(_ui_j) == 1 and _ui_j[0].get("multiple") is True + and _ui_j[0].get("type") == "string", json.dumps(_ui_j)) +_flat_j = {k: v for k, v in schema.items() + if k not in ("pv_forecast_entities", "pv_forecast_tomorrow_entities")} +try: + _rj = vol.Schema(vol.All(dict, AppOptions( + None, _flat_j, cfg["name"], "local_goodwe_controller")))( + MERGER.merge(copy.deepcopy(defaults), dict( + FC_STORED, pv_forecast_entities=_two))) + show("a DOWNGRADE to 0.17.0's schema drops the list keys and keeps the " + "single ones", + "pv_forecast_entities" not in _rj + and _rj.get("pv_forecast_entity") == FC_STORED["pv_forecast_entity"]) +except vol.Invalid as e: + show("a DOWNGRADE to 0.17.0's schema drops the list keys", False, str(e)) + # ⚠️ THE EXIT CODE IS PART OF THE INSTRUMENT. Without it the probe prints FAIL # and still succeeds, and a transcript is only evidence if a wrong answer is # distinguishable from a right one by something other than reading it. diff --git a/goodwe_controller/translations/en.yaml b/goodwe_controller/translations/en.yaml index c66de3c..2db6dd1 100644 --- a/goodwe_controller/translations/en.yaml +++ b/goodwe_controller/translations/en.yaml @@ -91,6 +91,12 @@ configuration: pv_forecast_tomorrow_entity: name: "Hardware · Solar forecast, tomorrow (kWh)" description: "Optional. The same forecast for tomorrow. It helps plan a cheap hour before midnight." + pv_forecast_entities: + name: "Hardware · Several solar forecasts, today (kWh)" + description: "Optional. One row per forecast sensor, such as one per inverter, used instead of the single one above. They are added up." + pv_forecast_tomorrow_entities: + name: "Hardware · Several solar forecasts, tomorrow (kWh)" + description: "Optional. The same list for tomorrow. If one sensor has no reading, that day's forecast is not used." solar_trust_pct: name: "Hardware · Trust in the forecast (%)" description: "Forecasts are often too sunny. At 70 % the add-on plans with 7 of every 10 kWh forecast." diff --git a/goodwe_controller/translations/nl.yaml b/goodwe_controller/translations/nl.yaml index 288e168..b48c735 100644 --- a/goodwe_controller/translations/nl.yaml +++ b/goodwe_controller/translations/nl.yaml @@ -72,6 +72,12 @@ configuration: pv_forecast_tomorrow_entity: name: "Hardware · Zonneprognose, morgen (kWh)" description: "Optioneel. Dezelfde prognose voor morgen. Ze helpt een goedkoop uur voor middernacht te plannen." + pv_forecast_entities: + name: "Hardware · Meerdere zonneprognoses, vandaag (kWh)" + description: "Optioneel. Eén rij per prognosesensor, bijvoorbeeld per omvormer, in plaats van de losse sensor hierboven. Ze worden opgeteld." + pv_forecast_tomorrow_entities: + name: "Hardware · Meerdere zonneprognoses, morgen (kWh)" + description: "Optioneel. Dezelfde lijst voor morgen. Heeft één sensor geen meting, dan wordt de prognose voor die dag niet gebruikt." solar_trust_pct: name: "Hardware · Vertrouwen in de prognose (%)" description: "Prognoses zijn vaak te zonnig. Bij 70 % plant de add-on met 7 van elke 10 kWh uit de prognose."