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:
@@ -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 == "":
|
||||
|
||||
Reference in New Issue
Block a user