Files
goodwe-addon/goodwe_controller/test_ux54_dst.py
T
glenn schrooyenandClaude 8dbf831854
checks / unit (pull_request) Failing after 50m11s
infra-pin / fresh (pull_request) Successful in 1s
UX-54 round 5: give blocks end at the floor point, the tab on the clock-change days
daycurve.curve() puts a point where a giving battery reaches the floor
inside a step (_floor_cut(), as it already does for a grid charge reaching
its level), so _stops() ends a give block in the quarter control stops. A
walk where control moves the packs with its own model (not the curve's
levels) matches the tab quarter by quarter in 11 cases; 25/45 % with the
peak guard: control 17:00-20:15, block 17:00-20:06.

SolarToday keys an hour by its start taken back from the UTC instant, so
the two 02:00 hours of the autumn change stay apart. test_ux54_dst.py builds
the tab (forecast_payload()) at every quarter of 27-29 March and
30 October-1 November 2027, modes on and off, in the no-tzdata zone and,
where the host has tzdata, ZoneInfo, every call under a time limit raised
as a BaseException that forecast_payload() cannot swallow.

give_later: "No block shows while a battery has no energy to give." /
"Er verschijnt geen blok zolang een batterij niets kan leveren."
DECISIONS: what the walks prove, the modes-off charge drawn on a sunny day,
the clock-change days.

No decision, command, timing or option key changes.

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

295 lines
11 KiB
Python

"""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.
"""
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")