"""UX-54: the Forecast tab, built on request (`GET forecast`). Reads only. Two days, today and tomorrow, per local hour: the solar forecast, the solar measured so far, the house's expected use, and each battery's expected level with its blocks. It follows the add-on, as Home does (UX-53), and decides nothing: * the blocks are Home's own: today's are the `runs` of `/status`'s plan, and tomorrow's come from the same builders (`homeview.runs_for()`), so a block is on the tab exactly where the plan has one, with the plan's words; * the sun is `solarfc.Forecast.between()` and the house's use is `dayplan.HouseProfile.estimator()` read as `dayplan.plan()` reads it; * the level is `daycurve.curve()`, fed the blocks, the give runs, the packs and the plan's levels (`modetrade.charge_to()`), floor and ceiling. A part that is not known yet is a plain note (`home_words.day`), never an empty chart. DECISIONS/UX-54-forecast-tab.md has the record. """ import dataclasses from datetime import datetime, timedelta from . import arbitrage, dayplan, homeview as hv, modetrade, priceruns from . import solarfc from .daycurve import Batt, curve from .packmode import SOLAR_FIRST from .price import Uncertainty, brussels, required_spread, uncertainty_of BLOCKS = ("grid", "give", "cycle") # the kinds a battery's blocks carry def _hours_between(a, b) -> float: return (b - a).total_seconds() / 3600.0 def build(c, status: dict, now: datetime, lang: str = "en") -> dict: """The tab's JSON. `status` is the `/status` payload just built, whose plan holds today's blocks.""" with hv.in_lang(lang): return _build(c, status, now) def sun_kwh(fc, now, a, b): """kWh the forecast gives in `[a, b)`, `a` at or after `now`, or None. Both ends are measured from `now`: `Forecast.between()` reads its start as the moment the "left today" figure was taken, so a span that starts later would be spread over what is left of the bell, not over its own hours (state-only forecasts, DECISIONS/UX-54-forecast-tab.md).""" hi, lo = fc.between(now, b), fc.between(now, a) return None if hi is None or lo is None else max(0.0, hi - lo) def _times(run): return (datetime.fromisoformat(run["start"]), datetime.fromisoformat(run["end"])) def _build(c, status, now): plan, rows = status.get("plan"), status.get("batteries") if not plan or rows is None: return {"ok": False} d0, d1 = hv.local_day(now) n0, n1 = hv.local_day(d1 + timedelta(hours=2)) shadow = bool(c.shadow) key = plan.get("verdict_key") running = key not in ("held", "stopped") modes_on = bool(getattr(c, "modes_on", False)) floor, ceil = arbitrage.soc_bounds(c.o) fc = getattr(c, "solar_fc", None) hp = getattr(c, "house_profile", None) est = hp.estimator() if hp is not None and fc is not None else None trust = max(0.0, min(1.0, float(getattr(c, "solar_trust_pct", 70)) / 100.0)) # Why a part is not known (None: nothing is missing). missing = ("modes_off" if not modes_on else "not_set" if c.fc_entity is None else "no_forecast" if fc is None else "learning" if est is None else None) # The blocks of each day: today's from Home, tomorrow's by its builders. # Tomorrow has not started, so the level now says nothing about it: the # builders read it with `ahead` and draw a block wherever the plan and the # gate have a run (`homeview.mode_runs()` has the room and energy filter). runs = [plan["runs"], hv.runs_for(c, n0, n1, now, shadow, ahead=True) if running else []] # The level curve of every battery that is on and known. It also makes # tomorrow's blocks again at the level each has when its run starts, and # draws a grid block as long as control keeps charging (`_levels()`). notes_b = {} levels = {} if running and missing is None: levels, runs = _levels(c, est, fc, trust, now, n1, runs, floor, ceil, notes_b, shadow, n0, n1) blocks = [[[r for r in rs if r["battery"] == i and r["kind"] in BLOCKS] for i in range(len(c.inverters))] for rs in runs] # Prices are planned about a day ahead (`priceruns.HORIZON`), so tomorrow's # blocks may be missing now and appear later. priced = bool(plan.get("has_prices")) and ( any(c.pack_mode(i).use and c.pack_mode(i).mode in hv.PRICE_MODES for i in c.inverters) if modes_on else arbitrage.enabled(c.o)) days = [] for k, (a, z) in enumerate(((d0, d1), (n0, n1))): days.append(_day(c, k, a, z, now, fc, est, trust, levels, blocks[k], rows, shadow, running, key, missing, notes_b, status, priced)) return {"ok": True, "running": running, "shadow": shadow, "now_h": round(_hours_between(d0, now), 3), "updated": None if fc is None else hv.hhmm(fc.at), "days": days} def _levels(c, est, fc, trust, now, end, runs, floor, ceil, notes_b, shadow, n0, n1): """`({name: [(t, pct), ...]}, runs)`: `daycurve.curve()` for the batteries that are on, have a level and are not in their monthly cycle, and `runs` with tomorrow's blocks made as control will meet them. Tomorrow's blocks are `homeview.mode_runs()`'s, read at the level the curve has when each block starts (its `ahead` function), so its own room and energy filter and `MIN_KWH` decide: no give block where the battery is at the floor when the run starts, no grid block where it is at its planned level. The curve in turn follows the blocks, so the two are taken to a fixed point. A cheap run is sized for all the batteries charging together, so a slower one is still below `modetrade.charge_to()` when it ends, and control then plans the next cheap slots (`Controller._price_runs()`). That is asked of the same functions, `modetrade.energy()` and `priceruns.plan()`, with the packs at the curve's level, and the block is drawn to where control stops charging. Today's blocks are Home's, as they are.""" packs = {p.name: p for p in c._mode_packs()} targets = c.plan_targets() rp = getattr(c, "mode_runs", None) plan_on = rp is not None and rp.on house_w, base_w = est def make(rs): batts = [] for i, inv in enumerate(c.inverters): pm = c.pack_mode(inv) if not pm.use: continue if inv.soc is None or not inv.capacity_kwh: notes_b[inv.name] = hv.day_note("no_level", name=inv.name) continue mine = [r for day in rs for r in day if r["battery"] == i and r["kind"] in BLOCKS] cyc = [_times(r)[0] for r in mine if r["kind"] == "cycle"] if cyc and min(cyc) <= now: notes_b[inv.name] = hv.day_note("cycle", name=inv.name) continue if cyc: notes_b[inv.name] = hv.day_note("cycle", name=inv.name) to = modetrade.charge_to(packs[inv.name], ceil, targets) grid = tuple((*_times(r), to) for r in mine if r["kind"] == "grid" and to is not None) priced = plan_on and pm.mode != SOLAR_FIRST give = tuple((g.start, g.end) for g in rp.give) if priced else () batts.append(Batt(inv.name, float(inv.capacity_kwh), inv.max_w / 1000.0, float(inv.soc), pm.mode == SOLAR_FIRST, priced, grid, give, min(cyc) if cyc else None)) return batts def sun(a, z): return sun_kwh(fc, now, a, z) def use(a, z): return sum(max(house_w(h), base_w) * _hours_between(s, e) / 1000.0 for s, e, h in dayplan._hour_slices(a, z)) def run(rs): batts = make(rs) return curve(batts, now, end, sun, use, floor_pct=floor, ceiling_pct=ceil, trust=trust) if batts else {} def settle(ext, lv): """Tomorrow's blocks read at the levels of `lv`, and the curve they make, again until the blocks stop changing.""" seen = None for _ in range(4): def at(name, _kind, start, lv=lv): return _level_at(lv.get(name, ()), start) rt = hv.runs_for(c, n0, n1, now, shadow, ahead=at, rp=ext) lv = run([runs[0], rt]) sig = [(r["battery"], r["kind"], r["start"], r["end"]) for r in rt] if sig == seen: break seen = sig return rt, lv lv0 = run([runs[0], []]) ext = rp plan = c.fresh_tariff_plan(now) cheap = None if rp is None else rp.cheap end_t = None if plan_on and cheap is not None and rp.charge_useful() \ and n0 <= cheap.start < n1 and plan is not None: required = required_spread( arbitrage.threshold_eur_kwh(c.o), uncertainty_of(plan.read.product) | {Uncertainty.NO_WEATHER}) end_t = cheap.end for _ in range(24): ext = dataclasses.replace(rp, cheap=priceruns.Run( cheap.start, end_t, cheap.mean_eur_kwh)) rt, lv = settle(ext, lv0) at_end = [dataclasses.replace(packs[n], soc=v) for n, v in ((n, _level_at(lv.get(n, ()), end_t)) for n in lv) if v is not None] e = modetrade.energy(at_end, floor, ceil, targets=targets) if e[0] <= 1e-6: break new = priceruns.plan( plan.read.points, end_t, required_eur_kwh=required, charge_kwh=e[0], charge_kw=e[1], sell_kwh=0.0, sell_kw=0.0) nxt = new.cheap if nxt is None or not new.charge_useful() or nxt.start > end_t \ or nxt.end <= end_t: break end_t = nxt.end ext = dataclasses.replace(rp, cheap=priceruns.Run( cheap.start, end_t, cheap.mean_eur_kwh)) rt, lv = settle(ext, lv0) # A run is for all the batteries charging together; each stops when it is # at its own level (control's `chargers()`), so its block ends there. names = [inv.name for inv in c.inverters] rt = [_stops(r, names, lv, packs, targets, floor, ceil, n0) for r in rt] return lv, [runs[0], rt] def _stops(r, names, lv, packs, targets, floor, ceil, day0): """`r` ended where its battery stops: a grid block where the curve has it at the level `modetrade.charge_to()` allows (the curve has a point there), a give block where the curve has it at the floor, within the 0.1 % the tab shows (a step end), if that is before the run ends. A run is for all the batteries together; each leaves it on its own.""" if r["kind"] not in ("grid", "give"): return r name = names[r["battery"]] start, end = _times(r) if r["kind"] == "grid": to = modetrade.charge_to(packs[name], ceil, targets) if to is None: return r hit = lambda v: v >= to - 1e-6 # noqa: E731 else: hit = lambda v: v <= floor + 0.05 # noqa: E731 done = next((t for t, v in lv.get(name, ()) if start < t <= end and hit(v)), None) if done is None or done >= end: return r return dict(r, end=done.isoformat(), to=hv.hhmm(done), end_h=round(_hours_between(day0, done), 3)) def _level_at(pts, t): """The curve's level at `t` (between two points, straight), or None when the curve does not reach `t`.""" for (t0, v0), (t1, v1) in zip(pts, pts[1:]): if t0 <= t <= t1: span = (t1 - t0).total_seconds() return v0 if span <= 0 else v0 + (v1 - v0) * ( (t - t0).total_seconds() / span) return None def _day(c, k, d0, d1, now, fc, house, trust, levels, blocks, rows, shadow, running, key, missing, notes_b, status, priced): day = (fc.today, fc.tomorrow)[k] if fc is not None else None solar = getattr(c, "solar_today", None) names = [inv.name for inv in c.inverters] hours = [] for s, e, h in dayplan._hour_slices(d0, d1): f = None if fc is not None and e > now and (k == 0 or fc.tomorrow is not None): f = sun_kwh(fc, now, max(s, now), e) got = solar.kwh(s, e, now) if k == 0 and solar is not None \ and s < now else None use = None if house is None else max(house[0](h), house[1]) \ * _hours_between(s, e) / 1000.0 row = {"start_h": round(_hours_between(d0, s), 3), "end_h": round(_hours_between(d0, e), 3), "label": hv.hhmm(s), "forecast_kwh": None if f is None else round(f, 3), "measured_kwh": None if got is None else round(got, 3), "house_kwh": None if use is None else round(use, 3), "levels": []} hours.append((s, row)) batts = [] for i, inv in enumerate(c.inverters): row = rows[i] if i < len(rows) else {} pts = [(t, v) for t, v in levels.get(inv.name, ()) if d0 <= t <= d1] mine = blocks[i] batts.append({ "name": inv.name, "capacity_kwh": inv.capacity_kwh, "off": row.get("use") is False, "levels": [[round(_hours_between(d0, t), 3), round(v, 1)] for t, v in pts], "blocks": [{x: r[x] for x in ("kind", "from", "to", "start_h", "end_h", "what", "why", "amount")} for r in mine], "line": _line(c, inv, row, mine, running, key, shadow, d0, d1, now)}) at = {t: v for t, v in pts} for s, hr in hours: hr["levels"].append(None if s not in at else round(at[s], 1)) has_levels = any(b["levels"] for b in batts) fcs = [h["forecast_kwh"] for _s, h in hours] notes = [] if missing is not None: notes.append(hv.day_note(missing)) if not running: notes.append(hv.why_words(key)) if shadow: notes.append(hv.why_words("shadow")) line = "" if fc is not None: if k == 0: line = hv.line_solar(fc.today.kwh, fc.today.kwh * trust, hv.hhmm(fc.at)) elif fc.tomorrow is not None: line = hv.day_note("tomorrow_total", kwh=hv._kwh(fc.tomorrow.kwh), count=hv._kwh(fc.tomorrow.kwh * trust)) if day is not None and not day.periods: notes.append(hv.day_note("estimate")) if k == 1 and fc.tomorrow is None: notes.append(hv.day_note("no_tomorrow", at=_sun_from())) if k == 0 and solar is not None and solar.first is not None \ and solar.day == brussels(now).date() \ and _hours_between(d0, solar.first) > 0.5: notes.append(hv.day_note("measured_from", at=hv.hhmm(solar.first))) if has_levels: notes.append(hv.day_note("trust", pct=round(trust * 100))) if k == 1 and _tomorrow_line(status): notes.append(_tomorrow_line(status)) if k == 1 and running and priced: notes.append(hv.day_note("ahead")) notes += [notes_b[n] for n in names if n in notes_b] return {"key": ("today", "tomorrow")[k], "date": brussels(d0).date() .isoformat(), "length_h": int(round(_hours_between(d0, d1))), "line": line, "notes": list(dict.fromkeys(notes)), "sun": {"forecast": any(x is not None for x in fcs), "estimate": day is not None and not day.periods, "measured": any(h["measured_kwh"] is not None for _s, h in hours), "house": house is not None}, "hours": [h for _s, h in hours], "batteries": batts} def _sun_from() -> str: """When the sun of a day starts for `solarfc` (its bell's first hour): with no forecast for tomorrow the sun is unknown from then, and so the curve stops there (`Forecast.between()` answers None past it).""" return f"{int(solarfc.BELL_FROM_H):02d}:00" def _tomorrow_line(status) -> str: """Home's own line about tomorrow's prices, for the tomorrow tab.""" return (status.get("plan") or {}).get("tomorrow_line") or "" def _give_planned(c, d0, d1, now) -> bool: """Does the run plan have a give run in `[d0, d1)` not yet over?""" rp = getattr(c, "mode_runs", None) return rp is not None and rp.on and any( g.end > max(now, d0) and g.start < d1 for g in rp.give) def _line(c, inv, row, mine, running, key, shadow, d0, d1, now) -> str: """What to say for a battery with no block: the plan's own words. Home draws no give block for a battery with no energy to give now, so a give run in the plan with no block gets its own sentence.""" if not running: return hv.why_words(key) if row.get("use") is False: return hv.why_words("off") if mine: return "" if getattr(c, "modes_on", False) and row.get("mode_choice") in \ hv.PRICE_MODES: return hv.day_note("give_later" if _give_planned(c, d0, d1, now) else "no_blocks") return hv.run_house_what(shadow) + " " + hv.run_house_why(shadow)