"""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 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", [[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) 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) 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"])) 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; the curve stops at " "sunrise; today is whole", say("no_tomorrow") in tmr["notes"] and say("no_tomorrow") not in notes_of(o, 0) and not tmr["sun"]["forecast"] and tmr["batteries"][0]["levels"][-1][0] <= 6.0 + 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() if fails: print(f"{len(fails)} FAILED") sys.exit(1) print("all passed")