MAINT-10: every pack at N=2, the next DST days, plainer upgrade note
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:
co-authored by
Claude Opus 5.5
parent
08fb1235fc
commit
0b6b34f563
@@ -5,8 +5,8 @@ with the UTC hour, so the shipped 10 started at 12:00 in a Belgian summer and
|
|||||||
11:00 in winter. It is now read on the site's local clock, and the default moves
|
11:00 in winter. It is now read on the site's local clock, and the default moves
|
||||||
to 19 so the drain runs into the evening and night rather than the solar day.
|
to 19 so the drain runs into the evening and night rather than the solar day.
|
||||||
|
|
||||||
Merges only after SAFETY-83 (peak guard outranks the drain at N=1): evening
|
Evening drains meet higher house loads; SAFETY-83 (merged first) lets the
|
||||||
drains meet higher house loads.
|
peak guard outrank the drain.
|
||||||
|
|
||||||
## 1. Which zone
|
## 1. Which zone
|
||||||
|
|
||||||
@@ -38,15 +38,24 @@ the settings stores every key (the reference install's blob has all 76). So
|
|||||||
almost every existing install keeps its stored 10, which now means 10:00 local
|
almost every existing install keeps its stored 10, which now means 10:00 local
|
||||||
instead of 12:00 (summer) or 11:00 (winter). Only a blob without the key gets 19.
|
instead of 12:00 (summer) or 11:00 (winter). Only a blob without the key gets 19.
|
||||||
The add-on does not rewrite a stored option: a silent migration would override
|
The add-on does not rewrite a stored option: a silent migration would override
|
||||||
an operator's choice with no way to tell it apart from the old default. DOCS
|
an operator's choice with no way to tell it apart from the old default. A site
|
||||||
and the release note (drafted on the PR, for the version bump) tell the owner
|
that set an earlier hour to make up for UTC (17 for 19:00 summer) must set it
|
||||||
to set 19 if they want the evening start.
|
back to 19 after the upgrade, or it starts two hours early. DOCS and the release
|
||||||
|
note (drafted on the PR, for the version bump) say this.
|
||||||
`tools/supervisor_shape_probe.py` §I measures this on Supervisor 2026.08.0.
|
`tools/supervisor_shape_probe.py` §I measures this on Supervisor 2026.08.0.
|
||||||
|
|
||||||
## 4. What does not change
|
## 4. Two inverters
|
||||||
|
|
||||||
|
`Controller` hands the same zone to every pack's `Maintenance`, so every pack's
|
||||||
|
`wants_start()` reads the local hour. `arbitrate()` is unchanged: one pack
|
||||||
|
cycles at a time, and the one that lost the slot starts in the first start
|
||||||
|
slot after the other is done (a later day: a cycle outlasts its start hour). On the day the clocks go back, a pack freed during the second pass of
|
||||||
|
a repeated hour does not start there. `test_maint10.py` part 10.
|
||||||
|
|
||||||
|
## 5. What does not change
|
||||||
|
|
||||||
Only the start decision (`Maintenance.wants_start`) and the Home screen's shown
|
Only the start decision (`Maintenance.wants_start`) and the Home screen's shown
|
||||||
start time read the zone. `test_maint10.py` part 9 runs the real Controller
|
start time read the zone. `test_maint10.py` parts 9 and 10 run the real
|
||||||
with start hour 11 UTC against 13 Europe/Brussels (the same instant) and with
|
Controller at N=1 and N=2 with start hour 11 UTC against 13 Europe/Brussels (the
|
||||||
12 UTC against 14 local (not the start hour): every command, claim, reason and
|
same instant): every command, claim, reason and log line is identical. A
|
||||||
log line is identical. A `Maintenance` built without a zone reads UTC, as before.
|
`Maintenance` built without a zone reads UTC, as before.
|
||||||
|
|||||||
@@ -1655,14 +1655,16 @@ help - see "Why the tuning is what it is".
|
|||||||
it uses the add-on's own time zone, and failing that UTC. The log says which
|
it uses the add-on's own time zone, and failing that UTC. The log says which
|
||||||
one it used. On the day the clocks go forward, a start hour that does not exist
|
one it used. On the day the clocks go forward, a start hour that does not exist
|
||||||
starts at the first hour after the jump. On the day they go back, a repeated
|
starts at the first hour after the jump. On the day they go back, a repeated
|
||||||
hour starts only once, in its first pass.
|
hour starts only once, in its first pass. With two inverters both batteries use
|
||||||
|
the same local hour; one cycles at a time, and the other starts at that hour on
|
||||||
|
a later day.
|
||||||
|
|
||||||
**Upgrading.** The hour used to be read as UTC, so the old default of 10
|
**Upgrading.** The hour used to be read as UTC. An existing install keeps its
|
||||||
started at 12:00 in a Belgian summer. The default is now 19 (19:00 local), but
|
saved hour, and that hour now means local time: a saved 10 now starts at 10:00,
|
||||||
it only reaches installs whose saved settings lack the hour. Saving the
|
not 12:00 in summer. The new default, 19, only reaches installs that have no
|
||||||
settings stores every value, so most existing installs have 10 saved. They keep
|
hour saved. If you set an earlier hour to get an evening start in UTC (for
|
||||||
10, and it now means 10:00 local. The add-on never changes a saved value: set
|
example 17 for 19:00 in summer), set it to 19. The add-on never changes a saved
|
||||||
19 yourself for the evening start. *For installers:* DECISIONS/MAINT-10-local-start-hour.md.
|
value. *For installers:* DECISIONS/MAINT-10-local-start-hour.md.
|
||||||
|
|
||||||
### Tariff (all optional)
|
### Tariff (all optional)
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ except Exception as err: # noqa: BLE001
|
|||||||
print(f" zoneinfo unavailable here ({err}); graded on the EU rule only")
|
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
|
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
|
AUTUMN = datetime(2026, 10, 25, tzinfo=UTC) # 03:00 CEST -> 02:00 CET
|
||||||
SUMMER = datetime(2026, 7, 15, tzinfo=UTC)
|
SUMMER = datetime(2026, 7, 15, tzinfo=UTC)
|
||||||
WINTER = datetime(2026, 1, 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")
|
print("3. the DST change days, every start hour")
|
||||||
for label, tz in ZONES:
|
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 = []
|
bad = []
|
||||||
for h in range(24):
|
for h in range(24):
|
||||||
b = one_block(slot_minutes(day, h, tz))
|
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))
|
sp2 = one_block(slot_minutes(SPRING, 2, tz))
|
||||||
check(f"[{label}] spring, hour 2 does not exist: it starts at 03:00 CEST "
|
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))
|
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))
|
au2 = one_block(slot_minutes(AUTUMN, 2, tz))
|
||||||
check(f"[{label}] autumn, hour 2 happens twice: only the first, 02:00 CEST "
|
check(f"[{label}] autumn, hour 2 happens twice: only the first, 02:00 CEST "
|
||||||
f"(00:00 UTC)", au2 == (AUTUMN, 60))
|
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).
|
# 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
|
real_drain, real_ctl = s78.DRAIN, rig.Controller
|
||||||
s78.DRAIN = {"maintenance_enabled": True,
|
s78.DRAIN = {"maintenance_enabled": True,
|
||||||
"maintenance_start_hour": start_hour}
|
"maintenance_start_hour": start_hour}
|
||||||
rig.Controller = functools.partial(real_ctl, site_tz=tz)
|
rig.Controller = functools.partial(real_ctl, site_tz=tz)
|
||||||
try:
|
try:
|
||||||
# s78.run() wraps rig.Controller once more for its own tariff db.
|
# 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:
|
finally:
|
||||||
s78.DRAIN, rig.Controller = real_drain, real_ctl
|
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",
|
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)))
|
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()
|
print()
|
||||||
if fails:
|
if fails:
|
||||||
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
||||||
|
|||||||
Reference in New Issue
Block a user