diff --git a/goodwe_controller/DECISIONS/MAINT-10-local-start-hour.md b/goodwe_controller/DECISIONS/MAINT-10-local-start-hour.md index 5b43c29..a1b0a8e 100644 --- a/goodwe_controller/DECISIONS/MAINT-10-local-start-hour.md +++ b/goodwe_controller/DECISIONS/MAINT-10-local-start-hour.md @@ -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 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 -drains meet higher house loads. +Evening drains meet higher house loads; SAFETY-83 (merged first) lets the +peak guard outrank the drain. ## 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 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 -an operator's choice with no way to tell it apart from the old default. DOCS -and the release note (drafted on the PR, for the version bump) tell the owner -to set 19 if they want the evening start. +an operator's choice with no way to tell it apart from the old default. A site +that set an earlier hour to make up for UTC (17 for 19:00 summer) must set it +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. -## 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 -start time read the zone. `test_maint10.py` part 9 runs the real Controller -with start hour 11 UTC against 13 Europe/Brussels (the same instant) and with -12 UTC against 14 local (not the start hour): every command, claim, reason and -log line is identical. A `Maintenance` built without a zone reads UTC, as before. +start time read the zone. `test_maint10.py` parts 9 and 10 run the real +Controller at N=1 and N=2 with start hour 11 UTC against 13 Europe/Brussels (the +same instant): every command, claim, reason and log line is identical. A +`Maintenance` built without a zone reads UTC, as before. diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index 9394a0e..2b907c5 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -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 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 -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 -started at 12:00 in a Belgian summer. The default is now 19 (19:00 local), but -it only reaches installs whose saved settings lack the hour. Saving the -settings stores every value, so most existing installs have 10 saved. They keep -10, and it now means 10:00 local. The add-on never changes a saved value: set -19 yourself for the evening start. *For installers:* DECISIONS/MAINT-10-local-start-hour.md. +**Upgrading.** The hour used to be read as UTC. An existing install keeps its +saved hour, and that hour now means local time: a saved 10 now starts at 10:00, +not 12:00 in summer. The new default, 19, only reaches installs that have no +hour saved. If you set an earlier hour to get an evening start in UTC (for +example 17 for 19:00 in summer), set it to 19. The add-on never changes a saved +value. *For installers:* DECISIONS/MAINT-10-local-start-hour.md. ### Tariff (all optional) diff --git a/goodwe_controller/test_maint10.py b/goodwe_controller/test_maint10.py index c2e712a..c88eac0 100644 --- a/goodwe_controller/test_maint10.py +++ b/goodwe_controller/test_maint10.py @@ -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)}")