Files
goodwe-addon/goodwe_controller/app/dayview.py
T
glenn schrooyenandClaude b6b2d1b184
checks / unit (pull_request) Successful in 48m29s
infra-pin / fresh (pull_request) Successful in 1s
UX-54: a Forecast tab with the sun, the house and each battery's level, per hour
A new tab beside Home and Batteries for today and tomorrow: the solar
forecast, the solar measured so far, the house's expected use and each
battery's expected level, with the plan's own charge and give blocks under it.

- Blocks are Home's: today's are the plan's `runs`, tomorrow's come from the
  same builders (`homeview.runs_for()`), in the plan lines' words.
- The sun is what solarfc reads; a daily total is spread over the bell and the
  tab says so. The house is HouseProfile.estimator(), as the plan reads it.
- The level is new: `daycurve.curve()` gives each battery the role
  `modetrade.decide()` gives it and moves it hour by hour inside the plan's
  floor and ceiling. It is an estimate and counts on `solar_trust_pct`.
- Built on request (`GET forecast`), no Home Assistant call. Measured solar is
  kept per hour in memory from the reading control already takes.
- A part not known yet is a note: modes off, no forecast, learning, no
  forecast for tomorrow, a monthly cycle, no level, stopped, shadow.

No decision, command, timing or option key changes. Found, not changed:
`dayplan.plan()` over-counts a state-only forecast (DECISIONS/UX-54).

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-26 19:38:59 +01:00

252 lines
11 KiB
Python

"""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.
"""
from datetime import datetime, timedelta
from . import arbitrage, dayplan, homeview as hv, modetrade
from .daycurve import Batt, curve
from .packmode import SOLAR_FIRST
from .price import brussels
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.
runs = [plan["runs"], hv.runs_for(c, n0, n1, now, shadow) if running
else []]
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]
# The level curve of every battery that is on and known.
notes_b = {}
levels = {}
if running and missing is None:
levels = _levels(c, est, fc, trust, now, n1, runs, floor, ceil,
notes_b)
# 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):
"""`{name: [(t, pct), ...]}` from `daycurve.curve()` for the batteries
that are on, have a level and are not in their monthly cycle."""
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
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 rs in runs for r in rs
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))
def sun(a, z):
kwh = sun_kwh(fc, now, a, z)
return None if kwh is None else kwh * trust
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))
if not batts:
return {}
return curve(batts, now, end, sun, use, floor_pct=floor, ceiling_pct=ceil)
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)})
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"))
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 _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 _line(c, inv, row, mine, running, key, shadow) -> str:
"""What to say for a battery with no block: the plan's own words."""
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("no_blocks")
return hv.run_house_what(shadow) + " " + hv.run_house_why(shadow)