OPT-17: the solar forecast can come from several entities, summed
checks / unit (pull_request) Successful in 36m13s
infra-pin / fresh (pull_request) Successful in 1s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
This commit is contained in:
glenn schrooyen
2026-10-11 02:55:58 +02:00
co-authored by Claude Opus 5.5
parent e36565fe39
commit 9b1fa758ad
13 changed files with 878 additions and 48 deletions
+29 -11
View File
@@ -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/<id> 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