"""SAFETY-123: the day plan's local-hour loop ends on the clock-change days. `python3 test_safety123.py` On a host with no tzdata (`tariff._EUTimezone`) `dayplan._hour_slices()` never returned on the spring clock change (28 March 2027, 00:00 UTC): the control tick froze. Graded here, each call under a hard time limit (SIGALRM): 1. `_hour_slices()` over 27-29 March and 30 Oct - 1 Nov 2027, and the US change days, at every quarter, in the no-tzdata zone, a US-rule zone built the same way, and (where the host has tzdata) Europe/Brussels, America/New_York and zones with half-hour changes: it ends, covers the span, never crosses a local hour, labels the local hour, and gives 23 slices on the spring day and 25 on the autumn day. 2. Normal days: the same slices as release/1.0 a1d0703 (a copy of its function is kept below as the reference). 3. `dayplan.plan()` at every quarter of those days with each pack mode, and its answer on normal days equal to the old function's. 4. `Controller._mode_roles()` at every quarter of those days with modes on, for Solar first, Leftover solar and Cheap hours, and one full control cycle through the forecast read at the hang's instants. See DECISIONS/SAFETY-123-hour-slices-step-in-utc.md. Mutating what this file grades? Read MUTATION.md first (stale bytecode). """ import asyncio import logging import os import resource import signal import sys from datetime import date, datetime, timedelta, timezone, tzinfo 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 opt11_identity as rig # noqa: E402 import app.main as M # noqa: E402 from app import dayplan, modetrade, packmode, priceruns, solarfc # noqa: E402 from app.price import PricePoint, PriceProduct, PriceRead, brussels # noqa from app.static_tariff import TariffPlan # noqa: E402 from app.store import unit_key # noqa: E402 from app.tariff import BILLING_TZ, _EUTimezone # noqa: E402 UTC = timezone.utc fails = [] LIMIT_S = 3.0 # one call; the old loop never returned 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 Slow(Exception): pass def _alarm(*_a): raise Slow() def limited(fn, *a, **k): """`fn(*a, **k)` or `Slow` when it takes more than LIMIT_S seconds.""" signal.signal(signal.SIGALRM, _alarm) signal.setitimer(signal.ITIMER_REAL, LIMIT_S) try: return fn(*a, **k) except MemoryError: raise Slow() from None finally: signal.setitimer(signal.ITIMER_REAL, 0) # --------------------------------------------------------------------------- # # The reference: release/1.0 a1d0703's function, verbatim, for normal days # --------------------------------------------------------------------------- # def old_slices(t0, t1): out, t = [], t0 while t < t1: lt = dayplan._local(t) nxt = (lt.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1)).astimezone(timezone.utc) e = min(nxt, t1) out.append((t, e, lt.hour)) t = e return out # --------------------------------------------------------------------------- # # Zones # --------------------------------------------------------------------------- # def _nth_sunday(year, month, n): d = date(year, month, 1) d += timedelta(days=(6 - d.weekday()) % 7 + 7 * (n - 1)) return datetime(d.year, d.month, d.day) class USZone(tzinfo): """America/New_York by the US rule (DST from the second Sunday of March 02:00 to the first Sunday of November 02:00, local), built like tariff._EUTimezone: the offset is read from the local wall clock, so the missing 02:00 reads as DST. Its change days are not the EU's.""" def _dst_local(self, naive): y = naive.year return (_nth_sunday(y, 3, 2).replace(hour=2) <= naive < _nth_sunday(y, 11, 1).replace(hour=2)) def _dst_utc(self, naive_utc): y = naive_utc.year return (_nth_sunday(y, 3, 2).replace(hour=7) <= naive_utc < _nth_sunday(y, 11, 1).replace(hour=6)) def fromutc(self, dt): return dt + timedelta( hours=-4 if self._dst_utc(dt.replace(tzinfo=None)) else -5) def utcoffset(self, dt): if dt is None: return timedelta(hours=-5) return timedelta(hours=-4 if self._dst_local( dt.replace(tzinfo=None)) else -5) def dst(self, dt): return timedelta(hours=1) if dt is not None and self._dst_local( dt.replace(tzinfo=None)) else timedelta(0) def _q(y, mo, d, h=0): return datetime(y, mo, d, h, tzinfo=UTC) # (name, tzinfo, whole-hour offsets?, windows [from, to) at every quarter) BRU_SPRING = (_q(2027, 3, 27), _q(2027, 3, 30)) BRU_AUTUMN = (_q(2027, 10, 30), _q(2027, 11, 2)) US_SPRING = (_q(2027, 3, 13), _q(2027, 3, 16)) US_AUTUMN = (_q(2027, 11, 6), _q(2027, 11, 9)) COMMON = [BRU_SPRING, BRU_AUTUMN, US_SPRING, US_AUTUMN] ZONES = [("no-tzdata EU rule", _EUTimezone(), True, COMMON), ("US rule", USZone(), True, COMMON)] try: from zoneinfo import ZoneInfo for _n, _w, _extra in ( ("Europe/Brussels", True, []), ("America/New_York", True, []), # DST of half an hour, and clocks that jump at midnight ("Australia/Lord_Howe", False, [(_q(2027, 4, 3), _q(2027, 4, 6)), (_q(2027, 10, 2), _q(2027, 10, 5))]), ("America/Santiago", True, [(_q(2027, 4, 2), _q(2027, 4, 5)), (_q(2027, 9, 4), _q(2027, 9, 7))]), ("Asia/Kathmandu", False, [])): try: ZONES.append((_n, ZoneInfo(_n), _w, COMMON + _extra)) except Exception: # noqa: BLE001 print(f" no tzdata for {_n} here: not graded") except ImportError: print(" no zoneinfo here") print(" zones graded:", ", ".join(z[0] for z in ZONES)) class In: """`dayplan.BILLING_TZ` is `tz` inside the with-block.""" def __init__(self, tz): self.tz = tz def __enter__(self): self.was = dayplan.BILLING_TZ dayplan.BILLING_TZ = self.tz def __exit__(self, *_a): dayplan.BILLING_TZ = self.was def quarters(a, b): t = a while t < b: yield t t += timedelta(minutes=15) # ============================================================================ # print("1. _hour_slices(): ends, covers, local hours, 23 and 25 hours") HOUR = timedelta(hours=1) for zname, tz, whole, windows in ZONES: bad, slow, n = [], None, 0 with In(tz): for a, b in windows: for t0 in quarters(a, b): for span in (HOUR, 7 * HOUR, 30 * HOUR): t1 = t0 + span n += 1 try: sl = limited(dayplan._hour_slices, t0, t1) except Slow: slow = (t0, t1) break ok = bool(sl) and sl[0][0] == t0 and sl[-1][1] == t1 ok = ok and all(x[1] == y[0] for x, y in zip(sl, sl[1:])) for s, e, h in sl: ok = ok and timedelta(0) < e - s <= HOUR lt = s.astimezone(tz) ok = ok and h == lt.hour last = (e - timedelta(microseconds=1)).astimezone(tz) # no local hour boundary inside the slice ok = ok and (lt.year, lt.month, lt.day, lt.hour) == ( last.year, last.month, last.day, last.hour) # it ends on a local hour, the span's end, or where # the clock itself jumps (half-hour changes) el = e.astimezone(tz) ok = ok and (e == t1 or el.minute == 0 or el.utcoffset() != lt.utcoffset()) if whole: # whole-hour zones: local hours are UTC hours ok = ok and (e.minute == 0 or e == t1) \ and (s.minute == 0 or s == t0) if not ok: bad.append((t0, t1)) if slow: break if slow: break check(f"{zname}: every quarter x 1, 7, 30 h ({n} spans) returns within " f"{LIMIT_S:.0f} s" + ("" if not slow else f" - HUNG at {slow[0]}"), slow is None) check(f"{zname}: ...contiguous from t0 to t1, each slice at most an " f"hour, labelled with its local hour, none across a local hour", not bad) # The change days: 23 and 25 local hours, from local midnight to midnight. # (spring day, autumn day, the local hour that happens twice in autumn) BRU_DAYS = ((2027, 3, 28), (2027, 10, 31), 2) US_DAYS = ((2027, 3, 14), (2027, 11, 7), 1) DAYS = {"no-tzdata EU rule": BRU_DAYS, "Europe/Brussels": BRU_DAYS, "US rule": US_DAYS, "America/New_York": US_DAYS} def local_day(tz, y, m, d): d0 = datetime(y, m, d, tzinfo=tz).astimezone(UTC) n = date(y, m, d) + timedelta(days=1) return d0, datetime(n.year, n.month, n.day, tzinfo=tz).astimezone(UTC) for zname, tz, _w, _win in ZONES: if zname not in DAYS: continue spring, autumn, twice = DAYS[zname] with In(tz): for what, (y, m, d), want, want_hours in ( ("spring", spring, 23, [h for h in range(24) if h != 2]), ("autumn", autumn, 25, [h for h in range(24) for _ in range(2 if h == twice else 1)])): d0, d1 = local_day(tz, y, m, d) try: sl = limited(dayplan._hour_slices, d0, d1) except Slow: sl = None check(f"{zname}, {what} day {y}-{m:02d}-{d:02d}: {want} one-hour " f"slices from local midnight to midnight", sl is not None and len(sl) == want and all(e - s == HOUR for s, e, _h in sl) and d1 - d0 == want * HOUR and sl[0][0] == d0 and sl[-1][1] == d1) check(f"{zname}, {what} day: the local hours are " f"{'0-23 without 2' if what == 'spring' else f'0-23 with {twice} twice'}", sl is not None and [h for _s, _e, h in sl] == want_hours) # ============================================================================ # print("2. normal days: the same slices as release/1.0 a1d0703") NORMAL = [(_q(2027, 1, 9), _q(2027, 1, 12)), (_q(2027, 6, 18), _q(2027, 6, 21)), (_q(2027, 8, 30), _q(2027, 9, 2)), (_q(2027, 12, 18), _q(2027, 12, 21))] for zname, tz, _w, _win in ZONES: diff, n = [], 0 with In(tz): for a, b in NORMAL: for t0 in quarters(a, b): for off in (timedelta(0), timedelta(seconds=7, microseconds=5), timedelta(minutes=14, seconds=59, microseconds=999999), timedelta(minutes=37, seconds=1)): for span in (timedelta(minutes=1), timedelta(minutes=50), 3 * HOUR, 26 * HOUR): s0 = t0 + off n += 1 if dayplan._hour_slices(s0, s0 + span) != old_slices( s0, s0 + span): diff.append(s0) check(f"{zname}: {n} spans on ordinary days equal the old slices " f"(including starts off the quarter)", n > 1000 and not diff) with In(ZONES[0][1]): s0 = _q(2027, 1, 10, 9) eq = dayplan._hour_slices(s0, s0) == [] == old_slices(s0, s0) \ and dayplan._hour_slices(s0, s0 - HOUR) == [] check("an empty or backwards span gives no slices, as before", eq) plus2 = timezone(timedelta(hours=2)) t0 = datetime(2027, 6, 1, 10, 20, tzinfo=plus2) check("a start given in another zone still gives the old slices", dayplan._hour_slices(t0, t0 + 3 * HOUR) == old_slices(t0, t0 + 3 * HOUR)) # ============================================================================ # print("3. dayplan.plan() at every quarter of the change days") def st(kwh, at): return {"state": str(kwh), "attributes": {"unit_of_measurement": "kWh"}, "last_updated": at.isoformat(), "last_reported": at.isoformat()} def cheap_dear(h): return 0.08 if 1.0 <= h < 5.0 else 0.34 if 17.0 <= h < 21.0 else 0.20 def price_plan(now, f=cheap_dear, days=4): """15-minute firm prices from a day before `now` for `days` days.""" start = (now - timedelta(days=1)).replace(minute=0, second=0, microsecond=0) pts = [] for i in range(days * 96 + 96): b = brussels(start + timedelta(minutes=15 * i)) pts.append(PricePoint(start=b, eur_per_kwh=f(b.hour + b.minute / 60), provisional=False)) return TariffPlan(PriceRead.series(pts, PriceProduct.DAY_AHEAD, "generic_dayahead", now), None, "provider") _PLANS = {} def plan_for(start): """One price plan covering `start` - 1 day .. + 5 days, kept: building it for every quarter is most of the run time.""" if start not in _PLANS: _PLANS[start] = price_plan(start, days=6) return _PLANS[start] HOUSE = dayplan.HouseProfile({"2027-03-20": { str(h): (3000.0 if 17 <= h < 22 else 600.0) for h in range(24)}}) MODES = {"Solar first": (packmode.SOLAR_FIRST, packmode.SOLAR_FIRST), "Leftover solar": (packmode.SOLAR_FIRST, packmode.LEFTOVER_SOLAR), "Cheap hours": (packmode.CHEAP_HOURS, packmode.CHEAP_HOURS)} def packs_of(modes, soc=(55.0, 40.0)): return [modetrade.Pack(f"inverter-{u}", True, mode, False, 2500.0, s, 10.0) for u, mode, s in zip("ab", modes, soc)] _INPUTS = {} def plan_at(now, modes, start=None): """plan() on the price runs and forecast of `now` (made once per quarter: the runs do not depend on the pack modes).""" if now not in _INPUTS: pts = plan_for(start or now.replace(hour=0, minute=0)).read.points _INPUTS[now] = ( priceruns.plan(pts, now, required_eur_kwh=0.1, charge_kwh=8.0, charge_kw=5.0, sell_kwh=0.0, sell_kw=0.0), solarfc.read(st(2.0, now), st(8.0, now), now)[0]) runs, fc = _INPUTS[now] return dayplan.plan(fc, HOUSE.estimator(), runs, now, packs_of(modes), trust_pct=70, floor_pct=20, ceiling_pct=95) spy = [] _slices = dayplan._hour_slices dayplan._hour_slices = lambda a, b: (spy.append((a, b)), _slices(a, b))[1] try: for mname, modes in MODES.items(): for wname, (a, b) in (("27-29 March", BRU_SPRING), ("30 Oct-1 Nov", BRU_AUTUMN)): made, slow, n = 0, None, 0 spy.clear() for now in quarters(a, b): n += 1 try: p = limited(plan_at, now, modes, a) except Slow: slow = now break made += p is not None check(f"plan(), {mname}, {wname}: {n} quarters return within " f"{LIMIT_S:.0f} s" + ("" if not slow else f" - HUNG at {slow}") + f"; a plan is made at {made} of them", slow is None and made > 20) if wname == "27-29 March" and not slow: cross = [s for s in spy if s[0] <= _q(2027, 3, 28) < s[1]] check(f"plan(), {mname}: {len(cross)} spans ran across the " "change (28 March 00:00 UTC): the test reaches the " "loop", len(cross) > 20) finally: dayplan._hour_slices = _slices # The answer on normal days is the old function's. for mname, modes in MODES.items(): same, n = True, 0 for now in quarters(_q(2027, 1, 11), _q(2027, 1, 13)): new = plan_at(now, modes) dayplan._hour_slices = old_slices try: was = plan_at(now, modes) finally: dayplan._hour_slices = _slices n += 1 same = same and new == was check(f"plan(), {mname}: {n} quarters on ordinary days equal the old " "answer", same) # ============================================================================ # print("4. Controller._mode_roles() at every quarter, modes on") def controller(modes, now): extra = {"battery_modes_enabled": True, "arbitrage_enabled": True, "cycle_cost_threshold_eur": 0.05, "pv_forecast_entities": ["sensor.t1", "sensor.t2", "sensor.t3"], "pv_forecast_tomorrow_entities": ["sensor.m1", "sensor.m2", "sensor.m3"]} opts = rig._options(2, extra) for i in opts["inverters"]: i["capacity_kwh"] = 10.0 store = rig._Store() for u, mode in zip("ab", modes): store.data[unit_key(f"inverter-{u}", "pack_mode")] = mode store.data["house_profile"] = {"2027-03-20": { str(h): (3000.0 if 17 <= h < 22 else 600.0) for h in range(24)}} class Hass(rig._Hass): async def state(self, e): if e.startswith(("sensor.t", "sensor.m")): return st(2.0 if e.startswith("sensor.t") else 8.0, self.at) return await super().state(e) hass = Hass(("a", "b"), lambda u, t: 55.0 if u == "a" else 40.0, lambda u, t: False) hass.at = now c = M.Controller(opts, hass, store, rig._Pub(), stop=asyncio.Event()) for i in c.inverters: i.dev_min, i.dev_max = -5000.0, 5000.0 i.soc = 55.0 if i.name.endswith("a") else 40.0 c.auto = True return c, hass for mname, modes in MODES.items(): for wname, (a, b) in (("27-29 March", BRU_SPRING), ("30 Oct-1 Nov", BRU_AUTUMN)): c, hass = controller(modes, a) slow, n, made, roles = None, 0, 0, set() spy.clear() dayplan._hour_slices = lambda x, y: (spy.append((x, y)), _slices(x, y))[1] try: for now in quarters(a, b): n += 1 c.tariff_plan, c.tariff_at = plan_for(a), now c.solar_fc, _w, _g = solarfc.read_sum( [("sensor.t1", st(2.0, now))], [("sensor.m1", st(8.0, now))], now) try: t = limited(c._mode_roles, now, None) except Slow: slow = now break made += c.day_plan is not None roles |= set(t.roles.values()) finally: dayplan._hour_slices = _slices check(f"_mode_roles(), {mname}, {wname}: {n} quarters return within " f"{LIMIT_S:.0f} s" + ("" if not slow else f" - HUNG at {slow}"), slow is None) check(f"...a day plan is made at {made} of them (the loop is " f"reached), roles seen {sorted(roles)}", slow is None and made > 20 and len(spy) > 20) # One whole control cycle through the real forecast read (three sensors a # day) at the instants the old loop hung: the page and control share the loop. for iso in ("2027-03-27T21:00:05+00:00", "2027-03-27T23:45:05+00:00", "2027-03-28T00:00:05+00:00"): now = datetime.fromisoformat(iso) c, hass = controller(MODES["Leftover solar"], now) real_dt = M.datetime class _DT(real_dt): @classmethod def now(cls, tz=None, _n=now): return _n if tz is None else _n.astimezone(tz) M.datetime = _DT try: async def one(): await c.read_inputs(now_mono=0.0) c.tariff_plan, c.tariff_at = price_plan(now), now await c.cycle() try: limited(lambda: asyncio.run(one())) ok = c.solar_fc is not None and c.day_plan is not None except Slow: ok = False finally: M.datetime = real_dt check(f"a whole control cycle at {iso[:16]}Z (three forecast sensors a " "day) returns with a day plan", ok) print() if fails: print(f"{len(fails)} FAILED:") for f in fails: print(" -", f) sys.exit(1) print("all ok")