Merge origin/master (0.20.1) into release/1.0
checks / unit (pull_request) Successful in 32m4s
infra-pin / fresh (pull_request) Successful in 2s

Brings OPT-36 (#229) and TEST-37 (#231) onto release/1.0, which keeps
SAFETY-128 (#228). No textual conflicts. CHANGELOG: the SAFETY-128 entry
moved out of the released 0.20.0 section into "Not in a release yet" above
## 0.20.1; the 0.20.0 and 0.20.1 sections are identical to master.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-11-05 18:34:34 +01:00
co-authored by Claude
15 changed files with 1292 additions and 69 deletions
+124 -9
View File
@@ -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,12 +230,100 @@ def _room(p, to_pct):
p.capacity_kwh)
def _cheap_give_kwh(runs, now, house, packs, *, floor_pct, ceiling_pct,
aim_w, until=None, slots=None, spread=None):
"""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 cheap charging reaches: the ceiling, or less
when the time left cannot get there. That time is the cheap run from now
(or the whole run ahead of it) and, given the firm price `slots`, each
later slot before the expensive hours (`until`, a give run) still worth
charging in (its price plus the spread control asks, `spread`, at most
the give run's dearest slot, `priceruns.plan()`'s test) and no dearer
than every slot from the start of those hours on: control plans the
charge left in the cheapest window ahead, so a slot with a cheaper one
after it may never be used (round 4, a three-price night). The run is
sized at the batteries' ratings (`priceruns.need_hours()`), not the peak
limit, so a run too short to fill them is planned again in that time;
counting the run alone let A's level fall in the night after A had
bought. In that time 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; x `EFF` of it is stored.
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())
run = runs.cheap
spans = [(max(now, run.start), run.end)]
top = max((eur for s, _e, eur in slots or ()
if until is not None and until.start <= s < until.end),
default=None)
low = min((eur for s, _e, eur in slots or ()
if until is not None and s >= until.start), default=None)
if top is not None and spread is not None:
spans += [(max(s, now, run.end), min(e, until.start))
for s, e, eur in slots
if eur + float(spread) <= top + 1e-9
and (low is None or eur <= low + 1e-9)
and e > run.end and s < until.start]
if runs.charge_useful() and total > 0.0:
for t0, t1 in spans:
for s, e, h in _hour_slices(t0, t1):
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 * EFF
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` is taken off first, shared by the energy each holds above the
floor, and what is left x `EFF` counts. Each hour is read at no less than
the night base: the profile is net of the sun the house used, and read
net it let a dark day's morning drain a battery that the plan still
counted (round 4, real-price replays). 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_w = house
use = sum(max(0.0, house_w(h), base_w) * (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, slots=None, spread=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
`modetrade.Pack`s."""
`modetrade.Pack`s. `aim_w`, `slots` (`priceruns.firm_slots()`) and
`spread` (the price gap control asks, EUR/kWh) size a Cheap hours
battery's charge (`_cheap_give_kwh()`)."""
if forecast is None or house is None or runs is None or not runs.on \
or runs.cheap is None:
return None
@@ -273,11 +372,23 @@ 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,
until=until, slots=slots, spread=spread) \
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 +396,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 +409,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:
+26 -9
View File
@@ -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,
@@ -1473,7 +1489,8 @@ def line_solar(fc_kwh, count_kwh, at, kept=False) -> str:
def line_uncovered(kwh) -> str:
"""OPT-28: the Leftover solar batteries cannot hold the expensive hours'
shortfall. The rest comes from another battery or the grid."""
shortfall. The rest comes from the grid: OPT-36 counts the other
batteries first."""
return _t("solar.uncovered", kwh=_kwh(kwh))
+3 -1
View File
@@ -2378,7 +2378,9 @@ 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(),
slots=priceruns.firm_slots(plan.read.points, now),
spread=required)
self.day_plan = dp
if dp is None:
self._plan_targets = None
+2 -1
View File
@@ -357,10 +357,11 @@
"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."
},
"solar": {"line": "Solar still to come today: {kwh}, we count on {count}. Updated {at}.", "uncovered": "The expensive hours need more than Leftover solar can hold. The rest, {kwh}, comes from another battery or the grid.", "kept": "Solar forecast read {at}, kept for tonight.", "no_forecast": "No solar forecast now, so Leftover solar charges from solar only.", "latched": "No solar forecast now. The charge under way goes on to its planned level.", "learning": "The add-on is learning your house's use. The plan starts after one full day."},
"solar": {"line": "Solar still to come today: {kwh}, we count on {count}. Updated {at}.", "uncovered": "The expensive hours need more than Leftover solar can hold. The rest, {kwh}, comes from the grid.", "kept": "Solar forecast read {at}, kept for tonight.", "no_forecast": "No solar forecast now, so Leftover solar charges from solar only.", "latched": "No solar forecast now. The charge under way goes on to its planned level.", "learning": "The add-on is learning your house's use. The plan starts after one full day."},
"today": {
"not_planned": "Not planned yet.",
"no_runs": "No cheap or expensive hours left today.",
+2 -1
View File
@@ -297,10 +297,11 @@
"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."
},
"solar": {"line": "Nog verwachte zon vandaag: {kwh}, we rekenen op {count}. Bijgewerkt om {at}.", "uncovered": "De dure uren vragen meer dan Overschot zonne-energie kan opslaan. De rest, {kwh}, komt van een andere batterij of het net.", "kept": "Zonneprognose van {at}, bewaard voor vannacht.", "no_forecast": "Nu geen zonneprognose, dus Overschot zonne-energie laadt alleen met zon.", "latched": "Nu geen zonneprognose. De lopende lading gaat door tot het geplande niveau.", "learning": "De add-on leert eerst het verbruik van uw huis. Het plan start na één volle dag."},
"solar": {"line": "Nog verwachte zon vandaag: {kwh}, we rekenen op {count}. Bijgewerkt om {at}.", "uncovered": "De dure uren vragen meer dan Overschot zonne-energie kan opslaan. De rest, {kwh}, komt van het net.", "kept": "Zonneprognose van {at}, bewaard voor vannacht.", "no_forecast": "Nu geen zonneprognose, dus Overschot zonne-energie laadt alleen met zon.", "latched": "Nu geen zonneprognose. De lopende lading gaat door tot het geplande niveau.", "learning": "De add-on leert eerst het verbruik van uw huis. Het plan start na één volle dag."},
"today": {
"not_planned": "Nog niet gepland.",
"no_runs": "Vandaag geen goedkope of dure uren meer.",