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