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 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-09-29 19:29:32 +02:00
parent 5a4cdc273e
commit eb10537aee
24 changed files with 1984 additions and 35 deletions
+46 -1
View File
@@ -246,6 +246,10 @@ NOT READY banner already names the problem, (b) does not apply.
12. `peak_margin_w` outside 0-500 W, not whole, or not below `peak_cap_w`.
SAFETY-79; the Supervisor checks the range too, but not for sim/inject.py.
13. OPT-11 P4: `solar_trust_pct` outside 30-100 or not whole, or a solar
forecast entity that is not a `sensor.` id. A trust of 0 or 500 % would
make the day plan buy everything or nothing, which no one means.
WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe:
- `integrator_max_w` above `max_w`. DOCS.md is right that the only thing the
@@ -326,7 +330,7 @@ WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe:
import logging
from typing import NamedTuple
from . import messages, pv
from . import dayplan, messages, pv, solarfc
from .arbitrage import enabled as arbitrage_on
from .control import SLEW_RAIL_FRAC, LOOP_TICK_S, PLANT_SETTLE_S
from .custom_formula import (
@@ -1199,6 +1203,7 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None,
# --- 12. SAFETY-79: the margin the peak guard keeps under the limit -----
problems += _margin_problems(opts)
problems += _forecast_problems(opts)
# --- warnings: real, but not worth refusing over -------------------------
integ = opts.get("integrator_max_w")
@@ -1751,6 +1756,8 @@ def _entities_read(opts: dict) -> list[tuple[str, str, str]]:
add("pv_entities" if solar else "pv_entity", list(pv.entities(opts)))
for key in ("peak_forecast_entity", "price_now_entity", "price_avg_entity"):
add(key, opts.get(key))
for key in FORECAST_KEYS:
add(key, opts.get(key))
return out
@@ -1790,6 +1797,19 @@ def _entity_notes(notes, opts: dict, entities: dict, bad=()) -> None:
_advise(notes, "warn", (key,),
f"{entity} reads in {unit}, but {S(key, where)} is "
"taken as watts, so pick a sensor in W.")
if key in FORECAST_KEYS and unit not in solarfc.KWH_PER:
_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 \
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"):
_advise(notes, "warn", ("pv_forecast_tomorrow_entity",),
"Tomorrow's forecast is used only with today's, so set "
f"{S('pv_forecast_entity')} too.")
# --------------------------------------------------------------------------- #
@@ -2045,6 +2065,31 @@ def _margin_problems(opts: dict) -> list[str]:
return []
TRUST_RANGE_PCT = (30, 100)
FORECAST_KEYS = ("pv_forecast_entity", "pv_forecast_tomorrow_entity")
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."""
out = []
raw = opts.get("solar_trust_pct")
trust = _number(opts, "solar_trust_pct", dayplan.DEFAULT_TRUST_PCT)
lo, hi = TRUST_RANGE_PCT
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 e.strip().startswith("sensor."):
out.append(f"{S(key)} must be a sensor, like "
f"sensor.energy_production_today_remaining. It is "
f"{messages.shown(e)}.")
return out
def _number(opts: dict, key, default):
raw = opts.get(key, default)
if raw is None or raw == "":