"""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