MAINT-10: every pack at N=2, the next DST days, plainer upgrade note
checks / unit (pull_request) Successful in 31m20s
infra-pin / fresh (pull_request) Successful in 1s

test_maint10.py part 10: at two inverters both packs get the site zone, the
real Controller is identical for 11 UTC and 13 Brussels, and two due packs
across 24/25 Oct 2026 and 27/28 Mar 2027 start at the local hour on
successive days; a pack freed in the repeated hour does not start there.
Part 3 adds 28 Mar 2027. DOCS: an existing install keeps its saved hour, now
read as local time; an hour set to make up for UTC goes back to 19.
DECISIONS: the SAFETY-83 gate is met; section on two inverters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-10-05 21:51:15 +02:00
co-authored by Claude Opus 5.5
parent 08fb1235fc
commit 0b6b34f563
3 changed files with 113 additions and 20 deletions
+85 -3
View File
@@ -49,6 +49,7 @@ except Exception as err: # noqa: BLE001
print(f" zoneinfo unavailable here ({err}); graded on the EU rule only")
SPRING = datetime(2026, 3, 29, tzinfo=UTC) # 02:00 CET -> 03:00 CEST
SPRING27 = datetime(2027, 3, 28, tzinfo=UTC) # the next one, last Sunday of March
AUTUMN = datetime(2026, 10, 25, tzinfo=UTC) # 03:00 CEST -> 02:00 CET
SUMMER = datetime(2026, 7, 15, tzinfo=UTC)
WINTER = datetime(2026, 1, 15, tzinfo=UTC)
@@ -105,7 +106,8 @@ for label, tz in ZONES:
# =========================================================================== #
print("3. the DST change days, every start hour")
for label, tz in ZONES:
for name, day in (("spring", SPRING), ("autumn", AUTUMN)):
for name, day in (("spring", SPRING), ("spring 2027", SPRING27),
("autumn", AUTUMN)):
bad = []
for h in range(24):
b = one_block(slot_minutes(day, h, tz))
@@ -117,6 +119,15 @@ for label, tz in ZONES:
sp2 = one_block(slot_minutes(SPRING, 2, tz))
check(f"[{label}] spring, hour 2 does not exist: it starts at 03:00 CEST "
f"(01:00 UTC), once", sp2 == (SPRING + timedelta(hours=1), 60))
check(f"[{label}] spring 2027 (28 March), hour 2: 01:00 UTC as well",
one_block(slot_minutes(SPRING27, 2, tz))
== (SPRING27 + timedelta(hours=1), 60))
check(f"[{label}] the evening before spring 2027, hour 19: 18:00 UTC "
f"(CET); that day, 17:00 UTC (CEST)",
one_block(slot_minutes(SPRING27 - timedelta(days=1), 19, tz))
== (SPRING27 - timedelta(hours=6), 60)
and one_block(slot_minutes(SPRING27, 19, tz))
== (SPRING27 + timedelta(hours=17), 60))
au2 = one_block(slot_minutes(AUTUMN, 2, tz))
check(f"[{label}] autumn, hour 2 happens twice: only the first, 02:00 CEST "
f"(00:00 UTC)", au2 == (AUTUMN, 60))
@@ -411,14 +422,16 @@ import safety78_identity as s78 # noqa: E402
# asked for as UTC hour 11 (the old reading) and as local hour 13 (the new).
def rows_with(start_hour, tz):
def rows_with(start_hour, tz, n=1, both=False):
"""The rig's rows; `both=True` also returns the Controller."""
real_drain, real_ctl = s78.DRAIN, rig.Controller
s78.DRAIN = {"maintenance_enabled": True,
"maintenance_start_hour": start_hour}
rig.Controller = functools.partial(real_ctl, site_tz=tz)
try:
# s78.run() wraps rig.Controller once more for its own tariff db.
return s78.run(1, dur=600.0)[0]
rows, c = s78.run(n, dur=600.0)
return (rows, c) if both else rows
finally:
s78.DRAIN, rig.Controller = real_drain, real_ctl
@@ -439,6 +452,75 @@ check("outside the start hour (12 UTC / 14 local): identical as well",
check("...and the zone matters: hour 11 read in Brussels does NOT start",
not any("drain" in r[-1][4] for r in rows_with(11, _eu)))
# =========================================================================== #
print("10. two inverters: every pack reads the start hour in local time")
_r2u, _c2u = rows_with(11, None, n=2, both=True)
_r2l, _c2l = rows_with(13, _eu, n=2, both=True)
check("N=2: both packs get the site's zone, not just the first",
len(_c2l.maints) == 2 and all(mm.tz is _eu for mm in _c2l.maints))
check("N=2 precondition: a drain starts in the UTC-11 run",
any(DRAIN in r[-1][4] for r in _r2u))
check(f"N=2: start hour 11 UTC and 13 Europe/Brussels identical over "
f"{len(_r2u)} cycles", _r2u == _r2l)
check("N=2: hour 11 read in Brussels does NOT start either pack",
not any(DRAIN in r[-1][4] for r in rows_with(11, _eu, n=2)))
def pair_starts(day0, days, hour, tz, cycle_min=70):
"""Two due packs, ticked each minute through `arbitrate()` as main.py
does; a cycle takes about `cycle_min` minutes. `(pack, "DD HH:MM" UTC)`s."""
cfg = MaintConfig(enabled=True, start_hour=hour, hold_min=10)
ms = [Maintenance(cfg, FakeStore(), name=u, tz=tz)
for u in ("A", "B")]
ms[0].store.set_time("last_completed", day0 - timedelta(days=60))
ms[1].store.set_time("last_completed", day0 - timedelta(days=40))
half = (cycle_min - 10) // 2
soc, out = [80.0, 80.0], []
t = day0 - timedelta(hours=3)
while t < day0 + timedelta(days=days):
blocks = MT.arbitrate(ms, t)
for i, mm in enumerate(ms):
was = mm.phase
mm.tick(t, soc=soc[i], blocked_by=blocks[i])
if mm.phase == DRAIN:
soc[i] -= (80.0 - cfg.soc_floor) / half
elif mm.phase == MT.CHARGE:
soc[i] += (cfg.soc_target - cfg.soc_floor) / half
if mm.phase == DRAIN and was != DRAIN:
out.append((mm.name, t))
t += timedelta(minutes=1)
return [(n, x.strftime("%d %H:%M")) for n, x in out]
def _at(*pairs):
return [(n, x.strftime("%d %H:%M")) for n, x in pairs]
for label, tz in ZONES:
# 24 Oct (CEST) and 25 Oct (CET): 19:00 local is 17:00 then 18:00 UTC.
got = pair_starts(AUTUMN - timedelta(days=1), 2, 19, tz)
check(f"[{label}] N=2 across the autumn change, hour 19: A at 19:00 "
f"local on the 24th, B at 19:00 local on the 25th {got}",
got == _at(("A", AUTUMN - timedelta(hours=7)),
("B", AUTUMN + timedelta(hours=18))))
got = pair_starts(SPRING27 - timedelta(days=1), 2, 19, tz)
check(f"[{label}] N=2 across the spring 2027 change, hour 19: 18:00 UTC "
f"then 17:00 UTC {got}",
got == _at(("A", SPRING27 - timedelta(hours=6)),
("B", SPRING27 + timedelta(hours=17))))
# Hour 2 on 25 Oct: A takes the first pass (00:00 UTC) and is done in its
# second pass. B must not start there - it waits for the next day.
got = pair_starts(AUTUMN, 2, 2, tz)
check(f"[{label}] N=2, autumn, hour 2: B does not start in the repeated "
f"hour after A is done; it starts at 02:00 CET on the 26th {got}",
got == _at(("A", AUTUMN),
("B", AUTUMN + timedelta(days=1, hours=1))))
got = pair_starts(SPRING27, 2, 2, tz)
check(f"[{label}] N=2, spring 2027, hour 2: A at 03:00 CEST (01:00 UTC), "
f"B the next night at 02:00 CEST (00:00 UTC) {got}",
got == _at(("A", SPRING27 + timedelta(hours=1)),
("B", SPRING27 + timedelta(days=1))))
print()
if fails:
print(f"{len(fails)} FAILED: {', '.join(fails)}")