With A on Leftover solar and B on Cheap hours (the dynamic scenario, and the owner's planned setup), the line said "The batteries cannot hold it all. The grid covers N kWh", while B holds a full charge for the same hours. It now says "The expensive hours need more than Leftover solar can hold. The rest, N kWh, comes from another battery or the grid." (NL likewise). DOCS, DECISIONS and the dayplan/homeview docstrings say the same. Text only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
317 lines
13 KiB
Python
317 lines
13 KiB
Python
"""OPT-11 P4: the day plan. Will the sun fill each battery before the
|
|
expensive hours? If not, how much should it take from the grid?
|
|
|
|
Pure: the forecast (`solarfc`), the house profile, the price runs and the
|
|
batteries in; a level per battery (`Plan.targets`) out. `modetrade.charge_to()`
|
|
turns the level into permission to charge from the grid; main.py rebuilds the
|
|
plan with the price runs (at most every 15 minutes) and keeps a level unless
|
|
it moves by `HYST_KWH` or `HYST_FRAC` (`hold()`).
|
|
|
|
The sums (OPT-11-design section 1, "Excess solar", with the owner's answers;
|
|
OPT-28 for the second):
|
|
* `sun`: trust x the solar the house will not use, between now and the
|
|
expensive run the cheap run is for, x the charge efficiency:
|
|
`trust x sum_h max(0, forecast_h - house_h) x EFF`.
|
|
* Solar first batteries take it first, up to the ceiling. What is left is
|
|
shared by the Leftover solar batteries by their room.
|
|
* `short` (OPT-28): what the house draws in the expensive run that the sun
|
|
will not give, `sum_h max(0, house_h - trust x forecast_h)` over every
|
|
hour of the run, early morning and evening. The Leftover solar batteries
|
|
hold it together: `short / EFF` above the floor, shared by capacity, and
|
|
a battery that reaches the ceiling passes the rest to the others. What
|
|
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
|
|
(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:
|
|
`min(ceiling, floor + its part / EFF) - share`. So it buys only the
|
|
shortfall, not up to the ceiling.
|
|
* Solar first never charges from the grid (owner, 2026-09-29).
|
|
* A state-only forecast is read from now for every span (`Forecast.between`
|
|
`since=`, OPT-23), so the hours add up to the day's figure.
|
|
DECISIONS/OPT-23-28-the-night-charge-covers-the-dear-hours.md has the record.
|
|
|
|
The house profile is the add-on's own measurement, grid + battery power, per
|
|
local hour, over the last `PROFILE_DAYS` days; the estimate for an hour is the
|
|
median of that hour. It is net of the solar the house used itself, so for
|
|
the solar sum it is never taken below the night-time median (`base()`).
|
|
The site's own solar readings are not used: TEL-11 keeps them out of every
|
|
control decision.
|
|
"""
|
|
|
|
import statistics
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from .packmode import LEFTOVER_SOLAR, SOLAR_FIRST
|
|
from .tariff import BILLING_TZ
|
|
|
|
EFF = 0.95 # charge (and discharge) efficiency
|
|
HYST_KWH = 1.0 # a kept level moves only by this much...
|
|
HYST_FRAC = 0.10 # ...or this share of the battery's shortfall
|
|
PROFILE_DAYS = 7
|
|
MIN_HOURS = 20 # hours of a day recorded before it counts
|
|
NIGHT_HOURS = (0, 1, 2, 3, 4, 5, 22, 23)
|
|
DEFAULT_TRUST_PCT = 70
|
|
|
|
# Site verdicts (the page's forecast line).
|
|
SUNNY, PARTLY, DARK = "sunny", "partly", "dark"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# The house profile
|
|
# --------------------------------------------------------------------------- #
|
|
def _local(t: datetime):
|
|
return t.astimezone(BILLING_TZ)
|
|
|
|
|
|
class HouseProfile:
|
|
"""Hourly means of the house's net use (grid + battery power), kept per
|
|
local day and hour: `{"YYYY-MM-DD": {hour: [mean_w, samples]}}`. The
|
|
store holds `dump()`; `load()` takes it back and drops what is bad."""
|
|
|
|
def __init__(self, data=None):
|
|
self.days = {}
|
|
self._cur = None # (day, hour, sum, n)
|
|
if isinstance(data, dict):
|
|
self.load(data)
|
|
|
|
def load(self, data):
|
|
for day, hours in data.items():
|
|
try:
|
|
datetime.strptime(str(day), "%Y-%m-%d")
|
|
except ValueError:
|
|
continue
|
|
if not isinstance(hours, dict):
|
|
continue
|
|
row = {}
|
|
for h, v in hours.items():
|
|
try:
|
|
hi, w = int(h), float(v)
|
|
except (TypeError, ValueError):
|
|
continue
|
|
if 0 <= hi < 24 and abs(w) < 1e6:
|
|
row[hi] = w
|
|
if row:
|
|
self.days[str(day)] = row
|
|
|
|
def dump(self) -> dict:
|
|
return {d: {str(h): round(w, 1) for h, w in sorted(r.items())}
|
|
for d, r in sorted(self.days.items())}
|
|
|
|
def add(self, now: datetime, house_w) -> bool:
|
|
"""One reading. Returns True when an hour closed (then store it)."""
|
|
lt = _local(now)
|
|
key = (lt.strftime("%Y-%m-%d"), lt.hour)
|
|
closed = False
|
|
if self._cur is not None and self._cur[:2] != key:
|
|
day, hour, s, n = self._cur
|
|
if n >= 60: # about 5 minutes of readings
|
|
self.days.setdefault(day, {})[hour] = s / n
|
|
closed = True
|
|
self._cur = None
|
|
self._prune(lt)
|
|
if house_w is None:
|
|
return closed
|
|
if self._cur is None:
|
|
self._cur = (key[0], key[1], 0.0, 0)
|
|
d, h, s, n = self._cur
|
|
self._cur = (d, h, s + float(house_w), n + 1)
|
|
return closed
|
|
|
|
def _prune(self, lt):
|
|
"""Keep the last `PROFILE_DAYS` days; a day after today (the clock
|
|
was wrong once) would never age out, so it goes too."""
|
|
keep = (lt.date() - timedelta(days=PROFILE_DAYS)).isoformat()
|
|
today = lt.date().isoformat()
|
|
for d in [d for d in self.days if d < keep or d > today]:
|
|
del self.days[d]
|
|
|
|
def ready(self) -> bool:
|
|
"""At least one day with `MIN_HOURS` hours recorded."""
|
|
return any(len(r) >= MIN_HOURS for r in self.days.values())
|
|
|
|
def hour_w(self, hour: int):
|
|
vals = [r[hour] for r in self.days.values() if hour in r]
|
|
return statistics.median(vals) if vals else None
|
|
|
|
def base(self):
|
|
vals = [w for h in NIGHT_HOURS if (w := self.hour_w(h)) is not None]
|
|
return statistics.median(vals) if vals else None
|
|
|
|
def estimator(self):
|
|
"""`f(hour) -> W`, or None when not ready. Fixed at call time."""
|
|
if not self.ready():
|
|
return None
|
|
base = self.base() or 0.0
|
|
table = {}
|
|
for h in range(24):
|
|
w = self.hour_w(h)
|
|
table[h] = base if w is None else w
|
|
return lambda h: table[h], max(0.0, base)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# The plan
|
|
# --------------------------------------------------------------------------- #
|
|
@dataclass(frozen=True)
|
|
class Plan:
|
|
verdict: str # SUNNY / PARTLY / DARK
|
|
sun_kwh: float # solar the batteries can count on
|
|
fc_kwh: float # the forecast over the same span
|
|
until: datetime # the expensive run the plan is for
|
|
targets: dict # name -> level % (Leftover solar only)
|
|
need_kwh: dict # name -> kWh from the grid (0: none)
|
|
share_kwh: dict # name -> kWh of solar counted for it
|
|
# OPT-28: the expensive hours' shortfall, the part of it each battery
|
|
# covers, and what no battery can hold. All kWh on the house's side.
|
|
short_kwh: float = 0.0
|
|
cover_kwh: dict = field(default_factory=dict)
|
|
uncovered_kwh: float = 0.0
|
|
|
|
|
|
def _hour_slices(t0, t1):
|
|
"""Local-hour slices of [t0, t1): (start, end, local hour).
|
|
|
|
Steps in UTC, so `t` always advances (SAFETY-123, DECISIONS)."""
|
|
out, t = [], t0
|
|
while t < t1:
|
|
lt = _local(t)
|
|
left = timedelta(minutes=60 - lt.minute) - timedelta(
|
|
seconds=lt.second, microseconds=lt.microsecond)
|
|
e = min(t.astimezone(timezone.utc) + left, t1)
|
|
out.append((t, e, lt.hour))
|
|
t = e
|
|
return out
|
|
|
|
|
|
def _spread(amount, room, weight):
|
|
"""Share `amount` over the keys of `room` by `weight`, none past its
|
|
room, what one cannot take passed to the others. Returns `(taken,
|
|
left)`: what each took and what none could."""
|
|
got = {k: 0.0 for k in room}
|
|
left = float(amount)
|
|
live = {k for k, r in room.items() if r > 1e-9 and weight.get(k, 0) > 0}
|
|
while left > 1e-9 and live:
|
|
total = sum(weight[k] for k in live)
|
|
used, full = 0.0, set()
|
|
for k in sorted(live):
|
|
part = left * weight[k] / total
|
|
free = room[k] - got[k]
|
|
if part >= free - 1e-9:
|
|
part = free
|
|
full.add(k)
|
|
got[k] += part
|
|
used += part
|
|
left -= used
|
|
if not full:
|
|
break
|
|
live -= full
|
|
return got, max(0.0, left)
|
|
|
|
|
|
def _room(p, to_pct):
|
|
if p.capacity_kwh is None or p.soc is None:
|
|
return 0.0
|
|
return max(0.0, float(to_pct) - float(p.soc)) / 100.0 * float(
|
|
p.capacity_kwh)
|
|
|
|
|
|
def plan(forecast, house, runs, now, packs, *, trust_pct, floor_pct,
|
|
ceiling_pct):
|
|
"""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."""
|
|
if forecast is None or house is None or runs is None or not runs.on \
|
|
or runs.cheap is None:
|
|
return None
|
|
house_w, base_w = house
|
|
until = next((g for g in runs.give if g.start >= runs.cheap.end), None)
|
|
if until is None:
|
|
return None
|
|
trust = max(0.0, min(1.0, float(trust_pct) / 100.0))
|
|
sun = fc_sum = 0.0
|
|
for s, e, h in _hour_slices(now, until.start):
|
|
pv = forecast.between(s, e, since=now)
|
|
if pv is None:
|
|
return None # the span reaches a day with no forecast
|
|
hrs = (e - s).total_seconds() / 3600.0
|
|
use = max(house_w(h), base_w) * hrs / 1000.0
|
|
sun += max(0.0, pv - use)
|
|
fc_sum += pv
|
|
sun *= trust * EFF
|
|
|
|
usable = [p for p in packs if p.use and p.live and p.idle
|
|
and p.soc is not None and p.capacity_kwh]
|
|
sf = [p for p in usable if p.mode == SOLAR_FIRST]
|
|
price = [p for p in usable if p.mode == LEFTOVER_SOLAR]
|
|
sf_room = sum(_room(p, ceiling_pct) for p in sf)
|
|
excess = max(0.0, sun - sf_room)
|
|
rooms = {p.name: _room(p, ceiling_pct) for p in price}
|
|
total_room = sum(rooms.values())
|
|
|
|
# OPT-28: what the house draws in the expensive runs, from the one this
|
|
# plan is for until the next cheap run (or a day), that the sun will not
|
|
# give. An hour with sun is read at no less than the night base, as the
|
|
# sun sum above reads it (the profile is net of the sun the house used);
|
|
# an hour without is read as measured. A run hour with no forecast counts
|
|
# no sun.
|
|
horizon = until.start + timedelta(hours=24)
|
|
short = 0.0
|
|
for g in runs.give:
|
|
if g.start < until.start or g.start >= horizon:
|
|
continue
|
|
for s, e, h in _hour_slices(g.start, g.end):
|
|
pv = forecast.between(s, e, since=now) or 0.0
|
|
w = max(0.0, house_w(h))
|
|
if pv > 0.0:
|
|
w = max(w, base_w)
|
|
short += max(0.0, w * (e - s).total_seconds() / 3.6e6
|
|
- 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)
|
|
|
|
targets, need, share, cover = {}, {}, {}, {}
|
|
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
|
|
top = float(floor_pct) + held[p.name] / caps[p.name] * 100.0
|
|
level = max(0.0, top - sh_pct)
|
|
targets[p.name] = round(level, 2)
|
|
need[p.name] = round(_room(p, level), 3)
|
|
share[p.name] = round(sh, 3)
|
|
cover[p.name] = round(held[p.name] * EFF, 3)
|
|
if sun < sf_room:
|
|
verdict = DARK
|
|
elif any(v > 0.05 for v in need.values()):
|
|
verdict = PARTLY
|
|
else:
|
|
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))
|
|
|
|
|
|
def hold(old: dict | None, new: dict, capacity: dict) -> dict:
|
|
"""Hysteresis: a battery keeps its old level unless the new one differs
|
|
by at least `HYST_KWH`, or by `HYST_FRAC` of the larger level's energy
|
|
(whichever is less). A battery new to the plan takes its level at once;
|
|
one that left the plan is dropped at once."""
|
|
out = {}
|
|
for name, level in new.items():
|
|
was = (old or {}).get(name)
|
|
cap = float(capacity.get(name) or 0.0)
|
|
if was is None or cap <= 0:
|
|
out[name] = level
|
|
continue
|
|
moved = abs(level - was) / 100.0 * cap
|
|
step = min(HYST_KWH, HYST_FRAC * max(was, level) / 100.0 * cap)
|
|
out[name] = level if moved >= step else was
|
|
return out
|