Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
297 lines
11 KiB
Python
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")
|