diff --git a/goodwe_controller/app/ui_text/en.json b/goodwe_controller/app/ui_text/en.json
index a7801cc..2581273 100644
--- a/goodwe_controller/app/ui_text/en.json
+++ b/goodwe_controller/app/ui_text/en.json
@@ -45,7 +45,7 @@
},
"home": {
"app": {"name": "Battery add-on"},
- "tabs": {"home": "Home", "batteries": "Batteries", "settings": "Settings", "setup": "Setup", "label": "Screens"},
+ "tabs": {"home": "Home", "batteries": "Batteries", "forecast": "Forecast", "settings": "Settings", "setup": "Setup", "label": "Screens"},
"now": {"name": "Now", "description": "Updated every 3 seconds.", "solar": "Solar", "house": "House", "grid": "Grid", "batteries": "Batteries"},
"plan": {
"name": "Today's plan",
@@ -68,6 +68,24 @@
"no_prices": {"description": "No prices for today, so the chart shows only the plan."},
"list": "The plan in words"
},
+ "forecast": {
+ "name": "Forecast",
+ "day": "Day",
+ "today": "Today",
+ "tomorrow": "Tomorrow",
+ "sun": "Solar and house",
+ "unit": "kWh per hour",
+ "forecast": "Solar forecast",
+ "measured": "Measured solar",
+ "house": "House use",
+ "levels": "Battery level",
+ "table": "Hour by hour",
+ "hour": "Hour",
+ "solar": "Solar",
+ "got": "Measured",
+ "use": "House",
+ "loading": "Loading the forecast."
+ },
"peak": {"name": "Peak limit", "this_month": "This month's peak", "limit": "Limit", "none": "None recorded yet", "unreadable": "Cannot be read now"},
"attention": {"name": "Needs attention", "nothing": "Nothing to look at", "to_look": "to look at", "details": "Details", "alarm": "Alarm", "warning": "Warning", "info": "Info"},
"control": {"name": "Control", "stop": "Stop control", "start": "Start control", "maint_start": "Start a battery cycle now", "maint_abort": "Stop the battery cycle", "docs": "Add-on documentation", "more": "More actions"},
@@ -425,6 +443,22 @@
"known": "Tomorrow's prices are in.",
"unknown": "Tomorrow's prices usually arrive after 13:00."
},
+ "day": {
+ "modes_off": "Battery modes are off. No forecast is read and no level is planned.",
+ "not_set": "No solar forecast is set up. Without one, no battery level is planned.",
+ "no_forecast": "The solar forecast is missing or too old. The levels return when it is back.",
+ "learning": "The add-on is learning your house's use. The levels start after one full day.",
+ "no_tomorrow": "There is no solar forecast for tomorrow yet. The levels stop at {at}.",
+ "estimate": "The forecast gives one total for the day. The add-on spreads it over the hours.",
+ "trust": "The levels count on {pct} % of the forecast.",
+ "cycle": "{name} does its monthly cycle. Its level is not shown once the cycle starts.",
+ "no_level": "{name} has no level reading yet. Its level is not shown.",
+ "measured_from": "Measured solar is counted from {at}, when the add-on started.",
+ "tomorrow_total": "Solar expected tomorrow: {kwh}, we count on {count}.",
+ "no_blocks": "No charge or give blocks planned.",
+ "give_later": "The add-on plans to give power. No block shows while a battery has no energy to give.",
+ "ahead": "The add-on plans about a day ahead. Blocks for later hours appear as they come near."
+ },
"region": {
"preset": "Levies and VAT: {region}, {year} figures.",
"own": "Levies and VAT: your own figure."
diff --git a/goodwe_controller/app/ui_text/nl.json b/goodwe_controller/app/ui_text/nl.json
index b9ad0ca..acb0145 100644
--- a/goodwe_controller/app/ui_text/nl.json
+++ b/goodwe_controller/app/ui_text/nl.json
@@ -42,7 +42,7 @@
"applies": {"restart": {"name": "Geldt na een herstart"}, "now": {"name": "Geldt meteen"}},
"home": {
"app": {"name": "Batterij-add-on"},
- "tabs": {"home": "Start", "batteries": "Batterijen", "settings": "Instellingen", "setup": "Installatie", "label": "Schermen"},
+ "tabs": {"home": "Start", "batteries": "Batterijen", "forecast": "Prognose", "settings": "Instellingen", "setup": "Installatie", "label": "Schermen"},
"now": {"name": "Nu", "description": "Elke 3 seconden bijgewerkt.", "solar": "Zonnepanelen", "house": "Huis", "grid": "Net", "batteries": "Batterijen"},
"plan": {
"name": "Plan voor vandaag",
@@ -65,6 +65,24 @@
"no_prices": {"description": "Geen prijzen voor vandaag, dus de grafiek toont alleen het plan."},
"list": "Het plan in woorden"
},
+ "forecast": {
+ "name": "Prognose",
+ "day": "Dag",
+ "today": "Vandaag",
+ "tomorrow": "Morgen",
+ "sun": "Zon en huis",
+ "unit": "kWh per uur",
+ "forecast": "Zonneprognose",
+ "measured": "Gemeten zon",
+ "house": "Huisverbruik",
+ "levels": "Laadniveau",
+ "table": "Uur per uur",
+ "hour": "Uur",
+ "solar": "Zon",
+ "got": "Gemeten",
+ "use": "Huis",
+ "loading": "De prognose wordt geladen."
+ },
"peak": {"name": "Piekgrens", "this_month": "Piek van deze maand", "limit": "Grens", "none": "Nog niets gemeten", "unreadable": "Nu niet leesbaar"},
"attention": {"name": "Vraagt aandacht", "nothing": "Niets om na te kijken", "to_look": "na te kijken", "details": "Details", "alarm": "Alarm", "warning": "Waarschuwing", "info": "Info"},
"control": {"name": "Regeling", "stop": "Regeling stoppen", "start": "Regeling starten", "maint_start": "Nu een batterijcyclus starten", "maint_abort": "Batterijcyclus stoppen", "docs": "Documentatie van de add-on", "more": "Meer acties"},
@@ -353,6 +371,22 @@
"period_unmeasured": "Een deel van dit kwartier is niet gemeten.",
"prices": {"now": "Prijs nu {eur} per kWh, alles inbegrepen.", "today": "Vandaag: laagste {lo} om {lo_at}, hoogste {hi} om {hi_at}.", "none": "Geen prijzen voor vandaag."},
"tomorrow": {"known": "De prijzen van morgen zijn binnen.", "unknown": "De prijzen van morgen komen meestal na 13:00."},
+ "day": {
+ "modes_off": "Batterijmodi staan uit. Er wordt geen prognose gelezen en geen niveau gepland.",
+ "not_set": "Er is geen zonneprognose ingesteld. Zonder prognose wordt geen laadniveau gepland.",
+ "no_forecast": "De zonneprognose ontbreekt of is te oud. De niveaus komen terug als ze er weer is.",
+ "learning": "De add-on leert het verbruik van uw huis. De niveaus starten na één volle dag.",
+ "no_tomorrow": "Er is nog geen zonneprognose voor morgen. De niveaus stoppen om {at}.",
+ "estimate": "De prognose geeft één totaal voor de dag. De add-on verdeelt het over de uren.",
+ "trust": "De niveaus rekenen op {pct} % van de prognose.",
+ "cycle": "{name} doet de maandelijkse cyclus. Het niveau wordt niet getoond zodra de cyclus start.",
+ "no_level": "{name} geeft nog geen laadniveau door. Het niveau wordt niet getoond.",
+ "measured_from": "Gemeten zon telt vanaf {at}, toen de add-on startte.",
+ "tomorrow_total": "Verwachte zon morgen: {kwh}, we rekenen op {count}.",
+ "no_blocks": "Geen laad- of leverblokken gepland.",
+ "give_later": "De add-on plant stroom te leveren. Er verschijnt geen blok zolang een batterij niets kan leveren.",
+ "ahead": "De add-on plant ongeveer een dag vooruit. Blokken voor latere uren verschijnen als ze naderen."
+ },
"region": {"preset": "Heffingen en btw: {region}, cijfers van {year}.", "own": "Heffingen en btw: uw eigen bedrag."},
"peak_not_covered": "Geen piekgrens: deze meter meet geen kwartieren.",
"control": {"shadow": "Schaduwmodus: elke omvormer blijft op 0 W.", "running": "De regeling is actief.", "stopped": "De regeling is gestopt. Elke omvormer staat op 0 W."},
diff --git a/goodwe_controller/app/web.py b/goodwe_controller/app/web.py
index e520ea6..d6a0551 100644
--- a/goodwe_controller/app/web.py
+++ b/goodwe_controller/app/web.py
@@ -150,6 +150,12 @@ def build_app(controller, selfcfg=None) -> web.Application:
return web.json_response(controller.status_payload())
return web.json_response(controller.status_payload(lang=lang))
+ async def forecast(request):
+ """UX-54: the Forecast tab's data, built when the tab asks. Reads only."""
+ build = getattr(controller, "forecast_payload", None)
+ lang = uitext.lang_of(request.query.get("lang"))
+ return web.json_response(build(lang=lang) if build else {"ok": False})
+
async def action(request):
try:
body = await request.json()
@@ -471,6 +477,7 @@ def build_app(controller, selfcfg=None) -> web.Application:
app.add_routes([
web.get("/", index),
web.get("/status", status),
+ web.get("/forecast", forecast),
web.post("/action", action),
web.get("/config", config_index),
web.get("/config/model", config_model),
diff --git a/goodwe_controller/test_home.py b/goodwe_controller/test_home.py
index b1db9e9..639223a 100644
--- a/goodwe_controller/test_home.py
+++ b/goodwe_controller/test_home.py
@@ -831,10 +831,12 @@ check("the page is assembled: no placeholder is left and the words parse, "
"/*APP_CSS*/" not in PAGE and "/*APP_JS*/" not in PAGE
and "/*UI_TEXT*/" not in PAGE
and _WORDS["en"]["home"] == UI and set(_WORDS) == {"en", "nl"})
-check("four tabs with the four hash routes, in a keyboard tablist",
+check("five tabs with the five hash routes, in a keyboard tablist (UX-54 "
+ "adds Forecast beside Home and Batteries)",
re.findall(r'data-hash="(\w+)"', PAGE) == ["home", "batteries",
- "settings", "setup"]
- and 'role="tablist"' in PAGE and PAGE.count('role="tabpanel"') == 4
+ "forecast", "settings",
+ "setup"]
+ and 'role="tablist"' in PAGE and PAGE.count('role="tabpanel"') == 5
and "ArrowRight" in JS and "ArrowLeft" in JS)
_ext = re.findall(r"""(?:https?:)?//[A-Za-z0-9.-]+\.[a-z]{2,}""", PAGE)
check(f"no external file and no web font: no URL leaves the add-on "
diff --git a/goodwe_controller/test_selfconfig.py b/goodwe_controller/test_selfconfig.py
index 81bd773..555c193 100644
--- a/goodwe_controller/test_selfconfig.py
+++ b/goodwe_controller/test_selfconfig.py
@@ -636,10 +636,12 @@ _app = web.build_app(FakeController(), FakeSupervisor())
_routes = sorted({(r.method, r.get_info().get("path"))
for r in _app.router.routes() if r.method != "HEAD"})
print(" " + " ".join(f"{m} {p}" for m, p in _routes))
-check("the configuration surface adds five routes and UX-13 one GET, and "
- "none touches the three that were there",
+check("the configuration surface adds five routes, UX-13 one GET and "
+ "UX-54 one more GET (the Forecast tab), and none touches the three "
+ "that were there",
_routes == [("GET", "/"), ("GET", "/config"), ("GET", "/config/model"),
- ("GET", "/ha/entities"), ("GET", "/status"),
+ ("GET", "/forecast"), ("GET", "/ha/entities"),
+ ("GET", "/status"),
("POST", "/action"), ("POST", "/config/restart"),
("POST", "/config/save"), ("POST", "/config/validate")])
_ents_src = io.open(os.path.join(HERE, "app", "haentities.py"),
diff --git a/goodwe_controller/test_ux54.py b/goodwe_controller/test_ux54.py
new file mode 100644
index 0000000..8d6496f
--- /dev/null
+++ b/goodwe_controller/test_ux54.py
@@ -0,0 +1,1462 @@
+"""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 dataclasses
+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, packshare, peakguard, solarfc, uitext # noqa: E402
+from app.main import pack_energy_of # noqa: E402
+from app.price import Uncertainty # noqa: E402
+from app import daycurve # 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)
+# Where a giving battery reaches the floor, worked out by hand. 10 kWh at
+# 25 % over a 20 % floor holds 0.05 x 10 x 0.95 = 0.475 kWh to give.
+
+
+def floor_at(out, name):
+ """`(seconds after T0, level)` of the first point at the floor."""
+ return next(((round((t - T0).total_seconds(), 3), v) for t, v in out[name]
+ if abs(v - 20.0) < 1e-9), (None, None))
+
+
+fc1 = run(Batt("a", 10.0, 5.0, 25.0, True, False), flat(0.0), flat(2.0))
+check("one battery, 2 kW short: empty at 0.475 / 2 h = 14:15 (855 s), a point "
+ f"there at the floor ({floor_at(fc1, 'a')})",
+ floor_at(fc1, "a") == (855.0, 20.0))
+fc2 = run([Batt("a", 10.0, 5.0, 25.0, True, False),
+ Batt("b", 20.0, 4.0, 80.0, True, False)], flat(0.0), flat(5.0))
+check("two that may give, 5 kW short: shared by what they hold (0.475 : "
+ "11.4 kWh), B held to its 4 kW, so A gives 1 kW and is empty at "
+ f"28:30 (1710 s) ({floor_at(fc2, 'a')})",
+ floor_at(fc2, "a") == (1710.0, 20.0))
+GV1 = ((T0, T0 + timedelta(hours=2)),)
+fc3 = run([Batt("g", 20.0, 2.0, 80.0, False, True, (), GV1),
+ Batt("h", 10.0, 5.0, 25.0, True, False)], flat(0.0), flat(2.95))
+check("the second group: G in a give run gives first, at its 2 kW; the 0.95 "
+ "kW left goes to H (Solar first), empty at 0.475 / 0.95 h = 30:00 "
+ f"(1800 s) ({floor_at(fc3, 'h')})",
+ floor_at(fc3, "h") == (1800.0, 20.0))
+fc4 = run([Batt("a", 10.0, 5.0, 25.0, True, False),
+ Batt("b", 20.0, 5.0, 80.0, True, False)], flat(0.0), flat(3.0))
+check("shared by what they hold, not by what they can give in a step: 3 kW "
+ "short, A (0.475 kWh) and B (11.4 kWh) drain in step and are both "
+ "empty when the 11.875 kWh is gone, at 3:57:30 (14250 s) "
+ f"({floor_at(fc4, 'a')}, {floor_at(fc4, 'b')})",
+ floor_at(fc4, "a") == (14250.0, 20.0)
+ and floor_at(fc4, "b") == (14250.0, 20.0))
+fc5 = curve([Batt("a", 10.0, 5.0, 20.0, True, False),
+ Batt("b", 20.0, 2.0, 80.0, True, False)], T0,
+ T0 + timedelta(hours=4), flat(0.0), flat(7.0), floor_pct=20.0,
+ ceiling_pct=95.0)
+check("a battery already at the floor sets no cut: A at 20 %, B held to its "
+ "2 kW for 4 h (it holds 11.4 kWh): one point per hour "
+ f"({len(fc5['a'])} points)",
+ len(fc5["a"]) == 5 and len(fc5["b"]) == 5)
+st_cap, real_max = {}, daycurve.MAX_FLOOR_CUTS
+daycurve.MAX_FLOOR_CUTS = 0
+try:
+ fc6 = run(Batt("a", 10.0, 5.0, 25.0, True, False), flat(0.0), flat(2.0),
+ stats=st_cap)
+finally:
+ daycurve.MAX_FLOOR_CUTS = real_max
+st_ok = {}
+run(Batt("a", 10.0, 5.0, 25.0, True, False), flat(0.0), flat(2.0),
+ stats=st_ok)
+check("the curve says when its bound on floor points stopped one (`stats`): "
+ f"{st_cap} at a bound of 0, {st_ok} as shipped",
+ st_cap == {"floor_cuts": 0, "capped": True}
+ and st_ok == {"floor_cuts": 1, "capped": False}
+ and floor_at(fc6, "a") == (3600.0, 20.0))
+# 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 cut_out_at_floor(c):
+ """Each pack cuts out at the add-on's floor. Home, the plan and the curve
+ stop giving there; control stops a pack at its own cut-out (the `pack`
+ claim, `packshare.envelope()`). With a lower cut-out a battery in a give
+ run covers the house below the floor, which none of them draws
+ (DECISIONS, Limits)."""
+ floor_, _ceil = arbitrage.soc_bounds(c.o)
+ for inv in c.inverters:
+ inv.bms_soc_floor = floor_
+
+
+def may_give(inv):
+ """Control's own answer: is this pack asked to discharge at all?"""
+ return packshare.envelope(pack_energy_of(inv)).hi > 0.0
+
+
+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. A GIVE role on a pack that control's discharge envelope closes
+ is seen as "closed": the pack is not asked to give. The tab is built
+ once, at `now0`."""
+ c = S.make(name, now0, soc=soc)
+ cut_out_at_floor(c)
+ 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)
+ for inv in c.inverters:
+ role = tick.roles.get(inv.name)
+ seen[inv.name][t] = "closed" if role == modetrade.GIVE \
+ and not may_give(inv) else role
+ t += Q
+ return c, out, seen, d0
+
+
+def role_at(seen, name, d0, h):
+ """The role seen in the quarter that starts at hour `h` from `d0`."""
+ return next((r for t, r in seen[name].items()
+ if 0 <= (t - d0).total_seconds() / 3600.0 - h < 0.25), None)
+
+
+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"]
+fl2 = arbitrage.soc_bounds(c2.o)[0]
+print(" peak guard, 20 %: level at 17:00", [level_at(b, 17.0) for b in tm2],
+ "floor", fl2, "give runs", [(homeview.hhmm(g.start), homeview.hhmm(g.end))
+ for g in c2.mode_runs.give])
+check("F2, the UX-53 gate: while the peak guard holds control never "
+ "charges tomorrow and the tab draws no grid block. The give run starts "
+ "at 17:00 with the curve at the floor on both (the 3 kWh of sun is "
+ "less than the house uses), so no give block, and control's GIVE role "
+ "there has its discharge closed",
+ c2.mode_gate == "peak"
+ and any(homeview.hhmm(g.start) == "17:00" for g in c2.mode_runs.give)
+ and all(level_at(b, 17.0) == fl2 for b in tm2)
+ and all(not b["blocks"] for b in tm2)
+ and all(role_at(s2, n, d02, 41.0) == "closed" for n in s2)
+ 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))
+
+
+# Control walked with its own model of the packs, not the curve's levels:
+# every 15 minutes the real `_mode_roles()`; a battery in CHARGE charges at
+# its rating up to `charge_to()`; spare sun goes to the batteries whose range
+# may charge, by room, up to the ceiling; a shortfall is covered by those
+# whose range may give and whose own discharge envelope is open, by what
+# they hold above the cut-out (at the floor), each at most its rating, what
+# one cannot take passed to the others (a water fill, as the allocator's
+# `_fill()`). The house is the profile the tab reads, the sun the forecast
+# at 100 % trust. Per battery and quarter tomorrow, control charges (gives)
+# exactly where the tab has a grid (give) block, both ways, no tolerance.
+# Each tab build is also measured: the curves' floor cuts (`stats`), the
+# level points per battery and day, and the fastest of five builds.
+def water(amount, caps, weights):
+ """`amount` shared by `weights`, none above its cap, the rest passed on."""
+ got = {n: 0.0 for n in caps}
+ live = {n for n in caps if caps[n] > 1e-12}
+ left = amount
+ while left > 1e-12 and live:
+ w = sum(weights[n] for n in live)
+ if w <= 0.0:
+ break
+ over = {n for n in live
+ if left * weights[n] / w >= caps[n] - got[n] - 1e-12}
+ if not over:
+ for n in live:
+ got[n] += left * weights[n] / w
+ break
+ for n in over:
+ left -= caps[n] - got[n]
+ got[n] = caps[n]
+ live.discard(n)
+ return got
+
+
+def built(c, now):
+ """The tab, with `(floor cuts, capped, fastest of 5 builds in s)`."""
+ stats, real = {}, dayview.curve
+
+ def counted(*a, **k):
+ return real(*a, stats=stats, **k)
+ dayview.curve = counted
+ try:
+ out = tab(c, now)
+ cut = (stats.get("floor_cuts", 0), stats.get("capped", False))
+ st = home(c, now)
+ fast = []
+ for _ in range(5):
+ t0_ = time.perf_counter()
+ dayview.build(c, st, now)
+ fast.append(time.perf_counter() - t0_)
+ finally:
+ dayview.curve = real
+ return out, cut + (min(fast),)
+
+
+def own_walk(soc, *, guard=None, sun=(0.0, 0.0), evening=None, max_w=None):
+ now0 = t22
+ c = S.make("dynamic", now0, soc=soc)
+ cut_out_at_floor(c)
+ if max_w is not None:
+ for inv in c.inverters:
+ inv.max_w = max_w
+ if evening is not None:
+ c.house_profile = S.profile(now0, evening=evening)
+ floor_, ceil_ = arbitrage.soc_bounds(c.o)
+ c.tariff_plan = S.quarters(now0, night_evening, days=3)
+ c.tariff_at = now0
+ if guard is not None:
+ c.peak_guard = guard
+ c.solar_trust_pct = 100
+ c.solar_fc = fc = S.forecast(now0, sun[0], sun[1])
+ c._mode_roles(now0, None)
+ out, cost = built(c, now0)
+ house_w, base_w = c.house_profile.estimator()
+ d0, _d1 = homeview.local_day(now0)
+ seen = {inv.name: {} for inv in c.inverters}
+ t = now0
+ while t < d0 + timedelta(hours=48):
+ c.tariff_at = t
+ tick = c._mode_roles(t, None)
+ targets = c.plan_targets()
+ packs = {p.name: p for p in c._mode_packs()}
+ house = max(house_w(S.brussels(t).hour), base_w) / 1000.0 * 0.25
+ net = (dayview.sun_kwh(fc, now0, t, t + Q) or 0.0) - house
+ takers, givers = [], []
+ for inv in c.inverters:
+ role = tick.roles.get(inv.name)
+ lo, hi = tick.ranges.get(inv.name, (0.0, 0.0))
+ act = None
+ if role == modetrade.CHARGE:
+ to = modetrade.charge_to(packs[inv.name], ceil_, targets)
+ step = min(inv.max_w / 1000.0 * 0.25 * dayplan.EFF,
+ max(0.0, to - inv.soc) / 100.0 * inv.capacity_kwh)
+ act = "grid" if step > 1e-9 else None
+ inv.soc += step / inv.capacity_kwh * 100.0
+ else:
+ if lo < 0.0 and inv.soc < ceil_:
+ takers.append(inv)
+ if hi > 0.0 and may_give(inv):
+ givers.append(inv)
+ act = "give" if role == modetrade.GIVE else None
+ seen[inv.name][t] = act
+ if net > 0.0:
+ room = {i.name: (ceil_ - i.soc) / 100.0 * i.capacity_kwh
+ / dayplan.EFF for i in takers}
+ tot = sum(room.values())
+ for i in takers:
+ part = min(net * room[i.name] / tot, room[i.name],
+ i.max_w / 1000.0 * 0.25) if tot > 0.0 else 0.0
+ i.soc += part * dayplan.EFF / i.capacity_kwh * 100.0
+ elif net < 0.0:
+ hold = {i.name: max(0.0, i.soc - floor_) / 100.0 * i.capacity_kwh
+ * dayplan.EFF for i in givers}
+ got = water(-net, {i.name: min(hold[i.name], i.max_w / 1000.0
+ * 0.25) for i in givers}, hold)
+ for i in givers:
+ i.soc = max(floor_, i.soc - got[i.name] / dayplan.EFF
+ / i.capacity_kwh * 100.0)
+ t += Q
+ return out, seen, d0, cost
+
+
+def own_match(out, seen, d0):
+ """`[(name, kind, hour, who)]`: the quarters tomorrow where control and
+ the tab disagree (who acts there alone)."""
+ bad = []
+ for i, name in enumerate(seen):
+ for kind in ("grid", "give"):
+ blocks = [r for r in out["days"][1]["batteries"][i]["blocks"]
+ if r["kind"] == kind]
+ for t, a in seen[name].items():
+ h = (t - d0).total_seconds() / 3600.0 - 24.0
+ if h < 0.0:
+ continue
+ inside = any(r["start_h"] - 1e-6 <= h < r["end_h"] - 1e-6
+ for r in blocks)
+ if (a == kind) != inside:
+ bad.append((name, kind, round(h, 2),
+ "control" if a == kind else "tab"))
+ return bad
+
+
+def give_span(seen, d0, name):
+ hs = [(t - d0).total_seconds() / 3600.0 - 24.0
+ for t, a in seen[name].items()
+ if a == "give" and (t - d0).total_seconds() >= 86400]
+ return (round(min(hs), 2), round(max(hs) + 0.25, 2)) if hs else None
+
+
+own = {}
+for key, soc, g, sun, *more in (("20/20", (20.0, 20.0), None, (0.0, 0.0)),
+ ("55/40", (55.0, 40.0), None, (0.0, 0.0)),
+ ("90/70", (90.0, 70.0), None, (0.0, 0.0)),
+ ("30/80", (30.0, 80.0), None, (0.0, 0.0)),
+ ("60/60 guard", (60.0, 60.0), guard, (0.0, 0.0)),
+ ("20/20 guard", (20.0, 20.0), guard, (0.0, 0.0)),
+ ("30/30 guard", (30.0, 30.0), guard, (0.0, 0.0)),
+ ("25/45 guard", (25.0, 45.0), guard, (0.0, 0.0)),
+ ("20/20 guard 14 kWh", (20.0, 20.0), guard,
+ (14.0, 14.0)),
+ ("20/20 guard 6 kWh", (20.0, 20.0), guard,
+ (6.0, 6.0)),
+ ("20/20 8 kWh", (20.0, 20.0), None, (8.0, 8.0)),
+ # one battery empty, the other held to its rating
+ ("30/80 guard 7 kW house", (30.0, 80.0), guard,
+ (0.0, 0.0), 7000.0, None),
+ ("30/80 guard 2 x 3 kW, 3.5 kW house", (30.0, 80.0),
+ guard, (0.0, 0.0), 3500.0, 3000.0)):
+ evening_, max_w_ = (more + [None, None])[:2]
+ o_w, s_w, d_w, cost = own_walk(soc, guard=g, sun=sun, evening=evening_,
+ max_w=max_w_)
+ pts = max(len(b["levels"]) for d in o_w["days"] for b in d["batteries"])
+ own[key] = (o_w, s_w, d_w, own_match(o_w, s_w, d_w), cost, pts)
+ print(f" own walk {key}: tab",
+ [[(r["kind"], r["from"], r["to"]) for r in b["blocks"]]
+ for b in o_w["days"][1]["batteries"]],
+ "control gives", {n: give_span(s_w, d_w, n) for n in s_w},
+ "mismatched", len(own[key][3]), own[key][3][:4],
+ f"| floor cuts {cost[0]}, capped {cost[1]}, {pts} points, "
+ f"{cost[2] * 1000:.0f} ms")
+check("F2, control walked with its own model of the packs (not the curve's "
+ "levels): in all 13 cases it charges and gives in exactly the quarters "
+ "the tab has a block of that kind, on both batteries",
+ len(own) == 13 and all(not v[3] for v in own.values()))
+MAX_CUTS, MAX_POINTS, MAX_S = 40, 40, 0.2
+big = {k: (v[4], v[5]) for k, v in own.items() if v[4][1]
+ or v[4][0] > MAX_CUTS or v[5] > MAX_POINTS or v[4][2] >= MAX_S}
+check(f"...and every build stays small: at most {MAX_CUTS} floor cuts over "
+ f"all its curves, never the curve's bound (`MAX_FLOOR_CUTS`), at most "
+ f"{MAX_POINTS} level points per battery and day, and under {MAX_S:g} s "
+ f"(the fastest of five builds on this host) {big}",
+ all(v[4][0] <= MAX_CUTS and not v[4][1] and v[5] <= MAX_POINTS
+ and v[4][2] < MAX_S for v in own.values()))
+o_7, s_7, d_7 = own["30/80 guard 7 kW house"][:3]
+check("...30/80 % with the guard and a 7 kW evening house (A soon empty, B "
+ "held to its rating): control gives A 17:00-17:45 and B 17:00-19:45, "
+ "and B's give block ends in control's last giving quarter, not at 20:00",
+ give_span(s_7, d_7, "inverter-a") == (17.0, 17.75)
+ and give_span(s_7, d_7, "inverter-b") == (17.0, 19.75)
+ and 19.5 < o_7["days"][1]["batteries"][1]["blocks"][-1]["end_h"]
+ <= 19.75)
+o_g, s_g, d_g, _b = own["25/45 guard"][:4]
+check("...25/45 % with the peak guard: control gives 17:00-20:15 on both "
+ "(the packs empty in the 20:00 quarter), and the tab's give block "
+ "starts at 17:00 and ends inside that quarter, at the curve's floor "
+ "point",
+ all(give_span(s_g, d_g, n) == (17.0, 20.25) for n in s_g)
+ and all([(r["kind"], r["from"]) for r in b["blocks"]] == [("give",
+ "17:00")]
+ and 20.0 < b["blocks"][0]["end_h"] <= 20.25
+ for b in o_g["days"][1]["batteries"]))
+o_6, s_6, d_6, _b = own["20/20 guard 6 kWh"][:4]
+check("...20/20 % with the guard and 6 kWh of sun: control gives 17:00-17:15, "
+ "and the tab's give block ends inside that quarter",
+ all(give_span(s_6, d_6, n) == (17.0, 17.25) for n in s_6)
+ and all(len(b["blocks"]) == 1 and 17.0 < b["blocks"][0]["end_h"] <= 17.25
+ for b in o_6["days"][1]["batteries"]))
+fl_g = arbitrage.soc_bounds(S.make("dynamic", t22).o)[0]
+check("...and the curve has a point exactly at the floor where the block "
+ "ends (no step end)",
+ all([v for h, v in b["levels"] if abs(h - b["blocks"][0]["end_h"])
+ < 1e-3] == [fl_g] for b in o_g["days"][1]["batteries"]))
+
+
+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)
+
+# Modes off: no forecast is read and no house profile kept, so there is no
+# curve, and tomorrow's blocks are Home's own count (`dayview._counted()`).
+# Control is walked every 15 minutes from 22:00 to the end of tomorrow on a
+# cloudy day (no sun), the packs moved by what control does:
+# `arbitrage.decide()` with the inputs `Controller.cycle()` gives it (the
+# emptiest pack, `trade_power_w()`, the guard's headroom), and between trades
+# each pack above its cut-out covers its share of the house. The tab is
+# built once, at 22:00. Each price extreme is one quarter, so a leg is one
+# quarter: per battery, control charges and gives in exactly the quarters
+# the tab has a block of that kind, in both directions.
+HOUSE_W = 400.0
+
+
+def one_quarter(cheap, dear):
+ """Prices with one cheap and one dear quarter a day (hours), 0.20 else."""
+ return lambda h: 0.12 if h == cheap else 0.45 if h == dear else 0.20
+
+
+def off_sweep(price_of, now0, soc):
+ c = S.make("off", now0, soc=soc)
+ cut_out_at_floor(c)
+ c.tariff_plan = S.quarters(now0, price_of, days=3)
+ c.tariff_at = now0
+ # the guard has measured the quarter: 6 kW of charging headroom
+ c.peak_guard = dataclasses.replace(c.peak_guard, wanted_w=-6000.0)
+ out = tab(c, now0)
+ floor_, ceil_ = arbitrage.soc_bounds(c.o)
+ d0, _d1 = homeview.local_day(now0)
+ seen = {inv.name: {} for inv in c.inverters}
+ t = now0
+ while t < d0 + timedelta(hours=48):
+ c.tariff_at = t
+ charge_w, discharge_w = c.trade_power_w(floor_, ceil_)
+ d = arbitrage.decide(
+ c.fresh_tariff_plan(t), t,
+ peak_guard_active=arbitrage.guard_state(c.peak_guard),
+ charge_headroom_w=peakguard.charge_headroom_w(c.peak_guard),
+ soc=c.pack_floor_soc,
+ min_spread_eur_kwh=arbitrage.threshold_eur_kwh(c.o),
+ uncertainty=frozenset({Uncertainty.NO_WEATHER}),
+ charge_w=charge_w, discharge_w=discharge_w,
+ soc_floor_pct=floor_, soc_ceiling_pct=ceil_)
+ able = [inv for inv in c.inverters if may_give(inv)]
+ for inv in c.inverters:
+ cap, act = float(inv.capacity_kwh), None
+ if d.action == arbitrage.CHARGE and inv.soc < ceil_:
+ act = "grid"
+ kw = abs(d.power_w) * inv.max_w / charge_w / 1000.0
+ inv.soc = min(ceil_, inv.soc + kw * 0.25 * dayplan.EFF
+ / cap * 100.0)
+ elif d.action == arbitrage.DISCHARGE and inv.soc > floor_ \
+ and may_give(inv):
+ act = "give"
+ inv.soc = max(floor_, inv.soc - inv.max_w / 1000.0 * 0.25
+ / dayplan.EFF / cap * 100.0)
+ elif d.action is None and inv in able:
+ inv.soc = max(floor_, inv.soc - HOUSE_W / len(able) / 1000.0
+ * 0.25 / dayplan.EFF / cap * 100.0)
+ seen[inv.name][t] = act
+ t += Q
+ return c, out, seen, d0
+
+
+def acts(seen, d0, kind):
+ """`{name: [hour, ...]}` tomorrow where control does `kind`."""
+ return {name: [round((t - d0).total_seconds() / 3600.0 - 24.0, 2)
+ for t, a in row.items() if a == kind
+ and (t - d0).total_seconds() >= 86400]
+ for name, row in seen.items()}
+
+
+def off_match(out, seen, d0):
+ """Per battery, tomorrow: control does `kind` in exactly the quarters
+ the tab has a block of that kind."""
+ for kind in ("grid", "give"):
+ got = acts(seen, d0, kind)
+ for i, name in enumerate(seen):
+ hs = [round(r["start_h"] + 0.25 * k, 2)
+ for r in out["days"][1]["batteries"][i]["blocks"]
+ if r["kind"] == kind
+ for k in range(round((r["end_h"] - r["start_h"]) / 0.25))]
+ if hs != got[name]:
+ print(" ", name, kind, "tab", hs, "control", got[name])
+ return False
+ return True
+
+
+def kinds(out):
+ return [[(r["kind"], r["from"], r["to"]) for r in b["blocks"]]
+ for b in out["days"][1]["batteries"]]
+
+
+# A: at the floor, a cheap quarter at 05:00, a dear one at 07:00.
+ca, oa, sa, da = off_sweep(one_quarter(5.0, 7.0), t22, (20.0, 20.0))
+print(" modes off A, 20 %, cheap 05:00, dear 07:00, no sun: tab", kinds(oa),
+ "control", acts(sa, da, "grid"), acts(sa, da, "give"))
+check("F2, modes off: both batteries at the floor at 22:00, a charge at "
+ "05:00 before the 07:00 give: both blocks on both, and control charges "
+ "and gives in exactly those quarters",
+ kinds(oa) == [[("grid", "05:00", "05:15"), ("give", "07:00", "07:15")]]
+ * 2 and off_match(oa, sa, da))
+# B: the cloudy-day give run. Nothing charges before the give.
+cb, ob, sb, db = off_sweep(one_quarter(13.0, 7.0), t22, (20.0, 20.0))
+print(" modes off B, 20 %, dear 07:00, cheap 13:00, no sun: tab", kinds(ob),
+ "control", acts(sb, db, "grid"), acts(sb, db, "give"))
+v_b, _w = homeview.trade_verdict(cb, cb.fresh_tariff_plan(t22), t22)
+check("F2, modes off, cloudy day: both batteries at the floor at 22:00 and "
+ "nothing charges before the verdict's 07:00 give: no give block on "
+ "either and control never gives; the 13:00 charge block shows and "
+ "control charges in exactly that quarter",
+ v_b is not None and homeview.hhmm(v_b.dearest.start) == "07:00"
+ and kinds(ob) == [[("grid", "13:00", "13:15")]] * 2
+ and all(not v for v in acts(sb, db, "give").values())
+ and off_match(ob, sb, db))
+check("...and each battery says the add-on plans to give, not that it only "
+ "follows the house",
+ all(b["line"] == say("give_later")
+ for b in ob["days"][1]["batteries"]))
+# D: the same day with B at 60 %: the site gives as one, and not while its
+# emptiest pack is at the floor (`arbitrage.decide()` reads the emptiest).
+cd_, od, sd, dd = off_sweep(one_quarter(13.0, 7.0), t22, (20.0, 60.0))
+print(" modes off D, 20 % and 60 %, dear 07:00, cheap 13:00: tab", kinds(od),
+ "control", acts(sd, dd, "grid"), acts(sd, dd, "give"))
+check("F2, modes off: one battery at the floor and one at 60 %: no give block "
+ "on either (the site does not give while its emptiest pack is at the "
+ "floor) and control gives on neither; the charge blocks match",
+ all(not [r for r in b if r[0] == "give"] for b in kinds(od))
+ and all(not v for v in acts(sd, dd, "give").values())
+ and off_match(od, sd, dd))
+c_dn = S.make("off", t22, soc=(20.0, 60.0))
+c_dn.tariff_plan = S.quarters(t22, one_quarter(13.0, 7.0), days=3)
+c_dn.tariff_at = t22
+od_nl = tab(c_dn, t22, "nl")
+check("...and B, which has energy, is not told it has none: the line names "
+ "'a battery' (the emptiest one holds the site back), EN and NL",
+ all(b["line"] == say("give_later") for b in od["days"][1]["batteries"])
+ and say("give_later").endswith(
+ "No block shows while a battery has no energy to give.")
+ and all(b["line"] == say("give_later", "nl")
+ for b in od_nl["days"][1]["batteries"])
+ and say("give_later", "nl").endswith(
+ "Er verschijnt geen blok zolang een batterij niets kan leveren."))
+# C: full at 22:00 and a cheap quarter at 01:00: the house uses some first,
+# so control charges then, and the tab has that block.
+cc, oc, sc, dc = off_sweep(one_quarter(1.0, 17.0), t22, (95.0, 95.0))
+print(" modes off C, 95 %, cheap 01:00, dear 17:00, no sun: tab", kinds(oc),
+ "control", acts(sc, dc, "grid"), acts(sc, dc, "give"))
+check("F2, modes off: both batteries at the ceiling at 22:00, a cheap "
+ "quarter at 01:00 and a dear one at 17:00: the house uses some first, "
+ "so control charges at 01:00 and gives at 17:00, in exactly the "
+ "quarters the tab has a block",
+ kinds(oc) == [[("grid", "01:00", "01:15"), ("give", "17:00", "17:15")]]
+ * 2 and off_match(oc, sc, dc))
+
+# `trade_runs(ahead=f)` reads each leg at `f(name, kind, start)`.
+n0_, n1_ = homeview.local_day(homeview.local_day(t22)[1] + timedelta(hours=2))
+fl_b, ce_b = arbitrage.soc_bounds(cb.o)
+cb.tariff_at = t22
+
+
+def legs_at(lv):
+ return sorted((r["battery"], r["kind"], r["from"]) for r in
+ homeview.runs_for(cb, n0_, n1_, t22, False,
+ ahead=lambda _n, k, _s: lv.get(k)))
+
+
+check("trade_runs(ahead=f): a give leg is read at the level f gives when it "
+ "starts (none at the floor), a charge too (none at the ceiling); f "
+ "None reads a charge from the floor and a give from the ceiling",
+ legs_at({"give": fl_b, "grid": None}) == [(0, "grid", "13:00"),
+ (1, "grid", "13:00")]
+ and legs_at({"give": 60.0, "grid": ce_b}) == [(0, "give", "07:00"),
+ (1, "give", "07:00")]
+ and legs_at({}) == sorted([(0, "give", "07:00"), (1, "give", "07:00"),
+ (0, "grid", "13:00"), (1, "grid", "13:00")]))
+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")
diff --git a/goodwe_controller/test_ux54_dst.py b/goodwe_controller/test_ux54_dst.py
new file mode 100644
index 0000000..850873b
--- /dev/null
+++ b/goodwe_controller/test_ux54_dst.py
@@ -0,0 +1,296 @@
+"""UX-54: the Forecast tab on the two clock-change days.
+`python3 test_ux54_dst.py`
+
+The tab walks local hours (`dayplan._hour_slices()` in `dayview` and
+`daycurve`, `SolarToday`'s hour keys). On the spring change a loop that steps
+on the wall clock never ends (SAFETY-123), and a tab request runs on the
+event loop control runs on. Graded here, every call under a hard time limit
+that the add-on's own `except Exception` cannot swallow (`Hang` is a
+`BaseException`), and a memory limit:
+
+1. The limit itself: a tab build that never returns is stopped and reported,
+ through `forecast_payload()`'s `except Exception`.
+2. `Controller.forecast_payload()` at every quarter of 27-29 March 2027 and
+ 30 October-1 November 2027, modes on (Leftover solar and Cheap hours,
+ with a forecast and a house profile: the level curve) and modes off, in
+ the no-tzdata zone (`tariff._EUTimezone`) and, where the host has tzdata,
+ in ZoneInfo Europe/Brussels: it returns, `ok`, each day has 23 / 24 / 25
+ hours with the local hour labels of that day, every level inside floor
+ and ceiling, every block inside its day.
+3. `SolarToday` keeps the two 02:00 hours of the autumn change apart.
+
+Mutating what this file grades? Read MUTATION.md first (stale bytecode).
+"""
+
+import logging
+import os
+import resource
+import signal
+import sys
+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
+import app.main as M # noqa: E402
+from app import arbitrage, dayplan, dayview, homeview, solarfc # noqa: E402
+from app import tariff # noqa: E402
+from app.daycurve import SolarToday # noqa: E402
+
+UTC = timezone.utc
+fails = []
+LIMIT_S = 10.0 # one call; a build takes well under a second
+try: # a loop that appends without end must not take the host
+ resource.setrlimit(resource.RLIMIT_AS, (3 << 29, resource.getrlimit(
+ resource.RLIMIT_AS)[1]))
+except (ValueError, OSError):
+ pass
+
+
+def check(name, cond):
+ print(f" {'ok ' if cond else 'FAIL'} {name}")
+ if not cond:
+ fails.append(name)
+
+
+class Hang(BaseException):
+ """A call ran over LIMIT_S. Not an `Exception`: `forecast_payload()`
+ catches every `Exception` and would turn it into `{"ok": False}`."""
+
+
+def _alarm(*_a):
+ raise Hang()
+
+
+def limited(fn, *a, **k):
+ """`fn(*a, **k)`, or `Hang` when it takes more than LIMIT_S seconds or
+ runs out of memory."""
+ signal.signal(signal.SIGALRM, _alarm)
+ signal.setitimer(signal.ITIMER_REAL, LIMIT_S)
+ try:
+ return fn(*a, **k)
+ except MemoryError:
+ raise Hang() from None
+ finally:
+ signal.setitimer(signal.ITIMER_REAL, 0)
+
+
+# --------------------------------------------------------------------------- #
+# The clock and the zone
+# --------------------------------------------------------------------------- #
+NOW = [datetime(2027, 1, 1, tzinfo=UTC)]
+
+
+class FakeDT(datetime):
+ @classmethod
+ def now(cls, tz=None):
+ return NOW[0].astimezone(tz) if tz else NOW[0].replace(tzinfo=None)
+
+
+for _mod in (M, homeview, dayview): # what reads the wall clock
+ _mod.datetime = FakeDT
+
+TZ_MODULES = (tariff, dayplan, homeview, solarfc)
+
+
+class zone:
+ """`BILLING_TZ` is `tz` in every module that holds it, in the block."""
+
+ def __init__(self, tz):
+ self.tz = tz
+
+ def __enter__(self):
+ self.was = [m.BILLING_TZ for m in TZ_MODULES]
+ for m in TZ_MODULES:
+ m.BILLING_TZ = self.tz
+
+ def __exit__(self, *_a):
+ for m, tz in zip(TZ_MODULES, self.was):
+ m.BILLING_TZ = tz
+
+
+ZONES = [("no tzdata (_EUTimezone)", tariff._EUTimezone())]
+try:
+ from zoneinfo import ZoneInfo
+ _bru = ZoneInfo("Europe/Brussels")
+ if datetime(2026, 7, 1, tzinfo=UTC).astimezone(_bru).hour == 2:
+ ZONES.append(("ZoneInfo Europe/Brussels", _bru))
+except Exception: # noqa: BLE001
+ pass
+if len(ZONES) == 1:
+ print(" no tzdata on this host: ZoneInfo is not graded here")
+
+# The local days (UTC midnight-to-midnight instants are not used: the days
+# are known by their dates) and their hour labels.
+SPRING, AUTUMN = (2027, 3, 28), (2027, 10, 31)
+WINDOWS = (((2027, 3, 27), SPRING, (2027, 3, 29)),
+ ((2027, 10, 30), AUTUMN, (2027, 11, 1)))
+
+
+def labels_of(ymd):
+ if ymd == SPRING:
+ return ["00:00", "01:00"] + [f"{h:02d}:00" for h in range(3, 24)]
+ if ymd == AUTUMN:
+ return ["00:00", "01:00", "02:00", "02:00"] + [
+ f"{h:02d}:00" for h in range(3, 24)]
+ return [f"{h:02d}:00" for h in range(24)]
+
+
+def next_ymd(ymd):
+ d = datetime(*ymd) + timedelta(days=1)
+ return (d.year, d.month, d.day)
+
+
+def day_start_utc(ymd):
+ """Local midnight of `ymd` in UTC (CET before the change, CEST after it;
+ midnight is never inside a change)."""
+ summer = (2027, 3, 28) < ymd < (2027, 10, 31) or ymd == (2027, 10, 31)
+ if ymd == SPRING:
+ summer = False
+ return datetime(*ymd, tzinfo=UTC) - timedelta(hours=2 if summer else 1)
+
+
+# --------------------------------------------------------------------------- #
+print("1. the time limit cannot be swallowed")
+# --------------------------------------------------------------------------- #
+check("`Hang` is a BaseException and not an Exception",
+ issubclass(Hang, BaseException) and not issubclass(Hang, Exception))
+c0 = limited(S.make, "off",
+ datetime(2027, 1, 12, 11, 0, 5, tzinfo=UTC))
+_real_build = dayview.build
+
+
+def _spin(*_a, **_k):
+ while True:
+ pass
+
+
+dayview.build = _spin
+try:
+ NOW[0] = datetime(2027, 1, 12, 11, 0, 5, tzinfo=UTC)
+ got = limited(c0.forecast_payload, "en")
+except Hang:
+ got = "stopped"
+finally:
+ dayview.build = _real_build
+check("a tab build that never returns is stopped by the limit and reported, "
+ f"not turned into a page answer by forecast_payload() ({got!r})",
+ got == "stopped")
+
+# --------------------------------------------------------------------------- #
+print("\n2. the tab at every quarter of the change days")
+# --------------------------------------------------------------------------- #
+Q = timedelta(minutes=15)
+SCENARIOS = (("modes on", "dynamic", (55.0, 40.0)),
+ ("modes off", "off", (55.0, 40.0)))
+
+
+def wrong(out, ymd, floor_, ceil_):
+ """What is wrong with one build, or None."""
+ if not out.get("ok") or len(out.get("days") or []) != 2:
+ return "not ok"
+ for k, day in enumerate(out["days"]):
+ exp = labels_of((ymd, next_ymd(ymd))[k])
+ hrs = day["hours"]
+ if day["length_h"] != len(exp):
+ return f"day {k}: length {day['length_h']}"
+ if [h["label"] for h in hrs] != exp:
+ return f"day {k}: labels"
+ if any(abs(h["end_h"] - h["start_h"] - 1.0) > 1e-6
+ or abs(h["start_h"] - j) > 1e-6 for j, h in enumerate(hrs)):
+ return f"day {k}: hour spans"
+ for b in day["batteries"]:
+ if any(not floor_ - 1e-6 <= v <= ceil_ + 1e-6
+ or not -1e-6 <= h <= len(exp) + 1e-6
+ for h, v in b["levels"]):
+ return f"day {k}: level {b['name']}"
+ if any(not 0.0 <= r["start_h"] <= r["end_h"] <= len(exp) + 1e-6
+ for r in b["blocks"]):
+ return f"day {k}: block {b['name']}"
+ return None
+
+
+def walk(scn, soc):
+ """`(builds, with a curve, stopped, wrong)` over every quarter of the
+ six local days; the first call stopped by the limit ends the walk."""
+ builds, curves, bad = 0, 0, []
+ for window in WINDOWS:
+ for ymd in window:
+ a = day_start_utc(ymd)
+ z = day_start_utc(next_ymd(ymd))
+ t = a + timedelta(seconds=5)
+ try:
+ NOW[0] = t
+ c = limited(S.make, scn, t, soc=soc)
+ floor_, ceil_ = arbitrage.soc_bounds(c.o)
+ while t < z:
+ NOW[0] = t
+ c.tariff_at = t
+ if c.modes_on:
+ limited(c._mode_roles, t, None)
+ limited(c.solar_today.add, t, 1500.0)
+ out = limited(c.forecast_payload, "en")
+ builds += 1
+ curves += any(len(b["levels"]) > 1
+ for d in out.get("days") or []
+ for b in d["batteries"])
+ why = wrong(out, ymd, floor_, ceil_)
+ if why:
+ bad.append((t.isoformat(), why))
+ t += Q
+ except Hang:
+ return builds, curves, t.isoformat(), bad
+ return builds, curves, None, bad
+
+
+for zname, tz in ZONES:
+ with zone(tz):
+ for label, scn, soc in SCENARIOS:
+ builds, curves, stopped, bad = walk(scn, soc)
+ print(f" {zname}, {label}: {builds} builds ({curves} with a "
+ f"level curve), stopped at {stopped}, {len(bad)} wrong "
+ f"{bad[:3]}")
+ check(f"{zname}, {label}: the tab returns at every quarter of "
+ "27-29 March and 30 October-1 November 2027 (under "
+ f"{LIMIT_S:g} s each), and each day has its 23, 24 or 25 "
+ "hours, labelled, levels inside floor and ceiling, blocks "
+ "inside the day",
+ stopped is None and not bad
+ and builds == (96 + 92 + 96) + (96 + 100 + 96)
+ and (curves == builds if scn == "dynamic" else not curves))
+
+# --------------------------------------------------------------------------- #
+print("\n3. measured solar keeps the two autumn 02:00 hours apart")
+# --------------------------------------------------------------------------- #
+for zname, tz in ZONES:
+ with zone(tz):
+ s = SolarToday()
+ a = day_start_utc(AUTUMN) # 00:00 CEST = 22:00 UTC
+ t = a
+ while t <= a + timedelta(hours=4):
+ # 1 kW in the first 02:00 hour (00:00-01:00 UTC), 3 kW in the
+ # second (01:00-02:00 UTC), nothing else
+ w = 1000.0 if a + timedelta(hours=2) <= t < a + timedelta(
+ hours=3) else 3000.0 if a + timedelta(hours=3) <= t < a + \
+ timedelta(hours=4) else 0.0
+ limited(s.add, t, w)
+ t += timedelta(minutes=1)
+ now = a + timedelta(hours=4, minutes=30)
+ sl = [x for x in limited(dayplan._hour_slices, a, now)
+ if x[2] == 2]
+ got = [None if x is None else round(x, 2) for x in
+ (limited(s.kwh, x[0], x[1], now) for x in sl)]
+ print(f" {zname}: the two 02:00 hours {got}")
+ check(f"{zname}: two 02:00 slices, about 1 kWh and 3 kWh, each in "
+ "its own hour", len(sl) == 2 and got[0] is not None
+ and got[1] is not None and abs(got[0] - 1.0) < 0.06
+ and abs(got[1] - 3.0) < 0.06)
+
+print()
+if fails:
+ print(f"{len(fails)} FAILED")
+ sys.exit(1)
+print("all passed")
diff --git a/goodwe_controller/tools/ux54_scenarios.py b/goodwe_controller/tools/ux54_scenarios.py
new file mode 100644
index 0000000..d30e7e6
--- /dev/null
+++ b/goodwe_controller/tools/ux54_scenarios.py
@@ -0,0 +1,275 @@
+"""UX-54: controllers in a known situation, for test_ux54.py and the browser
+proof (`scratch/UX-54/dev/demo54.py`). Real `Controller`, readings set by hand,
+no Home Assistant. `make(name, now)` returns one; the names are in `SCENARIOS`.
+
+owner modes on, both batteries Solar first, a day/night tariff, a forecast
+ from three state-only sensors (Forecast.Solar's shape), a house
+ profile (the owner's site 2026-10-26)
+dynamic modes on, A Leftover solar and B Cheap hours, 15-minute prices with a
+ cheap night and a dear evening, a dark day (a grid charge is due)
+sunny as dynamic, a sunny day (the sun fills A, no grid charge)
+periods as dynamic with a Solcast-shaped forecast (an hourly split)
+off modes off, arbitrage on, the same prices
+learning modes on, a forecast, and no house profile yet
+nofc modes on, no forecast sensor set
+stale modes on, a sensor set that gives no forecast now
+notomorrow as owner, today's forecast only
+cycle as dynamic, battery A waiting for its monthly cycle
+"""
+
+import asyncio
+import json
+import math
+import os
+import sys
+from datetime import datetime, timedelta, timezone
+
+HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+sys.path.insert(0, HERE)
+
+from app import dayplan, packmode, solarfc # noqa: E402
+from app.main import Controller # noqa: E402
+from app.peakguard import PeakDecision # noqa: E402
+from app.price import PricePoint, PriceProduct, PriceRead, brussels # noqa
+from app.static_tariff import TariffPlan, static_plan # noqa: E402
+from app.store import unit_key # noqa: E402
+
+UTC = timezone.utc
+SCENARIOS = ("owner", "dynamic", "sunny", "periods", "off", "learning",
+ "nofc", "stale", "notomorrow", "cycle")
+
+INV = [{"name": "inverter-a", "setpoint_entity": "number.a",
+ "soc_entity": "sensor.soc_a", "batt_entity": "sensor.batt_a",
+ "max_w": 5000, "capacity_kwh": 15.36},
+ {"name": "inverter-b", "setpoint_entity": "number.b",
+ "soc_entity": "sensor.soc_b", "batt_entity": "sensor.batt_b",
+ "max_w": 4600, "capacity_kwh": 20.48}]
+
+BASE = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
+ "meter_source": "off", "max_w": 9600, "shadow_mode": "off",
+ "maintenance_enabled": False, "arbitrage_enabled": True,
+ "cycle_cost_threshold_eur": 0.10, "pv_entity": "sensor.pv",
+ "inverters": INV}
+FC1 = "sensor.energy_production_today_remaining"
+FCT1 = "sensor.energy_production_tomorrow"
+
+DN = {"periods": [{"name": "day", "from": "07:00", "to": "22:00",
+ "days": "all", "eur_per_kwh": 0.2801},
+ {"name": "night", "from": "22:00", "to": "07:00",
+ "days": "all", "eur_per_kwh": 0.2333}]}
+
+
+class Store:
+ fresh = False
+
+ def __init__(self):
+ self.data = {}
+
+ def set(self, k, v):
+ self.data[k] = v
+
+ def get(self, k, d=None):
+ return self.data.get(k, d)
+
+ def set_time(self, k, v):
+ self.data[k] = v.isoformat()
+
+ def get_time(self, k):
+ v = self.data.get(k)
+ return None if v is None else datetime.fromisoformat(v)
+
+
+class Pub:
+ enabled = False
+
+ def publish(self, _v):
+ pass
+
+ def close(self):
+ pass
+
+
+class Hass:
+ session = None
+ token = ""
+
+ async def limits(self, _e):
+ return (-5000.0, 5000.0, 1.0)
+
+ async def number(self, _e, invert=False):
+ return None
+
+ async def state(self, _e):
+ return None
+
+ async def set_number(self, _e, _v):
+ return True
+
+ async def call(self, *_a):
+ return True
+
+ async def states(self):
+ return []
+
+
+def day0_of(now):
+ return solarfc._bounds(now)[0]
+
+
+def quarters(now, price_of, days=2):
+ """`days` days of 15-minute prices from local midnight; `price_of(h)`."""
+ d0 = day0_of(now)
+ pts = []
+ for i in range(96 * days):
+ t = d0 + timedelta(minutes=15 * i)
+ pts.append(PricePoint(brussels(t), price_of((i % 96) / 4.0), False))
+ return TariffPlan(PriceRead.series(tuple(pts), PriceProduct.DAY_AHEAD,
+ "generic_dayahead", now), None,
+ "provider")
+
+
+def dyn_price(h):
+ if 1.0 <= h < 5.0:
+ return 0.08
+ if 11.0 <= h < 15.0:
+ return 0.14
+ if 17.0 <= h < 21.0:
+ return 0.34
+ return 0.20
+
+
+def profile(now, *, day=600.0, evening=1800.0, night=350.0):
+ """A house profile with three full days: the net use per local hour."""
+ shape = {h: (night if h < 6 or h >= 22 else 900.0 if h < 9
+ else day if h < 17 else evening) for h in range(24)}
+ d = brussels(now).date()
+ return dayplan.HouseProfile(
+ {(d - timedelta(days=k)).isoformat():
+ {str(h): w for h, w in shape.items()} for k in (1, 2, 3)})
+
+
+def sensor(entity, kwh, now, *, periods=None, unit="kWh"):
+ """A forecast sensor's state, as Home Assistant gives it."""
+ attrs = {"unit_of_measurement": unit}
+ if periods:
+ attrs["wh_period"] = periods
+ return {"entity_id": entity, "state": f"{kwh:.3f}", "attributes": attrs,
+ "last_updated": now.isoformat(),
+ "last_reported": now.isoformat()}
+
+
+def bell_periods(now, day_kwh, *, tomorrow=False):
+ """An hourly split (Open-Meteo's `wh_period`) of a bell, for `now`'s day."""
+ d0 = day0_of(now) + (timedelta(days=1) if tomorrow else timedelta(0))
+ out = {}
+ for h in range(24):
+ a = solarfc._bell(h + 1.0) - solarfc._bell(float(h))
+ out[(d0 + timedelta(hours=h)).isoformat()] = round(
+ a * day_kwh * 1000.0, 1)
+ return out
+
+
+def left_today(now, day_kwh):
+ """What the integration says is left today at `now` (state only)."""
+ d0 = day0_of(now)
+ h = (now - d0).total_seconds() / 3600.0
+ return day_kwh * (1.0 - solarfc._bell(h))
+
+
+def forecast(now, today_kwh, tomorrow_kwh, *, split=False, members=1,
+ tomorrow=True):
+ """A `solarfc.Forecast` through `read_sum()`, as the controller reads it.
+ `today_kwh` is the whole day's sun; the sensors say what is left."""
+ left = left_today(now, today_kwh)
+ ts = [(f"{FC1}_{i}", sensor(
+ f"{FC1}_{i}", left / members, now,
+ periods=(bell_periods(now, today_kwh / members) if split else None)))
+ for i in range(members)]
+ tm = [(f"{FCT1}_{i}", sensor(
+ f"{FCT1}_{i}", tomorrow_kwh / members, now,
+ periods=(bell_periods(now, tomorrow_kwh / members, tomorrow=True)
+ if split else None)))
+ for i in range(members)] if tomorrow else []
+ fc, why, _gaps = solarfc.read_sum(ts, tm, now)
+ return fc
+
+
+def measure(c, now, peak_w):
+ """The solar this site made today until `now`, one reading a minute."""
+ d0 = day0_of(now)
+ t = d0
+ while t <= now:
+ h = (t - d0).total_seconds() / 3600.0
+ c.solar_today.add(t, peak_w * math.sin(math.pi * (h - 6.0) / 14.0)
+ if 6.0 <= h < 20.0 else 0.0)
+ t += timedelta(minutes=1)
+
+
+def make(name, now, *, soc=(55.0, 40.0), modes=("solar_first",
+ "solar_first")):
+ """The controller of scenario `name` at `now` (UTC), its cycle run once."""
+ assert name in SCENARIOS, name
+ opts = dict(BASE)
+ plan = quarters(now, dyn_price)
+ sun_today, sun_tomorrow = 18.0, 16.0
+ members, split, tomorrow = 1, False, True
+ if name == "owner":
+ opts.update(battery_modes_enabled=True,
+ pv_forecast_entities=[f"{FC1}_{i}" for i in range(3)],
+ pv_forecast_tomorrow_entities=[f"{FCT1}_{i}"
+ for i in range(3)],
+ static_profile=json.dumps(DN), arbitrage_enabled=False)
+ plan = static_plan(opts, day0_of(now), day0_of(now) + timedelta(
+ hours=48), now=now)
+ members = 3
+ elif name in ("dynamic", "sunny", "periods", "learning", "stale",
+ "cycle"):
+ opts.update(battery_modes_enabled=True, pv_forecast_entity=FC1,
+ pv_forecast_tomorrow_entity=FCT1)
+ modes = ("leftover_solar", "cheap_hours")
+ if name == "dynamic":
+ sun_today, sun_tomorrow = 3.0, 3.0
+ if name == "periods":
+ split = True
+ sun_today, sun_tomorrow = 6.0, 6.0
+ if name == "cycle":
+ opts.update(maintenance_enabled=True)
+ elif name == "off":
+ opts.update(battery_modes_enabled=False, arbitrage_enabled=True)
+ elif name == "nofc":
+ opts.update(battery_modes_enabled=True)
+ modes = ("leftover_solar", "cheap_hours")
+ elif name == "notomorrow":
+ opts.update(battery_modes_enabled=True, pv_forecast_entity=FC1,
+ static_profile=json.dumps(DN), arbitrage_enabled=False)
+ plan = static_plan(opts, day0_of(now), day0_of(now) + timedelta(
+ hours=48), now=now)
+ tomorrow = False
+ if name == "sunny":
+ sun_today, sun_tomorrow = 40.0, 40.0
+ c = Controller(opts, Hass(), Store(), Pub(), stop=asyncio.Event())
+ c.auto = True
+ for i, inv in enumerate(c.inverters):
+ inv.soc, inv.batt, inv.last_write = soc[i], 0.0, 0.0
+ inv.dev_min, inv.dev_max = -5000.0, 5000.0
+ if opts.get("battery_modes_enabled"):
+ c.store.data[unit_key(inv.name, packmode.MODE_KEY)] = modes[i]
+ c.grid, c.pv_w = 300.0, 0.0
+ c.tariff_plan, c.tariff_at = plan, now
+ c.peak_guard = PeakDecision(engaged=False, floor_w=None, ceiling_w=7000.0,
+ ceiling_from="cap", projected_w=800.0)
+ c.arb_headroom_w = 6000.0
+ if name not in ("nofc", "off", "stale") and \
+ opts.get("battery_modes_enabled"):
+ c.solar_fc = forecast(now, sun_today, sun_tomorrow, split=split,
+ members=members, tomorrow=tomorrow)
+ c.solar_fc_why = None
+ if name != "learning":
+ c.house_profile = profile(now)
+ if name in ("owner", "dynamic", "sunny", "periods", "learning",
+ "notomorrow", "cycle"):
+ measure(c, now, 7000.0 if name != "dynamic" else 2500.0)
+ if opts.get("battery_modes_enabled"):
+ c.tariff_at = now
+ c._mode_roles(now, None)
+ return c