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