Files
goodwe-addon/goodwe_controller/test_ux54.py
T
glenn schrooyenandClaude 5ab8622265
checks / unit (pull_request) Successful in 48m22s
infra-pin / fresh (pull_request) Successful in 1s
UX-54 round 3: tomorrow's blocks last as long as control acts, give blocks checked at run start
- Tomorrow's grid block is drawn to where control stops charging. A cheap run
  is sized for all batteries together; a slower one is still below
  `charge_to()` when it ends and control plans the next cheap slots. The tab
  asks `modetrade.energy()` and `priceruns.plan()` with the packs at the
  curve's level and extends the run, so the curve charges as long.
- Each battery's block ends where it is at its own level (grid) or at the
  floor (give). The curve puts a point where a battery reaches its charge
  level inside a step.
- `homeview.mode_runs(ahead=f, rp=...)` reads each block at the level the
  curve has when it starts, with Home's own filter and MIN_KWH: no give block
  at the floor, no grid block at the planned level. Home never passes either.
- `no_tomorrow` names the hour from `solarfc.BELL_FROM_H`, where the curve
  stops.
- test_ux54.py part 7 compares control (`_mode_roles()` every 15 minutes) with
  the tab with no tolerance, and adds the cloudy / floor case.

No decision, command, timing or option key changes.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-27 00:53:58 +01:00

971 lines
44 KiB
Python

