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