OPT-36: Leftover solar counts what Cheap hours and Solar first batteries will give before buying
Live 0.20.0: A (Leftover solar, 60 %) charged to 95 % while B (Cheap hours, 94 %) already covered the expensive hours. dayplan.plan() now takes off the shortfall what Cheap hours batteries will hold when the hours start (the ceiling or what their cheap run reaches under the peak aim) and what Solar first batteries will still hold after the night's use. Home reads a give block after the planned charge, plan_cover() is the plan's own figure, and the Why line says other batteries cover. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude
parent
01e88a24f2
commit
05746c0564
@@ -22,7 +22,10 @@ OPT-28 for the second):
|
||||
all of them cannot hold (`Plan.uncovered_kwh`) is not bought: another
|
||||
battery (Cheap hours, Solar first) or the grid carries it.
|
||||
* Cheap hours only ignores the plan and fills to the ceiling, as in P3 and
|
||||
the design (section 1). Skipping the sun's share cost money in the replay
|
||||
the design (section 1). OPT-36: what it, and a Solar first battery after
|
||||
the night's use, will give in the expensive hours is taken off `short`
|
||||
before the Leftover solar batteries share it (`_cheap_give_kwh()`,
|
||||
`_solar_first_give_kwh()`). Skipping the sun's share cost money in the replay
|
||||
(PR #156 review: 29.72 against 29.41 EUR on real 2026 prices).
|
||||
* A Leftover solar battery charges from the grid up to the level it needs
|
||||
for its part of `short`, less its share of the sun before the run:
|
||||
@@ -45,7 +48,7 @@ import statistics
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from .packmode import LEFTOVER_SOLAR, SOLAR_FIRST
|
||||
from .packmode import CHEAP_HOURS, LEFTOVER_SOLAR, SOLAR_FIRST
|
||||
from .tariff import BILLING_TZ
|
||||
|
||||
EFF = 0.95 # charge (and discharge) efficiency
|
||||
@@ -170,6 +173,14 @@ class Plan:
|
||||
short_kwh: float = 0.0
|
||||
cover_kwh: dict = field(default_factory=dict)
|
||||
uncovered_kwh: float = 0.0
|
||||
# OPT-36: what Cheap hours batteries will give in those hours (kWh on the
|
||||
# house's side), and, per Leftover solar battery, the grid kWh it buys
|
||||
# less because of it.
|
||||
cheap_kwh: float = 0.0
|
||||
saved_kwh: dict = field(default_factory=dict)
|
||||
# ...and the same for Solar first batteries (owner, 2026-11-02): their
|
||||
# level when the expensive hours start, after the night's use.
|
||||
solar_first_kwh: float = 0.0
|
||||
|
||||
|
||||
def _hour_slices(t0, t1):
|
||||
@@ -219,8 +230,66 @@ def _room(p, to_pct):
|
||||
p.capacity_kwh)
|
||||
|
||||
|
||||
def _cheap_give_kwh(runs, now, house, packs, *, floor_pct, ceiling_pct,
|
||||
aim_w):
|
||||
"""OPT-36: what the Cheap hours batteries in `packs` give in the
|
||||
expensive hours, in kWh on the house's side: (the level at the start of
|
||||
those hours - the floor) x capacity x `EFF`.
|
||||
|
||||
That level is the one its own cheap run reaches: the ceiling, or less when
|
||||
the run cannot get there. From now to the run's end (or the whole run
|
||||
ahead of it) the batteries together charge at their ratings, held to what
|
||||
the peak aim leaves above the house's use that hour (`aim_w`, None: no
|
||||
limit), shared by rating. No cheap run worth charging in: the level now.
|
||||
A battery that refuses a direction counts 0."""
|
||||
ps = [p for p in packs if p.mode == CHEAP_HOURS and p.refused == 0]
|
||||
if not ps:
|
||||
return 0.0
|
||||
house_w, base_w = house
|
||||
gain = {p.name: 0.0 for p in ps}
|
||||
rate = {p.name: abs(float(p.rating_w)) for p in ps}
|
||||
total = sum(rate.values())
|
||||
if runs.charge_useful() and total > 0.0:
|
||||
for s, e, h in _hour_slices(max(now, runs.cheap.start),
|
||||
runs.cheap.end):
|
||||
w = total
|
||||
if aim_w is not None:
|
||||
w = min(w, max(0.0, float(aim_w)
|
||||
- max(house_w(h), base_w, 0.0)))
|
||||
hrs = (e - s).total_seconds() / 3600.0
|
||||
for p in ps:
|
||||
gain[p.name] += w * rate[p.name] / total * hrs / 1000.0
|
||||
out = 0.0
|
||||
for p in ps:
|
||||
cap = float(p.capacity_kwh)
|
||||
lvl = max(float(p.soc), min(float(ceiling_pct),
|
||||
float(p.soc) + gain[p.name] / cap * 100.0))
|
||||
out += max(0.0, lvl - float(floor_pct)) / 100.0 * cap * EFF
|
||||
return out
|
||||
|
||||
|
||||
def _solar_first_give_kwh(house, now, start, packs, *, floor_pct):
|
||||
"""OPT-36 (owner, 2026-11-02): what the Solar first batteries in `packs`
|
||||
will still give when the expensive hours start at `start`, in kWh on the
|
||||
house's side. They give at any hour, so the house's use from now to
|
||||
`start` (the plan's own profile) is taken off first, shared by the energy
|
||||
each holds above the floor, and what is left x `EFF` counts. A battery
|
||||
that runs down to the floor before then counts 0; one that refuses a
|
||||
direction counts 0."""
|
||||
ps = [p for p in packs if p.mode == SOLAR_FIRST and p.refused == 0]
|
||||
if not ps:
|
||||
return 0.0
|
||||
house_w, _base = house
|
||||
use = sum(max(0.0, house_w(h)) * (e - s).total_seconds() / 3.6e6
|
||||
for s, e, h in _hour_slices(now, start))
|
||||
have = {p.name: max(0.0, float(p.soc) - float(floor_pct)) / 100.0
|
||||
* float(p.capacity_kwh) for p in ps}
|
||||
took, _ = _spread(use / EFF, have, have)
|
||||
return sum(have[n] - took[n] for n in have) * EFF
|
||||
|
||||
|
||||
def plan(forecast, house, runs, now, packs, *, trust_pct, floor_pct,
|
||||
ceiling_pct):
|
||||
ceiling_pct, aim_w=None):
|
||||
"""The day plan, or None when there is none to make: no forecast, no
|
||||
house profile yet, no price plan, or no expensive run after the cheap
|
||||
run. `house` is `HouseProfile.estimator()`'s answer. `packs` are
|
||||
@@ -273,11 +342,22 @@ def plan(forecast, house, runs, now, packs, *, trust_pct, floor_pct,
|
||||
- trust * pv)
|
||||
caps = {p.name: float(p.capacity_kwh) for p in price}
|
||||
span = max(0.0, float(ceiling_pct) - float(floor_pct))
|
||||
held, left = _spread(short / EFF,
|
||||
{n: span / 100.0 * c for n, c in caps.items()},
|
||||
caps)
|
||||
# OPT-36: the Cheap hours and Solar first batteries give first;
|
||||
# Leftover solar holds the rest. A Leftover solar battery's own stored energy counts through its
|
||||
# level: it buys from where it is to the level it needs.
|
||||
cheap = _cheap_give_kwh(runs, now, house, usable, floor_pct=floor_pct,
|
||||
ceiling_pct=ceiling_pct, aim_w=aim_w) \
|
||||
if price else 0.0
|
||||
cheap = min(cheap, short)
|
||||
sfirst = _solar_first_give_kwh(house, now, until.start, usable,
|
||||
floor_pct=floor_pct) if price else 0.0
|
||||
sfirst = min(sfirst, short - cheap)
|
||||
others = cheap + sfirst
|
||||
room_of = {n: span / 100.0 * c for n, c in caps.items()}
|
||||
held, left = _spread((short - others) / EFF, room_of, caps)
|
||||
held0 = _spread(short / EFF, room_of, caps)[0] if others > 0.0 else held
|
||||
|
||||
targets, need, share, cover = {}, {}, {}, {}
|
||||
targets, need, share, cover, saved = {}, {}, {}, {}, {}
|
||||
for p in price:
|
||||
sh = excess * rooms[p.name] / total_room if total_room > 0 else 0.0
|
||||
sh_pct = sh / caps[p.name] * 100.0
|
||||
@@ -285,6 +365,9 @@ def plan(forecast, house, runs, now, packs, *, trust_pct, floor_pct,
|
||||
level = max(0.0, top - sh_pct)
|
||||
targets[p.name] = round(level, 2)
|
||||
need[p.name] = round(_room(p, level), 3)
|
||||
top0 = float(floor_pct) + held0[p.name] / caps[p.name] * 100.0
|
||||
saved[p.name] = round(
|
||||
_room(p, max(0.0, top0 - sh_pct)) - need[p.name], 3)
|
||||
share[p.name] = round(sh, 3)
|
||||
cover[p.name] = round(held[p.name] * EFF, 3)
|
||||
if sun < sf_room:
|
||||
@@ -295,7 +378,8 @@ def plan(forecast, house, runs, now, packs, *, trust_pct, floor_pct,
|
||||
verdict = SUNNY
|
||||
return Plan(verdict, round(sun, 3), round(fc_sum, 3), until.start,
|
||||
targets, need, share, round(short, 3), cover,
|
||||
round(left * EFF if price else 0.0, 3))
|
||||
round(left * EFF if price else 0.0, 3), round(cheap, 3),
|
||||
saved, round(sfirst, 3))
|
||||
|
||||
|
||||
def hold(old: dict | None, new: dict, capacity: dict) -> dict:
|
||||
|
||||
@@ -444,9 +444,16 @@ def mode_runs(c, day0, day1, now, shadow, ahead=False, rp=None):
|
||||
return got if got is not None else (
|
||||
floor if kind == "grid" else ceiling)
|
||||
|
||||
grids = [] # (end, kWh) of the grid blocks drawn
|
||||
|
||||
def room(kind, hrs, start):
|
||||
kwh = inv.max_w / 1000.0 * hrs
|
||||
lvl = start_pct(kind, start) if ahead else soc
|
||||
if kind != "grid" and not ahead and cap and lvl is not None:
|
||||
# OPT-36: a give block reads the level after the grid block
|
||||
# before it, not the level now.
|
||||
lvl = min(float(ceiling), lvl + sum(
|
||||
k for e, k in grids if e <= start) / cap * 100.0)
|
||||
if cap and lvl is not None:
|
||||
kwh = min(kwh, max(0.0, (ceiling - lvl) if kind == "grid"
|
||||
else (lvl - floor)) / 100.0 * cap)
|
||||
@@ -474,6 +481,8 @@ def mode_runs(c, day0, day1, now, shadow, ahead=False, rp=None):
|
||||
kwh = min(kwh, need)
|
||||
if kwh < MIN_KWH:
|
||||
continue
|
||||
if kind == "grid":
|
||||
grids.append((run.end, kwh))
|
||||
price = run.mean_eur_kwh
|
||||
if kind == "grid":
|
||||
cov = plan_cover(c, inv.name)
|
||||
@@ -522,17 +531,16 @@ def plan_cover(c, name):
|
||||
grid: the level it charges to, and the expensive-hour use of the house
|
||||
that charge covers and the sun will not. Read from the level control
|
||||
holds (`plan_targets()`), so Home says what control does. None when the
|
||||
plan buys nothing for it."""
|
||||
plan buys nothing for it. OPT-36: the kWh is the plan's own cover figure
|
||||
(`Plan.cover_kwh`), not one rebuilt from the level."""
|
||||
dp = getattr(c, "day_plan", None)
|
||||
targets = c.plan_targets() if hasattr(c, "plan_targets") else None
|
||||
inv = next((i for i in c.inverters if i.name == name), None)
|
||||
if dp is None or not targets or name not in targets or inv is None \
|
||||
or not inv.capacity_kwh or targets[name] <= 0.0:
|
||||
or not inv.capacity_kwh or targets[name] <= 0.0 \
|
||||
or name not in dp.cover_kwh:
|
||||
return None
|
||||
floor, _ceil = arbitrage.soc_bounds(c.o)
|
||||
cap = float(inv.capacity_kwh)
|
||||
top = targets[name] + dp.share_kwh.get(name, 0.0) / cap * 100.0
|
||||
return targets[name], max(0.0, top - floor) / 100.0 * cap * EFF
|
||||
return targets[name], dp.cover_kwh[name]
|
||||
|
||||
|
||||
def _latched_below(c) -> bool:
|
||||
@@ -931,7 +939,8 @@ def battery_rows(c, shadow, running, held=False):
|
||||
|
||||
def day_key(c, name):
|
||||
"""P4: the day plan's word for one Leftover solar or Cheap hours
|
||||
battery: `sun_fills`, `tops_up`, `no_forecast`, or None."""
|
||||
battery: `sun_fills`, `others_cover`, `tops_up`, `no_forecast`, or
|
||||
None."""
|
||||
st = plan_state(c)
|
||||
if st == "no_forecast":
|
||||
mode = c.pack_mode(next(i for i in c.inverters if i.name == name))
|
||||
@@ -941,7 +950,14 @@ def day_key(c, name):
|
||||
need = plan_need(c, name)
|
||||
if need is None:
|
||||
return None
|
||||
return "tops_up" if need >= MIN_KWH else "sun_fills"
|
||||
if need >= MIN_KWH:
|
||||
return "tops_up"
|
||||
# OPT-36: no grid charge because other batteries (Cheap hours only,
|
||||
# Solar first) cover the hours, not the sun.
|
||||
dp = getattr(c, "day_plan", None)
|
||||
if dp is not None and dp.saved_kwh.get(name, 0.0) >= MIN_KWH:
|
||||
return "others_cover"
|
||||
return "sun_fills"
|
||||
|
||||
|
||||
def battery_why(*, modes_on, mode, off, reading, phase, shadow, running,
|
||||
|
||||
@@ -2375,7 +2375,7 @@ class Controller:
|
||||
else self.house_profile.estimator(),
|
||||
priceruns.keep_started(self.mode_runs, runs, now), now, packs,
|
||||
trust_pct=self.solar_trust_pct, floor_pct=floor_pct,
|
||||
ceiling_pct=ceiling_pct)
|
||||
ceiling_pct=ceiling_pct, aim_w=self.peak_aim_w())
|
||||
self.day_plan = dp
|
||||
if dp is None:
|
||||
self._plan_targets = None
|
||||
|
||||
@@ -357,6 +357,7 @@
|
||||
"no_prices": "No prices, so it follows the house.",
|
||||
"no_spread": "Prices differ too little, so it follows the house.",
|
||||
"sun_fills": "Enough sun is forecast, so it does not charge from the grid.",
|
||||
"others_cover": "Other batteries cover the expensive hours. It does not charge from the grid.",
|
||||
"tops_up": "It charges from the grid to {pct}. That covers {kwh} of expensive hours the sun will not.",
|
||||
"no_forecast": "No solar forecast, so it charges from solar only."
|
||||
},
|
||||
|
||||
@@ -297,6 +297,7 @@
|
||||
"no_prices": "Geen prijzen, dus ze volgt het huis.",
|
||||
"no_spread": "De prijzen verschillen te weinig, dus ze volgt het huis.",
|
||||
"sun_fills": "Er is genoeg zon voorspeld, dus ze laadt niet van het net.",
|
||||
"others_cover": "Andere batterijen dekken de dure uren. Ze laadt niet van het net.",
|
||||
"tops_up": "Ze laadt van het net tot {pct}. Dat dekt {kwh} dat de zon in dure uren niet levert.",
|
||||
"no_forecast": "Geen zonneprognose, dus ze laadt alleen met zonne-energie."
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user