"""Runnable check for UX-54. `python3 test_ux54.py`
The Forecast tab: today's and tomorrow's solar, the house's expected use and
each battery's expected level, per hour. It reads the add-on and decides
nothing. Real `Controller`s in known situations (tools/ux54_scenarios.py).
1. The hourly shape: what each forecast integration gives, and that the tab
spreads a state-only total over the bell and says so.
2. The blocks are the plan's: a block is on the tab exactly where Home has
one, with its words; tomorrow's come from the same builders; the gate
(UX-53), modes off and every mode are followed.
3. The level curve (`daycurve.curve()`): inside floor and ceiling, the right
way in a charge and a give block, from spare solar and from a shortfall,
and a fuzz against the invariants.
4. A part that is not known yet is a note: modes off, no forecast, a lost
forecast, learning, no tomorrow, a monthly cycle, no level, stopped,
shadow.
5. Measured solar (`SolarToday`), and that keeping it can never cost a cycle.
6. The words, English and Dutch, and the page and its route. Reading it
asks Home Assistant for nothing and moves nothing in control.
Mutations (MUTATION.md, purged copies): listed in the pull request.
"""
import asyncio
import json
import logging
import os
import random
import re
import sys
import time
from datetime import datetime, timedelta, timezone
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "tools"))
logging.disable(logging.WARNING)
import ux54_scenarios as S # noqa: E402
from app import arbitrage, dayplan, dayview, homeview, modetrade # noqa: E402
from app import packmode, solarfc, uitext # noqa: E402
from app.daycurve import Batt, SolarToday, curve # noqa: E402
from app.peakguard import PeakDecision # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
UTC = timezone.utc
DAY0 = datetime(2026, 10, 26, 23, 0, tzinfo=UTC) # Tue 27 Oct, local
KINDS = ("grid", "give", "cycle")
def at(h, m=0, s=5):
return DAY0 + timedelta(hours=h, minutes=m, seconds=s)
def home(c, now, lang="en"):
return homeview.build(c, level="ok", checks=[], now=now, lang=lang)
def tab(c, now, lang="en"):
return dayview.build(c, home(c, now, lang), now, lang)
def block_key(r):
return (r["kind"], r["from"], r["to"], r["start_h"], r["end_h"],
r["what"], r["why"], r["amount"])
def tab_blocks(out, day=0):
return [[block_key(r) for r in b["blocks"]]
for b in out["days"][day]["batteries"]]
def home_blocks(status, n):
return [[block_key(r) for r in status["plan"]["runs"]
if r["battery"] == i and r["kind"] in KINDS] for i in range(n)]
def notes_of(out, day=0):
return out["days"][day]["notes"]
def say(key, lang="en", **v):
with homeview.in_lang(lang):
return homeview.day_note(key, **v)
# --------------------------------------------------------------------------- #
print("1. the hourly shape")
# --------------------------------------------------------------------------- #
now = at(10, 7)
c = S.make("owner", now)
fc = c.solar_fc
out = tab(c, now)
d = out["days"][0]
fcs = [h["forecast_kwh"] for h in d["hours"]]
print(f" owner: {len(c.fc_entities)} + {len(c.fc_tomorrow_entities)} "
f"state-only sensors, today {fc.today.kwh:.2f} kWh left, tomorrow "
f"{fc.tomorrow.kwh:.2f}")
check("the owner's shape (three 'remaining' + three 'tomorrow' sensors) "
"carries no hourly split: the add-on sums them and uses its bell",
len(c.fc_entities) == 3 and len(c.fc_tomorrow_entities) == 3
and fc.today.periods == () and fc.tomorrow.periods == ()
and abs(fc.tomorrow.kwh - 16.0) < 0.01)
check("...so the tab says it is an estimate from a daily total, and fades "
"the bars", d["sun"]["estimate"] is True
and say("estimate") in notes_of(out)
and out["days"][1]["sun"]["estimate"] is True)
check("today the hours still to come add up to what the sensors say is left "
"(no hour before now)",
all(f is None for f in fcs[:10])
and abs(sum(f for f in fcs if f is not None) - fc.today.kwh) < 1e-3)
tm = [h["forecast_kwh"] for h in out["days"][1]["hours"]]
check("tomorrow's 24 hours add up to tomorrow's total, are 0 outside 06-20 "
"and peak at 13:00 (the bell)",
abs(sum(tm) - fc.tomorrow.kwh) < 0.01
and all(v == 0 for v in tm[:6] + tm[20:])
and tm.index(max(tm)) in (12, 13))
# The day plan reads the same forecast hour by hour with `between(s, e)`;
# for a state-only forecast that spreads what is left over each span's own
# tail, so its hours add up to more than the total (a finding in the pull
# request, not changed here: no control change).
plan_sum = sum(fc.between(s, e) for s, e, _h in dayplan._hour_slices(
now, solarfc._bounds(now)[1]))
print(f" dayplan's own per-hour sum: {plan_sum:.1f} kWh for {fc.today.kwh:.1f} kWh left")
check("the tab's hours use the span from now (`dayview.sun_kwh()`), not the "
"plan's per-hour call, and so keep the total",
abs(dayview.sun_kwh(fc, now, now, solarfc._bounds(now)[1])
- fc.today.kwh) < 1e-6)
cp = S.make("periods", now)
dp = tab(cp, now)["days"]
check("an hourly split (Open-Meteo's `wh_period`, Solcast's "
"`detailedForecast`) is used as it is, and the tab does not call it an "
"estimate",
cp.solar_fc.today.periods and cp.solar_fc.tomorrow.periods
and dp[0]["sun"]["estimate"] is False
and say("estimate") not in dp[0]["notes"]
and abs(sum(h["forecast_kwh"] for h in dp[1]["hours"])
- cp.solar_fc.tomorrow.kwh) < 1e-3)
# Solcast's shape: `detailedForecast` rows, kW per half hour.
d0 = S.day0_of(now)
rows = [{"period_start": (d0 + timedelta(minutes=30 * i)).isoformat(),
"pv_estimate": 4.0 if 20 <= i < 36 else 0.0} for i in range(48)]
st = {"state": "20.0", "attributes": {"unit_of_measurement": "kWh",
"detailedForecast": rows},
"last_updated": now.isoformat(), "last_reported": now.isoformat()}
sc = solarfc.read_sum([("sensor.solcast", st)], [], now)[0]
check("Solcast's `detailedForecast` gives a split too (kW per half hour)",
sc is not None and sc.today.periods and len(sc.today.periods) >= 16)
check("the forecast the tab reads is the one the controller holds "
"(`Controller.solar_fc`), read by `solarfc` as the plan reads it",
out["updated"] == homeview.hhmm(c.solar_fc.at))
# --------------------------------------------------------------------------- #
print("\n2. the blocks are the plan's")
# --------------------------------------------------------------------------- #
SCN = [("owner", 10.1), ("dynamic", 10.1), ("dynamic", 0.3),
("dynamic", 18.5), ("sunny", 0.3), ("sunny", 18.5), ("off", 10.1),
("off", 16.9), ("cycle", 10.1), ("nofc", 10.1), ("stale", 0.3),
("learning", 0.3), ("periods", 18.5)]
bad, seen = [], {"grid": 0, "give": 0, "cycle": 0}
for name, hh in SCN:
now = at(hh)
c = S.make(name, now)
st = home(c, now)
out = tab(c, now)
n = len(c.inverters)
if tab_blocks(out) != home_blocks(st, n):
bad.append((name, hh))
for rows in tab_blocks(out):
for r in rows:
seen[r[0]] += 1
print(f" {len(SCN)} scenarios, blocks seen on the tab: {seen}")
check("today, every battery's blocks on the tab are Home's `runs` of that "
"battery (grid, give, cycle): same times, same words, no more, no less "
f"({bad})", not bad and all(v > 0 for v in seen.values()))
now = at(10, 7)
c = S.make("dynamic", now)
d0, d1 = homeview.local_day(now)
n0, n1 = homeview.local_day(d1 + timedelta(hours=2))
check("Home's own price blocks for today are `homeview.runs_for()` of "
"today: one builder, two days",
[block_key(r) for r in homeview.runs_for(c, d0, d1, now, False)]
== [block_key(r) for r in home(c, now)["plan"]["runs"]
if r["kind"] in ("grid", "give")])
tmr = tab(c, now)["days"][1]["batteries"]
check("tomorrow's blocks are the same builder over tomorrow's window, read "
"`ahead` (the level now does not filter a day that has not started)",
[[block_key(r) for r in b["blocks"]] for b in tmr]
== [[block_key(r) for r in homeview.runs_for(c, n0, n1, now, False, ahead=True)
if r["battery"] == i] for i in range(2)])
# Modes on: Solar first draws no price block; Leftover solar and Cheap hours do.
c_o = S.make("owner", at(10, 7))
o = tab(c_o, at(10, 7))
check("modes on, both batteries Solar first, a day/night tariff (the "
"owner's shape): no block on either battery, either day, and the "
"battery says it follows the house",
all(not b["blocks"] for dd in o["days"] for b in dd["batteries"])
and all(b["line"] == homeview.run_house_what(False) + " "
+ homeview.run_house_why(False)
for b in o["days"][0]["batteries"]))
c_d = S.make("dynamic", at(10, 7))
dd = tab(c_d, at(10, 7))["days"][0]["batteries"]
rp = c_d.mode_runs
check("Leftover solar and Cheap hours: a give block exactly on the give run "
"(17:00 to 21:00), on both",
all([(r["kind"], r["from"], r["to"]) for r in b["blocks"]]
== [("give", "17:00", "21:00")] for b in dd)
and rp.give[0].start == at(17, 0, 0) and rp.give[0].end == at(21, 0, 0))
c_n = S.make("dynamic", at(0, 18))
nb = tab(c_n, at(0, 18))["days"][0]["batteries"]
check("in the night before the cheap run both charge from the grid in it "
"(01:00 to 02:30), then give at 17:00",
all([r["kind"] for r in b["blocks"]] == ["grid", "give"] for b in nb)
and all(b["blocks"][0]["from"] == "01:00"
and b["blocks"][0]["to"] == "02:30" for b in nb))
# UX-53: no grid block while the gate refuses.
for why in ("peak", "not_worth"):
c_n.mode_gate = why
gb = tab(c_n, at(0, 18))["days"][0]["batteries"]
check(f"the trade gate refuses ({why}): no grid block on the tab, as on "
"Home, and the give block stays",
all([r["kind"] for r in b["blocks"]] == ["give"] for b in gb)
and tab_blocks(tab(c_n, at(0, 18))) == home_blocks(
home(c_n, at(0, 18)), 2))
c_n.mode_gate = None
def at_h(b, h):
"""A drawn level at hour `h` of the day, NaN where no point is drawn."""
return dict((round(x, 3), v) for x, v in b["levels"]).get(h, float("nan"))
check("the curve charges in a grid block: A (planned level 64 %) and B "
"(Cheap hours, to the end of its 01:30 run) are well above where they "
"were at 01:00, and stay put after the run",
all(at_h(b, 2.5) - at_h(b, 1.0) > 8.0
and abs(at_h(b, 3.0) - at_h(b, 2.5)) < 1e-6
for b in nb) and nb[0]["levels"][0][1] == 55.0)
# Modes off: the verdict's legs.
c_f = S.make("off", at(10, 7))
fb = tab(c_f, at(10, 7))
check("modes off, arbitrage on: the verdict's blocks are Home's, today, and "
"tomorrow's charge comes from the same builder; no level is drawn",
[b["kind"] for x in fb["days"][0]["batteries"] for b in x["blocks"]]
== ["give", "give"]
and [b["kind"] for x in fb["days"][1]["batteries"]
for b in x["blocks"]] == ["grid", "grid"]
and not any(x["levels"] for dd in fb["days"] for x in dd["batteries"]))
# A battery that is Off has no block and no curve.
c_d.store.data[S.unit_key("inverter-b", packmode.USE_KEY)] = False
off = tab(c_d, at(10, 7))["days"][0]["batteries"]
check("a battery switched Off: no block, no curve, and it says so in the "
"plan's words",
off[1]["off"] is True and not off[1]["blocks"] and not off[1]["levels"]
and off[1]["line"] == homeview.why_words("off")
and off[0]["levels"])
# The cycle is a block, and the curve stops where it starts.
c_c = S.make("cycle", at(10, 7))
cy = tab(c_c, at(10, 7))
a0 = cy["days"][0]["batteries"][0]
check("a monthly cycle waiting today is a block, the level is drawn only "
"until it starts, and a note says so",
[r["kind"] for r in a0["blocks"]] == ["give", "cycle"]
and a0["levels"][-1][0] <= 20.0 + 1e-6
and say("cycle", name="inverter-a") in notes_of(cy)
and not cy["days"][1]["batteries"][0]["levels"])
# --------------------------------------------------------------------------- #
print("\n3. the level curve")
# --------------------------------------------------------------------------- #
T0 = DAY0 + timedelta(hours=9)
T1 = T0 + timedelta(hours=24)
def flat(kwh_per_h):
return lambda a, z: kwh_per_h * (z - a).total_seconds() / 3600.0
def run(b, sun, use, **kw):
return curve([b] if isinstance(b, Batt) else b, T0, T1, sun, use,
floor_pct=20.0, ceiling_pct=95.0, **kw)
def lv(out, name, h):
"""The level at `h` hours after T0 (NaN when there is no such point)."""
return dict(out[name]).get(T0 + timedelta(hours=h), float("nan"))
B1 = Batt("a", 10.0, 5.0, 50.0, True, False)
o1 = run(B1, flat(3.0), flat(1.0))
check("Solar first: 2 kWh of spare solar an hour fills it at 95 % "
"efficiency, 19 % an hour",
abs(lv(o1, "a", 1) - 69.0) < 1e-6 and abs(lv(o1, "a", 2) - 88.0) < 1e-6)
check("...stops at the ceiling (95 %), and stays there", abs(lv(o1, "a", 3)
- 95.0) < 1e-6
and max(v for _t, v in o1["a"]) <= 95.0 + 1e-9)
o2 = run(B1, flat(0.0), flat(1.0))
check("a shortfall of 1 kWh an hour is given at 95 % efficiency, 10.53 % an "
"hour", abs(lv(o2, "a", 1) - (50.0 - 100.0 / 9.5)) < 1e-6)
check("...stops at the floor (20 %), and stays there",
abs(lv(o2, "a", 6) - 20.0) < 1e-6
and min(v for _t, v in o2["a"]) >= 20.0 - 1e-9)
check("a battery already above the ceiling does not charge and one below the "
"floor does not give",
lv(run(Batt("a", 10.0, 5.0, 98.0, True, False), flat(3), flat(1)),
"a", 3) == 98.0
and lv(run(Batt("a", 10.0, 5.0, 12.0, True, False), flat(0), flat(1)),
"a", 3) == 12.0)
slow = run(Batt("a", 10.0, 1.0, 50.0, True, False), flat(5.0), flat(0.0))
check("the rating caps the rate: 1 kW charges 9.5 % of 10 kWh an hour",
abs(lv(slow, "a", 1) - 59.5) < 1e-6)
# Roles.
GV = ((T0 + timedelta(hours=3), T0 + timedelta(hours=6)),)
hold = Batt("p", 10.0, 5.0, 60.0, False, True, (), GV)
oh = run(hold, flat(0.0), flat(1.0))
check("a price-mode battery holds outside a give run (no give), and gives "
"inside it",
lv(oh, "p", 3) == 60.0
and lv(oh, "p", 6) < 60.0 - 3 * 100.0 / 9.5 + 1e-6 + 0.01
and lv(oh, "p", 24) == lv(oh, "p", 6))
oh2 = run(hold, flat(3.0), flat(1.0))
check("...and charges from spare solar while it holds",
lv(oh2, "p", 3) > 60.0)
GR = ((T0 + timedelta(hours=2), T0 + timedelta(hours=4), 80.0),)
ch = Batt("c", 10.0, 5.0, 40.0, False, True, GR, ())
oc = run(ch, flat(0.0), flat(0.0))
check("a grid block charges to its level at the rating (5 kW x 95 %: 47.5 % "
"an hour on 10 kWh, so 80 % in the first hour), and stops there",
lv(oc, "c", 2) == 40.0 and abs(lv(oc, "c", 3) - 80.0) < 1e-6
and lv(oc, "c", 5) == 80.0)
reach = T0 + timedelta(hours=2, minutes=50, seconds=31.579)
check("a battery that reaches its grid level inside a step gets a point "
"there (80 % at 02:50:31, not at the next hour), so the line and the "
"block end are exact",
any(abs((t - reach).total_seconds()) < 1.0
and abs(v - 80.0) < 1e-6 for t, v in oc["c"]))
two = [Batt("a", 10.0, 5.0, 50.0, True, False),
Batt("b", 10.0, 5.0, 50.0, False, True)]
ot = run(two, flat(3.0), flat(1.0))
check("two batteries: Solar first takes the spare solar first; the other "
"gets what is left",
lv(ot, "a", 1) > lv(ot, "b", 1) - 1e-9
and abs(lv(ot, "a", 1) - 69.0) < 1e-6 and lv(ot, "b", 1) == 50.0)
big = run(two, flat(8.0), flat(0.0))
check("...and the other charges once Solar first is at its rating or full",
lv(big, "b", 1) > 50.0)
GR2 = ((T0 + timedelta(hours=2), T0 + timedelta(hours=4), 80.0),)
mix = [Batt("a", 40.0, 5.0, 50.0, True, True, (), ()),
Batt("b", 10.0, 5.0, 40.0, False, True, GR2, ())]
om = run(mix, flat(0.0), flat(2.0))
check("while one battery charges from the grid the others do not charge "
"(they rest) and Solar first may still give",
lv(om, "b", 3) > lv(om, "b", 2) and lv(om, "a", 3) < lv(om, "a", 2))
ends = run(B1, lambda a, z: None if a >= T0 + timedelta(hours=5)
else flat(3.0)(a, z), flat(1.0))
check("an unknown sun ends the curve there", ends["a"][-1][0]
== T0 + timedelta(hours=5) and len(ends["a"]) == 6)
stop = run(Batt("a", 10.0, 5.0, 50.0, True, False, (), (),
T0 + timedelta(hours=2)), flat(3.0), flat(1.0))
check("a monthly cycle ends the battery's curve at its start",
stop["a"][-1][0] == T0 + timedelta(hours=2))
check("the curve starts at the battery's level now, at `start`",
o1["a"][0] == (T0, 50.0) and o2["a"][0] == (T0, 50.0))
# A fuzz against the invariants.
rnd = random.Random(54)
bad_f = []
for i in range(300):
n = rnd.choice((1, 2, 3))
bs = []
for k in range(n):
gs = ()
gw = ()
if rnd.random() < 0.5:
a = rnd.randint(0, 20)
gs = ((T0 + timedelta(hours=a),
T0 + timedelta(hours=a + rnd.randint(1, 4)),
rnd.uniform(40, 95)),)
if rnd.random() < 0.5:
a = rnd.randint(0, 20)
gw = ((T0 + timedelta(hours=a),
T0 + timedelta(hours=a + rnd.randint(1, 4))),)
bs.append(Batt(f"b{k}", rnd.uniform(5, 25), rnd.uniform(1, 6),
rnd.uniform(20, 95), rnd.random() < 0.5,
rnd.random() < 0.6, gs, gw))
sun = rnd.uniform(0, 5)
use = rnd.uniform(0, 3)
out = run(bs, lambda a, z, s=sun: s * (z - a).total_seconds() / 3600.0,
lambda a, z, u=use: u * (z - a).total_seconds() / 3600.0)
for b in bs:
pts = out[b.name]
vals = [v for _t, v in pts]
if min(vals) < 20.0 - 1e-6 or max(vals) > 95.0 + 1e-6:
bad_f.append(("range", i, b.name))
for (t1, v1), (t2, v2) in zip(pts, pts[1:]):
hrs = (t2 - t1).total_seconds() / 3600.0
if abs(v2 - v1) / 100.0 * b.capacity_kwh > b.rating_kw * hrs \
/ dayplan.EFF * 1.0001 + 1e-9:
bad_f.append(("rate", i, b.name))
# holds, and not in a grid block or a give run: never gives
in_give = any(s <= t1 < e for s, e in b.give)
in_grid = any(s <= t1 < e for s, e, _ in b.grid)
if b.priced and not b.solar_first and not in_give \
and not in_grid and v2 < v1 - 1e-9:
bad_f.append(("hold gave", i, b.name))
# Energy: what the batteries gain never exceeds the sun's spare (net of
# the grid blocks) at 95 %.
check(f"a fuzz of 300 days of 1-3 random batteries: every level stays inside "
f"the floor and ceiling, no step beats the rating, a holding battery "
f"never gives ({bad_f[:3]})", not bad_f)
# On the controllers: inside floor and ceiling, the right way in blocks.
rows_ok, moves_ok, n_curves = True, True, 0
for name, hh in SCN:
now = at(hh)
c = S.make(name, now)
out = tab(c, now)
fl, ce = arbitrage.soc_bounds(c.o)
for day in out["days"]:
for b in day["batteries"]:
n_curves += bool(b["levels"])
for h_, v in b["levels"]:
rows_ok &= fl - 1e-6 <= v <= ce + 1e-6
for r in b["blocks"]:
inside = [(h_, v) for h_, v in b["levels"]
if r["start_h"] - 1e-6 <= h_ <= r["end_h"] + 1e-6]
if len(inside) >= 2 and r["kind"] == "grid":
moves_ok &= inside[-1][1] >= inside[0][1] - 1e-6
if len(inside) >= 2 and r["kind"] == "give":
moves_ok &= inside[-1][1] <= inside[0][1] + 1e-6
print(f" {n_curves} battery curves checked on the controllers")
check("on the controllers: every drawn level is inside the floor and the "
"ceiling", rows_ok and n_curves >= 10)
check("...and the level rises in a grid block and does not rise in a give "
"block", moves_ok)
c_s = S.make("sunny", at(10, 7))
ls = tab(c_s, at(10, 7))["days"][0]["batteries"][0]["levels"]
check("a sunny day with Leftover solar: the level climbs by noon and "
"reaches the ceiling by evening",
ls[0][1] == c_s.inverters[0].soc and max(v for _h, v in ls) > 85.0)
# --------------------------------------------------------------------------- #
print("\n4. a part that is not known yet is a note")
# --------------------------------------------------------------------------- #
def no_charts(out):
return all(not b["levels"] for dd in out["days"]
for b in dd["batteries"])
c = S.make("off", at(10, 7))
o = tab(c, at(10, 7))
check("modes off: a note, no forecast, no house use, no curve",
say("modes_off") in notes_of(o) and no_charts(o)
and not o["days"][0]["sun"]["forecast"]
and not o["days"][0]["sun"]["house"])
o = tab(S.make("nofc", at(10, 7)), at(10, 7))
check("no forecast set up: a note, and the price blocks still show",
say("not_set") in notes_of(o) and no_charts(o)
and o["days"][0]["batteries"][0]["blocks"])
o = tab(S.make("stale", at(10, 7)), at(10, 7))
check("a forecast that is missing or too old: a note, no curve",
say("no_forecast") in notes_of(o) and no_charts(o))
o = tab(S.make("learning", at(10, 7)), at(10, 7))
check("learning (no house profile yet): a note, the sun is drawn, no house "
"use and no curve",
say("learning") in notes_of(o) and no_charts(o)
and o["days"][0]["sun"]["forecast"] and not o["days"][0]["sun"]["house"]
and all(h["house_kwh"] is None for h in o["days"][0]["hours"]))
SUN_FROM = f"{int(solarfc.BELL_FROM_H):02d}:00"
o = tab(S.make("notomorrow", at(10, 7)), at(10, 7))
tmr = o["days"][1]
check("no forecast for tomorrow: a note on that day only, naming the hour "
"the curve really stops (`solarfc.BELL_FROM_H`, the first hour of the "
"sun: its last point is there); today is whole",
say("no_tomorrow", at=SUN_FROM) in tmr["notes"]
and say("no_tomorrow", at=SUN_FROM) not in notes_of(o, 0)
and not tmr["sun"]["forecast"]
and abs(tmr["batteries"][0]["levels"][-1][0] - solarfc.BELL_FROM_H) < 1e-6
and o["days"][0]["batteries"][0]["levels"][-1][0] > 23.0)
c = S.make("owner", at(10, 7))
c.inverters[1].soc = None
o = tab(c, at(10, 7))
check("a battery with no level reading: a note naming it, no curve for it, "
"and the other still has one",
say("no_level", name="inverter-b") in notes_of(o)
and not o["days"][0]["batteries"][1]["levels"]
and o["days"][0]["batteries"][0]["levels"])
c = S.make("owner", at(10, 7))
c.auto = False
o = tab(c, at(10, 7))
check("control stopped: the plan's words, no curve, no block",
homeview.why_words("stopped") in notes_of(o) and no_charts(o)
and not o["running"])
c = S.make("owner", at(10, 7))
c.o["shadow_mode"] = "on"
o = tab(c, at(10, 7))
check("shadow mode: the plan's own shadow sentence is on the tab",
homeview.why_words("shadow") in notes_of(o))
c = S.make("owner", at(10, 7))
check("the levels count on the trust the plan counts on, and the tab says "
"so", say("trust", pct=70) in notes_of(tab(c, at(10, 7))))
c.solar_trust_pct = 100
full = tab(c, at(10, 7))["days"][0]["batteries"][0]["levels"]
c.solar_trust_pct = 30
low = tab(c, at(10, 7))["days"][0]["batteries"][0]["levels"]
check("...a higher trust fills the battery more (the curve uses it)",
max(v for _h, v in full) > max(v for _h, v in low))
# --------------------------------------------------------------------------- #
print("\n5. measured solar")
# --------------------------------------------------------------------------- #
t0 = at(0, 0, 0)
sd = SolarToday()
for i in range(0, 3600, 5): # 2 kW for the hour 10:00-11:00
sd.add(t0 + timedelta(hours=10, seconds=i), 2000.0)
sd.add(t0 + timedelta(hours=11), 2000.0)
check("2 kW read all hour is 2.0 kWh for that hour",
abs(sd.kwh(t0 + timedelta(hours=10), t0 + timedelta(hours=11),
t0 + timedelta(hours=12)) - 2.0) < 0.02)
check("the hour in progress is what was made so far (half an hour: 1 kWh)",
abs(sd.kwh(t0 + timedelta(hours=10), t0 + timedelta(hours=11),
t0 + timedelta(hours=10, minutes=30)) - 1.0) < 0.05)
check("an hour with no reading, or under half covered, is not guessed",
sd.kwh(t0 + timedelta(hours=9), t0 + timedelta(hours=10),
t0 + timedelta(hours=12)) is None)
g = SolarToday()
g.add(t0 + timedelta(hours=10), 1000.0)
g.add(t0 + timedelta(hours=10, minutes=50), 1000.0) # a gap: 60 s counted
check("a gap in the readings counts for no more than a minute, so a "
"restart does not invent energy",
g.kwh(t0 + timedelta(hours=10), t0 + timedelta(hours=11),
t0 + timedelta(hours=11)) is None)
g.add(t0 + timedelta(hours=10, minutes=51), None)
g.add(t0 + timedelta(hours=10, minutes=52), 1000.0)
check("no reading ends an interval; a negative reading counts as 0",
g._last == (t0 + timedelta(hours=10, minutes=52), 1000.0))
nxt = t0 + timedelta(days=1, hours=1)
g.add(nxt, 500.0)
check("a new local day starts again", g.day != (t0).date()
and g.first == nxt and g._hours == {})
c = S.make("owner", at(10, 7))
o = tab(c, at(10, 7))
got = [h["measured_kwh"] for h in o["days"][0]["hours"]]
check("on the page: the hours already over have measured solar, the hours "
"to come do not, tomorrow has none",
got[9] is not None and got[11] is None and got[0] is not None
and all(h["measured_kwh"] is None for h in o["days"][1]["hours"]))
c = S.make("owner", at(10, 7))
c.solar_today = SolarToday()
c.solar_today.add(at(8, 0, 0), 1000.0)
o2 = tab(c, at(10, 7))
check("the add-on that started at 08:00 says measured solar counts from "
"then", say("measured_from", at="08:00") in notes_of(o2))
class _Hass:
def __init__(self):
self.reads = 0
async def state(self, e):
self.reads += 1
return {"entity_id": e, "state": "1500",
"attributes": {"unit_of_measurement": "W"}}
async def _read_pv_with(add):
c = S.make("owner", at(10, 7))
c.hass = _Hass()
c.solar_today.add = add
await c._read_pv()
return c.pv_w
def _boom(*_a):
raise RuntimeError("no")
def _survives():
try:
return asyncio.run(_read_pv_with(_boom)) == 1500.0
except Exception: # noqa: BLE001
return False
check("a fault in keeping the measured solar never costs the control "
"cycle its reading (`_read_pv` still returns the total)", _survives())
# --------------------------------------------------------------------------- #
print("\n6. words, page, route; reading asks for nothing and moves nothing")
# --------------------------------------------------------------------------- #
en = uitext.words("en")
nl = uitext.words("nl")
check("every sentence of the tab has an English and a Dutch form, with the "
"same placeholders",
set(en["home_words"]["day"]) == set(nl["home_words"]["day"])
and all(re.findall(r"\{\w+\}", en["home_words"]["day"][k])
== re.findall(r"\{\w+\}", nl["home_words"]["day"][k])
for k in en["home_words"]["day"]))
now = at(10, 7)
c = S.make("owner", now)
oe, on = tab(c, now, "en"), tab(c, now, "nl")
check("Dutch: the notes, the totals and the blocks are in Dutch, with a "
"decimal comma",
on["days"][0]["line"].startswith("Nog verwachte zon vandaag: 14,4 kWh")
and say("estimate", "nl") in on["days"][0]["notes"]
and on["days"][1]["line"].startswith("Verwachte zon morgen: 16,0 kWh")
and oe["days"][0]["line"].startswith("Solar still to come today: 14.4"))
c = S.make("dynamic", at(0, 18))
nl_b = tab(c, at(0, 18), "nl")["days"][0]["batteries"][0]["blocks"]
check("...and so are the plan's words on a block",
nl_b[0]["what"].startswith("Laadt tot") and nl_b[0]["why"].startswith(
"Goedkoopste uren") and nl_b[1]["what"].startswith("Levert tot"))
check("the language goes back to English after a Dutch build",
homeview.pill("running") == en["home_words"]["pill"]["running"])
c = S.make("dynamic", at(10, 7))
sent = []
orig_state = c.hass.state
c.hass.state = lambda *a, **k: sent.append(a) or orig_state(*a, **k)
snap = lambda: repr((c.mode_tick, c.mode_runs, c._plan_targets, c.arb,
[(i.soc, i.batt, i.last_write) for i in c.inverters],
c.mode_gate, c.day_plan))
before = snap()
t_a = time.perf_counter()
for _ in range(10):
c.forecast_payload("en")
per = (time.perf_counter() - t_a) / 10 * 1000.0
print(f" forecast_payload: {per:.1f} ms per build on this machine")
check("building the tab asks Home Assistant for nothing and changes nothing "
"control holds (modes, runs, levels, decision, readings)",
not sent and snap() == before)
def _broken(c):
real = dayview.build
dayview.build = lambda *a, **k: 1 / 0
logging.disable(logging.CRITICAL)
try:
return c.forecast_payload("en")
finally:
dayview.build = real
logging.disable(logging.WARNING)
pay = c.forecast_payload("en")
check("`Controller.forecast_payload()` gives the tab's JSON, and a fault in "
"building it gives `ok: false` rather than raising",
pay.get("ok") is True and len(pay["days"]) == 2
and _broken(c) == {"ok": False})
PAGE_SRC = None
async def _serve():
from aiohttp import web as aioweb, ClientSession
from app import web
class _Sup:
async def info(self):
return {"options": {}, "schema": [], "translations": {}}
c = S.make("owner", datetime.now(UTC))
runner = aioweb.AppRunner(web.build_app(c, _Sup()))
await runner.setup()
site = aioweb.TCPSite(runner, "127.0.0.1", 0)
await site.start()
port = site._server.sockets[0].getsockname()[1]
res = {}
try:
async with ClientSession() as s:
async with s.get(f"http://127.0.0.1:{port}/forecast") as r:
res["en"] = (r.status, await r.json())
async with s.get(f"http://127.0.0.1:{port}/forecast?lang=nl-BE") as r:
res["nl"] = await r.json()
finally:
await runner.cleanup()
return res
try:
res = asyncio.run(_serve())
except Exception: # noqa: BLE001
res = None
check("GET forecast answers 200 with the tab's JSON, in English by default "
"and in Dutch with ?lang=nl",
res is not None and res["en"][0] == 200 and res["en"][1]["ok"] is True
and res["en"][1]["days"][0]["line"].startswith("Solar still to come")
and res["nl"]["days"][0]["line"].startswith("Nog verwachte zon"))
PAGE = open(os.path.join(HERE, "app", "static", "index.html"),
encoding="utf-8").read()
JS = open(os.path.join(HERE, "app", "static", "app.js"),
encoding="utf-8").read()
check("the page has the Forecast tab between Batteries and Settings, with "
"its own panel", re.findall(r'data-hash="(\w+)"', PAGE)[:4]
== ["home", "batteries", "forecast", "settings"]
and 'id="s-fc"' in PAGE and 'aria-labelledby="t-fc"' in PAGE)
check("it asks for `forecast?lang=` (relative, for ingress), only while its "
"screen shows, and sets data as text",
'fetch("forecast?lang="' in JS and "scheduleForecast" in JS
and "innerHTML" not in JS)
labels = set(re.findall(r'''\bt\("(forecast\.[\w.]+)"\)''', JS)) | set(
re.findall(r'data-t(?:-label)?="(forecast\.[\w.]+)"', PAGE))
miss = sorted(k for k in labels if not isinstance(
en["home"]["forecast"].get(k.split(".", 1)[1]), str)
or not isinstance(nl["home"]["forecast"].get(k.split(".", 1)[1]), str))
check(f"every label the screen asks for is in both languages ({len(labels)} "
f"keys; missing {miss})", len(labels) >= 12 and not miss)
CSS = open(os.path.join(HERE, "app", "static", "app.css"),
encoding="utf-8").read()
check("it is readable on a phone: columns flex to the width, the chart has "
"no fixed pixel width, and the controls are 44 px tall",
"min-width:0" in CSS and "width:100%" in CSS
and not re.search(r"\.fc[a-z]*\{[^}]*width:\s*\d{3,}px", CSS)
and "min-height:44px" in CSS)
# --------------------------------------------------------------------------- #
print("\n7. round 2: a block exists where the add-on will act (F1, F2, F3)")
# --------------------------------------------------------------------------- #
Q = timedelta(minutes=15)
def level_at(b, h):
"""The curve's level at hour `h` of the day the list is for, straight
between its points (the first level before the curve starts, the last
after it ends)."""
pts = b["levels"]
for (h0, v0), (h1, v1) in zip(pts, pts[1:]):
if h0 <= h <= h1:
return v0 if h1 <= h0 else v0 + (v1 - v0) * (h - h0) / (h1 - h0)
return pts[0][1] if h < pts[0][0] else pts[-1][1]
def night_evening(h):
"""A cheap night and a dear evening, flat between: one cheap run, one give
run, nothing the plan of tomorrow evening would add before then."""
return 0.08 if 1.0 <= h < 5.0 else 0.34 if 17.0 <= h < 21.0 else 0.20
def sweep(name, now0, soc, *, guard=None, until=48.0, sun=None):
"""Walk the real control cycle (`_mode_roles()`) every 15 minutes from
`now0` to the end of tomorrow, each battery at the level the tab's curve
has at that time, and return the tab and the role seen per battery and
quarter. The tab is built once, at `now0`."""
c = S.make(name, now0, soc=soc)
c.tariff_plan = S.quarters(now0, night_evening, days=3)
c.tariff_at = now0
if guard is not None:
c.peak_guard = guard
if sun is not None:
c.solar_fc = S.forecast(now0, sun[0], sun[1])
c._mode_roles(now0, None)
out = tab(c, now0)
d0, _d1 = homeview.local_day(now0)
seen = {inv.name: {} for inv in c.inverters}
t = now0
end = d0 + timedelta(hours=until)
while t < end:
h = (t - d0).total_seconds() / 3600.0
for i, inv in enumerate(c.inverters):
day = out["days"][0 if h < 24 else 1]
b = day["batteries"][i]
inv.soc = level_at(b, h if h < 24 else h - 24) if b["levels"] \
else inv.soc
c.tariff_at = t
tick = c._mode_roles(t, None)
floor_, _ceil = arbitrage.soc_bounds(c.o)
for inv in c.inverters:
role = tick.roles.get(inv.name)
# a give from a battery at the floor has no energy behind it:
# Home's own rule (`mode_runs()`, `MIN_KWH`) draws no block
seen[inv.name][t] = "no energy" if role == modetrade.GIVE \
and inv.soc <= floor_ + 0.05 else role
t += Q
return c, out, seen, d0
def tomorrow_match(out, seen, d0, role, kind):
"""Per battery, tomorrow: control has `role` in exactly the quarters the
tab has a block of `kind` (no quarter outside a block, none of a block
without it), with no tolerance."""
for i, name in enumerate(seen):
blocks = [r for r in out["days"][1]["batteries"][i]["blocks"]
if r["kind"] == kind]
quarters = [(t, (t - d0).total_seconds() / 3600.0 - 24.0)
for t in seen[name] if (t - d0).total_seconds() >= 86400]
for t, h in quarters:
acting = seen[name][t] == role
inside = any(r["start_h"] - 1e-6 <= h < r["end_h"] - 1e-6
for r in blocks)
if acting != inside:
print(" ", name, role, "quarter", round(h, 2),
"control acts" if acting else "tab has a block",
[(r["start_h"], r["end_h"]) for r in blocks])
return False
return True
t22 = at(22, 0, 5)
c1, o1, s1, d01 = sweep("dynamic", t22, (20.0, 20.0))
tm1 = o1["days"][1]["batteries"]
print(" 22:00, both batteries at 20 %: tomorrow", [[(r["kind"], r["from"], r["to"]) for r in b["blocks"]] for b in tm1])
check("F1, modes on: at 22:00 with both batteries at 20 % tomorrow's give "
"block (17:00-21:00) and the night's grid block show on both",
all([(r["kind"], r["from"]) for r in b["blocks"]]
== [("grid", "01:00"), ("give", "17:00")]
and b["blocks"][1]["to"] == "21:00" for b in tm1))
check("F2: ...and control, walked every 15 minutes through tomorrow at the "
"level the curve has, gives (role GIVE) exactly in the give block and "
"charges (role CHARGE) only in the grid block, on both batteries",
tomorrow_match(o1, s1, d01, modetrade.GIVE, "give")
and tomorrow_match(o1, s1, d01, modetrade.CHARGE, "grid"))
check("...and today's blocks are still Home's runs there",
tab_blocks(o1) == home_blocks(home(c1, t22), 2))
guard = PeakDecision(engaged=True, floor_w=1000.0, ceiling_w=3500.0,
ceiling_from="cap", projected_w=3600.0)
c2, o2, s2, d02 = sweep("dynamic", t22, (20.0, 20.0), guard=guard)
tm2 = o2["days"][1]["batteries"]
check("F2, the UX-53 gate: while the peak guard holds control never "
"charges tomorrow and the tab draws no grid block (both batteries stay "
"at the floor, so no give either, which control cannot give from)",
c2.mode_gate == "peak"
and all(not b["blocks"] for b in tm2)
and tomorrow_match(o2, s2, d02, modetrade.CHARGE, "grid")
and tomorrow_match(o2, s2, d02, modetrade.GIVE, "give"))
c2b, o2b, s2b, d02b = sweep("dynamic", t22, (60.0, 60.0), guard=guard)
check("...with the batteries at 60 % the give block stays and the grid "
"block does not",
all([r["kind"] for r in b["blocks"]] == ["give"]
for b in o2b["days"][1]["batteries"])
and tomorrow_match(o2b, s2b, d02b, modetrade.CHARGE, "grid")
and tomorrow_match(o2b, s2b, d02b, modetrade.GIVE, "give"))
# Low sun, a battery at the floor when the evening give run starts (no grid
# charge, the peak guard holds): Home's rule draws no give block for it, and
# control has no energy to give from. A little sun fills it a little: a small
# block, and control gives from it.
c4, o4, s4, d04 = sweep("dynamic", t22, (20.0, 20.0), guard=guard,
sun=(0.0, 0.0))
check("F1, cloudy day, no grid charge, both batteries at the floor at the "
"evening give run: no block on either, and control has no energy to "
"give from (the sweep sees none)",
all(not b["blocks"] for b in o4["days"][1]["batteries"])
and tomorrow_match(o4, s4, d04, modetrade.GIVE, "give"))
c5, o5, s5, d05 = sweep("dynamic", t22, (20.0, 20.0), guard=guard,
sun=(14.0, 14.0))
gv5 = [[r for r in b["blocks"] if r["kind"] == "give"]
for b in o5["days"][1]["batteries"]]
check("...a bright day fills them: a give block on both, and control gives "
"in exactly that block",
all(len(g) == 1 and g[0]["from"] == "17:00" for g in gv5)
and tomorrow_match(o5, s5, d05, modetrade.GIVE, "give"))
o3 = [sweep("dynamic", t22, soc) for soc in ((55.0, 40.0), (90.0, 70.0))]
check("F2: the same with the batteries part full and nearly full at 22:00",
all(tomorrow_match(x[1], x[2], x[3], modetrade.GIVE, "give")
and tomorrow_match(x[1], x[2], x[3], modetrade.CHARGE, "grid")
for x in o3))
def off_prices(h):
if 23.0 <= h < 24.0:
return 0.05
if 7.0 <= h < 9.0:
return 0.40
return 0.20
def off_case(soc):
now = at(20, 0, 5)
c = S.make("off", now, soc=soc)
c.tariff_plan = S.quarters(now, off_prices)
c.tariff_at = now
return c, now, tab(c, now), home(c, now)
for soc in ((20.0, 20.0), (60.0, 60.0), (95.0, 95.0)):
c, now, out, st = off_case(soc)
tz = S.brussels(now).tzinfo
v, _why = homeview.trade_verdict(c, c.fresh_tariff_plan(now), now)
t07 = at(24 + 7, 0, 5)
c.tariff_at = t07
v7, _w = homeview.trade_verdict(c, c.fresh_tariff_plan(t07), t07)
t06 = at(24 + 6, 0, 5)
c.tariff_at = t06
v6, _w6 = homeview.trade_verdict(c, c.fresh_tariff_plan(t06), t06)
check(f"F1, modes off, a charge today at 23:00 and a give tomorrow at "
f"07:00, batteries at {soc[0]:.0f} %: Home says 'Tomorrow: gives "
"power at 07:00', the tab draws that give on both batteries, and "
"the control cycle's own verdict gives at 07:00 and not at 06:00",
any("07:00" in x for x in st["plan"]["lines"])
and [[(r["kind"], r["from"]) for r in b["blocks"]]
for b in out["days"][1]["batteries"]]
== [[("give", "07:00")]] * 2
and v7 is not None and v7.dearest.start.astimezone(tz).strftime(
"%H:%M") == "07:00" and v7.dearest.start <= t07
and v6 is not None and v6.dearest.start > t06)
tm90 = o3[1][1]["days"][1]["batteries"]
check("F2: a battery already at its planned level when the run starts has no "
"grid block tomorrow (A at 90 %), the one below it has (B at 70 %); "
"control charged neither A nor B outside that",
[r["kind"] for r in tm90[0]["blocks"]] == ["give"]
and [r["kind"] for r in tm90[1]["blocks"]] == ["grid", "give"])
# Today: the give run is planned and Home draws no block for an empty battery.
c_s = S.make("sunny", at(10, 7), soc=(20.0, 20.0))
st_s = home(c_s, at(10, 7))
o_s = tab(c_s, at(10, 7))
check("today: with a give run in the plan and both batteries at the floor "
"Home draws no give block, the tab equals Home, and it says the "
"add-on plans to give, never 'No charge or give blocks planned'",
not [r for r in st_s["plan"]["runs"] if r["kind"] == "give"]
and c_s.mode_runs.give
and tab_blocks(o_s) == home_blocks(st_s, 2)
and all(b["line"] == say("give_later")
for b in o_s["days"][0]["batteries"]))
bad_no_blocks = []
for name, hh in SCN + [("sunny", 10.1), ("dynamic", 22.0)]:
for soc in ((20.0, 20.0), (55.0, 40.0), (95.0, 95.0)):
c = S.make(name, at(hh), soc=soc)
if not c.modes_on:
continue
o = tab(c, at(hh))
for k in (0, 1):
for b in o["days"][k]["batteries"]:
if b["line"] == say("no_blocks"):
d0_, d1_ = homeview.local_day(at(hh))
lo, hi = (d0_, d1_) if k == 0 else homeview.local_day(
d1_ + timedelta(hours=2))
if c.mode_runs is not None and c.mode_runs.on and any(
g.end > max(at(hh), lo) and g.start < hi
for g in c.mode_runs.give):
bad_no_blocks.append((name, hh, soc, k))
check("'No charge or give blocks planned.' is never said where the run plan "
f"has a give run in that day ({bad_no_blocks})", not bad_no_blocks)
# F3: trust as the plan counts it.
cap_big = 100.0
f3 = curve([Batt("a", cap_big, 50.0, 50.0, True, False)], T0,
T0 + timedelta(hours=1), flat(10.0), flat(2.0), floor_pct=20.0,
ceiling_pct=95.0, trust=0.7)
stored = (lv(f3, "a", 1) - 50.0) / 100.0 * cap_big
plan_sun = 0.7 * dayplan.EFF * max(0.0, 10.0 - 2.0)
print(f" 10 kWh sun, 2 kWh house, 70 %: curve stores {stored:.3f} kWh, plan {plan_sun:.3f}")
check("F3: 10 kWh of sun, 2 kWh of house and 70 % trust: the curve stores "
"5.32 kWh, as the plan counts trust x EFF x (sun - house), not 4.75",
abs(stored - 5.32) < 1e-6 and abs(plan_sun - 5.32) < 1e-9)
f3b = curve([Batt("a", cap_big, 50.0, 50.0, True, False)], T0,
T0 + timedelta(hours=1), flat(1.0), flat(2.0), floor_pct=20.0,
ceiling_pct=95.0, trust=0.7)
check("...a shortfall is not scaled by trust (the house needs all of it)",
abs((50.0 - lv(f3b, "a", 1)) / 100.0 * cap_big - 1.0 / dayplan.EFF)
< 1e-6)
c_t = S.make("owner", at(10, 7))
c_t.solar_trust_pct = 70
hi_ = tab(c_t, at(10, 7))["days"][0]["batteries"][0]["levels"]
check("on a controller the curve is built with the plan's trust "
"(`solar_trust_pct`)", hi_ and max(v for _h, v in hi_) > hi_[0][1])
print()
if fails:
print(f"{len(fails)} FAILED")
sys.exit(1)
print("all passed")