Modes off has no curve (no forecast is read, no house profile kept), so dayview._levels() never ran there and tomorrow's legs were drawn from the bound. homeview.trade_runs(ahead=f) now reads each leg at f(name, kind, start), as mode_runs() does: no give while the emptiest battery is at the floor, each share sized from that level. dayview._counted() gives a give the level by Home's own count (the level now, plus what the blocks before it put in, less what they take out); a charge is read from the floor, because a full battery covers the house before the cheap hour. The battery line says a give is planned when one is and the battery has no give block, also with modes off. test_ux54.py: the modes-on walk no longer relabels a GIVE role at the floor; it asks control's own discharge envelope (packshare.envelope()) with each pack's cut-out at the floor. The peak-guard 20 % case asserts the curve is at the floor at the 17:00 give start. A modes-off walk on a cloudy day (arbitrage.decide() with the cycle's inputs, the packs moved by what control does and by the house), cases A-D: control charges and gives in exactly the quarters the tab has a block. trade_runs(ahead=f) unit check. _stops() ends a give block where the level the tab shows is at the floor (LEVEL_DP). No decision, command, timing or option key changes. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
449 lines
20 KiB
Python
449 lines
20 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.
|
|
"""
|
|
|
|
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
|
|
LEVEL_DP = 1 # the tab shows a level to 0.1 %
|
|
|
|
|
|
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 each block at the level it starts at (`ahead`), with
|
|
# their own room and energy filter. With modes on that level is the
|
|
# curve's (`_levels()`); with modes off there is no curve, and it is
|
|
# Home's own count (`_counted()`).
|
|
runs = [plan["runs"], []]
|
|
if running and not modes_on:
|
|
runs[1] = _off_tomorrow(c, runs[0], now, shadow, n0, n1)
|
|
elif running:
|
|
runs[1] = hv.runs_for(c, n0, n1, now, shadow, ahead=True)
|
|
|
|
# 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 _off_tomorrow(c, today, now, shadow, n0, n1):
|
|
"""Modes off: tomorrow's blocks by `homeview.trade_runs()`, each read at
|
|
the level `_counted()` gives when it starts. A charge may come before a
|
|
give, so this repeats until the blocks stop changing."""
|
|
sig, rt = None, []
|
|
for _ in range(4):
|
|
rt = hv.runs_for(c, n0, n1, now, shadow,
|
|
ahead=_counted(c, [today, rt]))
|
|
new = [(r["battery"], r["kind"], r["start"], r["end"]) for r in rt]
|
|
if new == sig:
|
|
break
|
|
sig = new
|
|
return rt
|
|
|
|
|
|
def _counted(c, rs):
|
|
"""Modes off reads no forecast and keeps no house profile, so there is no
|
|
curve. A give is read at Home's own count (`trade_runs()`): the level now,
|
|
plus what the blocks in `rs` that start before it put in, less what they
|
|
take out. The house can only take from that and no sun is read, so a
|
|
battery at the floor by the count is at the floor then. A charge is read
|
|
from the floor (None): the house uses some of a full battery before the
|
|
cheap hour, and control charges it back then."""
|
|
_floor, ceil = arbitrage.soc_bounds(c.o)
|
|
|
|
def at(name, kind, start):
|
|
i = next(k for k, inv in enumerate(c.inverters) if inv.name == name)
|
|
inv = c.inverters[i]
|
|
if kind == "grid" or inv.soc is None or not inv.capacity_kwh:
|
|
return None
|
|
v = float(inv.soc)
|
|
mine = sorted((r for day in rs for r in day if r["battery"] == i
|
|
and r["kind"] in ("grid", "give")
|
|
and r.get("kwh") is not None
|
|
and _times(r)[0] < start),
|
|
key=lambda r: _times(r)[0])
|
|
for r in mine:
|
|
d = float(r["kwh"]) / float(inv.capacity_kwh) * 100.0
|
|
v = min(max(v, ceil), v + d) if r["kind"] == "grid" else v - d
|
|
return v
|
|
return at
|
|
|
|
|
|
def _stops(r, names, lv, packs, targets, floor, ceil, day0):
|
|
"""`r` ended where its battery stops, if that is before the run ends: a
|
|
grid block where the curve has it at the level `modetrade.charge_to()`
|
|
allows (the curve has a point there), a give block at the first point
|
|
where the level the tab shows (`LEVEL_DP`) is at the floor. 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: round(v, LEVEL_DP) <= floor # 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, LEVEL_DP)]
|
|
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], LEVEL_DP))
|
|
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 add-on plan a give in `[d0, d1)` not yet over? Modes on: a
|
|
give run of the run plan. Modes off: a give leg of the verdict that the
|
|
gate takes (`homeview.trade_legs()`), at a price that is not negative."""
|
|
if not getattr(c, "modes_on", False):
|
|
plan = c.fresh_tariff_plan(now)
|
|
verdict, _why = hv.trade_verdict(c, plan, now)
|
|
return verdict is not None and any(
|
|
k == "give" and pt.eur_per_kwh >= 0 and pt.start < d1
|
|
and hv._point_end(plan, pt) > max(now, d0)
|
|
for k, pt in hv.trade_legs(c, plan, verdict, now))
|
|
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, so a give that
|
|
the add-on plans 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")
|
|
modes_on = getattr(c, "modes_on", False)
|
|
gives = not modes_on or row.get("mode_choice") in hv.PRICE_MODES
|
|
if gives and not any(r["kind"] == "give" for r in mine) \
|
|
and _give_planned(c, d0, d1, now):
|
|
return hv.day_note("give_later")
|
|
if mine:
|
|
return ""
|
|
if modes_on and gives:
|
|
return hv.day_note("no_blocks")
|
|
return hv.run_house_what(shadow) + " " + hv.run_house_why(shadow)
|