Files
goodwe-addon/goodwe_controller/test_ux54_dst.py
T
glenn schrooyenandClaude a4b769f257
checks / unit (pull_request) Successful in 50m12s
infra-pin / fresh (pull_request) Successful in 1s
UX-54 round 5: test_ux54_dst.py points at MUTATION.md (test_bytecode.py)
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-27 16:19:07 +01:00

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