Merge origin/master (0.20.1) into release/1.0
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:
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user