SAFETY-123: dayplan._hour_slices() could not end on the spring clock change.
What. It added an hour to the local wall clock and read it back. Where the zone has no tzdata (tariff._EUTimezone: the CI image, a dev host), 02:00 on 28 March 2027 reads as 00:00 UTC again, so t never advanced and the list grew until the process died. Control and the web page share one event loop. dayplan.plan() calls it on every plan, before it looks at a battery's mode.
Fix. Each step adds the time left in the local hour to the UTC instant (at least a microsecond), so the loop always advances and ends. Ordinary days give the old slices. The spring day gives 23 slices, the autumn day 25.
Scope. No option, no text change, no CHANGELOG or version. The shipped image has tzdata, where the old function does not stall (measured), so a running add-on is not known to be hit. AUDIT-01 F4 (SAFETY-120) is the same defect.
One change on the autumn day. The old function (with tzdata) made one two-hour slice for the doubled 02:00 hour. It now makes two slices of an hour, both labelled 2. The plan sums per slice, and there is no sun at 02:00, so the plan is the same.
Tests.test_safety123.py, each call under a 3 s alarm: the slices at every quarter of 27-29 March and 30 Oct - 1 Nov 2027 (and the US change days) in the no-tzdata zone and a US-rule zone built the same way (plus Europe/Brussels, America/New_York, Lord_Howe, Santiago, Kathmandu where tzdata exists); normal days equal the old slices; dayplan.plan() and Controller._mode_roles() at every quarter with Solar first, Leftover solar and Cheap hours; a whole control cycle at 21:00, 23:45 and 00:00 UTC. Eight deliberate breakages are each caught. Replay digests (modes on and off, 3 recorded days) equal release/1.0.
SAFETY-123: `dayplan._hour_slices()` could not end on the spring clock change.
**What.** It added an hour to the local wall clock and read it back. Where the zone has no tzdata (`tariff._EUTimezone`: the CI image, a dev host), 02:00 on 28 March 2027 reads as 00:00 UTC again, so `t` never advanced and the list grew until the process died. Control and the web page share one event loop. `dayplan.plan()` calls it on every plan, before it looks at a battery's mode.
**Fix.** Each step adds the time left in the local hour to the UTC instant (at least a microsecond), so the loop always advances and ends. Ordinary days give the old slices. The spring day gives 23 slices, the autumn day 25.
**Scope.** No option, no text change, no CHANGELOG or version. The shipped image has tzdata, where the old function does not stall (measured), so a running add-on is not known to be hit. AUDIT-01 F4 (SAFETY-120) is the same defect.
**One change on the autumn day.** The old function (with tzdata) made one two-hour slice for the doubled 02:00 hour. It now makes two slices of an hour, both labelled 2. The plan sums per slice, and there is no sun at 02:00, so the plan is the same.
**Tests.** `test_safety123.py`, each call under a 3 s alarm: the slices at every quarter of 27-29 March and 30 Oct - 1 Nov 2027 (and the US change days) in the no-tzdata zone and a US-rule zone built the same way (plus Europe/Brussels, America/New_York, Lord_Howe, Santiago, Kathmandu where tzdata exists); normal days equal the old slices; `dayplan.plan()` and `Controller._mode_roles()` at every quarter with Solar first, Leftover solar and Cheap hours; a whole control cycle at 21:00, 23:45 and 00:00 UTC. Eight deliberate breakages are each caught. Replay digests (modes on and off, 3 recorded days) equal release/1.0.
Report: `reports/SAFETY-123-dev-report.md`.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
dayplan._hour_slices() added an hour to the local wall clock and read it
back. Without tzdata (tariff._EUTimezone) 02:00 on the spring change reads
as 00:00 UTC again, so the loop never advanced and the list grew until the
process died. Each step now adds the time left in the local hour to the UTC
instant. Ordinary days give the old slices; the spring day gives 23 slices
and the autumn day 25. No option, no text change.
test_safety123.py: every quarter of 27-29 March and 30 Oct-1 Nov 2027 (and
the US change days) for the slices, dayplan.plan() and
Controller._mode_roles(), each call under a hard time limit.
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
SAFETY-123 review (Opus): approve and merge, head d2820e8
I reviewed head d2820e8 against base a1d0703, checking it again with ls-remote just before merging. CI run 1408 checks / unit is a real run on the merge (Updating a1d0703..d2820e8, 84 PASS, test_freeze against the rig hwsim). Run 1409 infra-pin / fresh is also green.
Verdict: the fix is right.dayplan.py:166-168 sets each step to the time left in the local hour (at least 1 µs) added to the UTC instant. So t always moves forward, and no wall time is converted back to UTC. I found nothing that blocks the merge.
What I checked:
Always ends. I fuzzed the function for 10 min per image under SIGALRM, RLIMIT_AS, --memory and timeout, with a spy on every loop step:
92 524 calls in the CI image (EU and US wall-clock rules).
211 527 + 140 460 calls in a tzdata image (600 zones; starts within ±40 h of real transitions; spans that are negative, zero, sub-second or up to 80 h; random start and end zones).
Result: 0 time-outs, 0 memory failures, 0 steps that did not move forward.
Naive input raises TypeError, as before. No caller passes it: now is datetime.now(timezone.utc) and run edges are fixed-offset.
Normal days. On spans with no offset change, the slices equal a1d0703's: 62 507 (CI image) + 103 179 (tzdata) spans compared.
Autumn day. The labels are used only as house_w(h) lookups in plan() (dayplan.py:201, :223), never as a dict key. I compared plan() old and new at every quarter of 30 Oct-1 Nov 2027: 3 pack mixes, 3 price shapes (one with a dear run over the doubled 02:00 hour), a state-only and a wh_period forecast, in both tz modes. That is 10 368 comparisons with 0 differences.
Severity. The stall needs _EUTimezone. The add-on image does not use it:
amd64-base:3.20 has tzdata-2025b-r0.
The aarch64-base:3.20 layer has P:tzdata and zoneinfo/Europe/Brussels.
I built the add-on from this repo's Dockerfile and got BILLING_TZ = ZoneInfo. There the old function returns on both days.
Owner shape on base, no tzdata, every quarter 26-29 March 2027: 0 calls. With the threshold lowered to 0.02: 146 calls, none across the change, no hang.
An all-Solar-first site on dynamic prices hangs at 40 of 48 quarters on base and at 0 on head.
Mutations. The tests catch base and 7 of 8 wrong fixes. Details are in the review report.
Replay digests. base = head for p3_ls fc, p3_both_ch fc and modes_off.
Offline suite. 84/84 pass in goodwe-addon-checks:1 with the hwsim.
Findings (none blocking)
Low, test_safety123.py:280-309, :409-421. The normal-day comparisons run without limited().
A wrong fix that loops without allocating memory (my mutation R4) fails in part 1, then hangs in part 2.
CI has no per-file timeout (checks.yml:212, only the 70-min job limit), so the job would use up the shared runner's 70 min and print no FAIL line.
This is not a false pass. Fix: wrap these calls in limited().
Also class Slow(Exception) (:63) would be swallowed by any except Exception between the loop and the alarm. There is none today. Making it a BaseException closes that.
Info, dayplan.py:168. Removing .astimezone(timezone.utc) (mutation R1) is not caught, because every test input is UTC or a fixed offset. No caller passes anything else, and R1 does not stall. It only gives wrong slices for a start that carries a zone such as BILLING_TZ.
Info, CI coverage. CI has no tzdata, so the ZoneInfo zones (and the autumn 1-h/1-h shape under ZoneInfo) are "not graded" there. I covered them locally in a tzdata image.
Info, outside the diff.price.py:116-119 and static_tariff.py:165-170 still say the shipped image has no tzdata. That is SAFETY-120's remaining item.
Info. In zones whose clock changes at a time that is not a whole local hour (Pacific/Chatham at 02:45), one slice can span two local hours. The old function does the same, and BILLING_TZ is always Europe/Brussels.
## SAFETY-123 review (Opus): approve and merge, head d2820e8
I reviewed head `d2820e8` against base `a1d0703`, checking it again with ls-remote just before merging. CI run 1408 `checks / unit` is a real run on the merge (`Updating a1d0703..d2820e8`, 84 PASS, test_freeze against the rig hwsim). Run 1409 `infra-pin / fresh` is also green.
**Verdict: the fix is right.** `dayplan.py:166-168` sets each step to the time left in the local hour (at least 1 µs) added to the UTC instant. So `t` always moves forward, and no wall time is converted back to UTC. I found nothing that blocks the merge.
What I checked:
- **Always ends.** I fuzzed the function for 10 min per image under SIGALRM, RLIMIT_AS, `--memory` and `timeout`, with a spy on every loop step:
- 92 524 calls in the CI image (EU and US wall-clock rules).
- 211 527 + 140 460 calls in a tzdata image (600 zones; starts within ±40 h of real transitions; spans that are negative, zero, sub-second or up to 80 h; random start and end zones).
- Result: 0 time-outs, 0 memory failures, 0 steps that did not move forward.
- Naive input raises `TypeError`, as before. No caller passes it: `now` is `datetime.now(timezone.utc)` and run edges are fixed-offset.
- **Normal days.** On spans with no offset change, the slices equal a1d0703's: 62 507 (CI image) + 103 179 (tzdata) spans compared.
- **Autumn day.** The labels are used only as `house_w(h)` lookups in `plan()` (`dayplan.py:201`, `:223`), never as a dict key. I compared `plan()` old and new at every quarter of 30 Oct-1 Nov 2027: 3 pack mixes, 3 price shapes (one with a dear run over the doubled 02:00 hour), a state-only and a `wh_period` forecast, in both tz modes. That is 10 368 comparisons with 0 differences.
- **Severity.** The stall needs `_EUTimezone`. The add-on image does not use it:
- `amd64-base:3.20` has `tzdata-2025b-r0`.
- The `aarch64-base:3.20` layer has `P:tzdata` and `zoneinfo/Europe/Brussels`.
- I built the add-on from this repo's Dockerfile and got `BILLING_TZ = ZoneInfo`. There the old function returns on both days.
- Owner shape on base, no tzdata, every quarter 26-29 March 2027: 0 calls. With the threshold lowered to 0.02: 146 calls, none across the change, no hang.
- An all-Solar-first site on dynamic prices hangs at 40 of 48 quarters on base and at 0 on head.
- **Mutations.** The tests catch base and 7 of 8 wrong fixes. Details are in the review report.
- **Replay digests.** base = head for `p3_ls fc`, `p3_both_ch fc` and `modes_off`.
- **Offline suite.** 84/84 pass in `goodwe-addon-checks:1` with the hwsim.
### Findings (none blocking)
1. **Low, `test_safety123.py:280-309`, `:409-421`.** The normal-day comparisons run without `limited()`.
- A wrong fix that loops without allocating memory (my mutation R4) fails in part 1, then hangs in part 2.
- CI has no per-file timeout (`checks.yml:212`, only the 70-min job limit), so the job would use up the shared runner's 70 min and print no FAIL line.
- This is not a false pass. Fix: wrap these calls in `limited()`.
- Also `class Slow(Exception)` (`:63`) would be swallowed by any `except Exception` between the loop and the alarm. There is none today. Making it a `BaseException` closes that.
2. **Info, `dayplan.py:168`.** Removing `.astimezone(timezone.utc)` (mutation R1) is not caught, because every test input is UTC or a fixed offset. No caller passes anything else, and R1 does not stall. It only gives wrong slices for a start that carries a zone such as `BILLING_TZ`.
3. **Info, CI coverage.** CI has no tzdata, so the ZoneInfo zones (and the autumn 1-h/1-h shape under ZoneInfo) are "not graded" there. I covered them locally in a tzdata image.
4. **Info, outside the diff.** `price.py:116-119` and `static_tariff.py:165-170` still say the shipped image has no tzdata. That is SAFETY-120's remaining item.
5. **Info.** In zones whose clock changes at a time that is not a whole local hour (Pacific/Chatham at 02:45), one slice can span two local hours. The old function does the same, and `BILLING_TZ` is always Europe/Brussels.
admin
merged commit f5afb420cf into release/1.02026-10-27 11:00:22 +01:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
SAFETY-123:
dayplan._hour_slices()could not end on the spring clock change.What. It added an hour to the local wall clock and read it back. Where the zone has no tzdata (
tariff._EUTimezone: the CI image, a dev host), 02:00 on 28 March 2027 reads as 00:00 UTC again, sotnever advanced and the list grew until the process died. Control and the web page share one event loop.dayplan.plan()calls it on every plan, before it looks at a battery's mode.Fix. Each step adds the time left in the local hour to the UTC instant (at least a microsecond), so the loop always advances and ends. Ordinary days give the old slices. The spring day gives 23 slices, the autumn day 25.
Scope. No option, no text change, no CHANGELOG or version. The shipped image has tzdata, where the old function does not stall (measured), so a running add-on is not known to be hit. AUDIT-01 F4 (SAFETY-120) is the same defect.
One change on the autumn day. The old function (with tzdata) made one two-hour slice for the doubled 02:00 hour. It now makes two slices of an hour, both labelled 2. The plan sums per slice, and there is no sun at 02:00, so the plan is the same.
Tests.
test_safety123.py, each call under a 3 s alarm: the slices at every quarter of 27-29 March and 30 Oct - 1 Nov 2027 (and the US change days) in the no-tzdata zone and a US-rule zone built the same way (plus Europe/Brussels, America/New_York, Lord_Howe, Santiago, Kathmandu where tzdata exists); normal days equal the old slices;dayplan.plan()andController._mode_roles()at every quarter with Solar first, Leftover solar and Cheap hours; a whole control cycle at 21:00, 23:45 and 00:00 UTC. Eight deliberate breakages are each caught. Replay digests (modes on and off, 3 recorded days) equal release/1.0.Report:
reports/SAFETY-123-dev-report.md.🤖 Generated with Claude Code
https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
SAFETY-123 review (Opus): approve and merge, head
d2820e8I reviewed head
d2820e8against basea1d0703, checking it again with ls-remote just before merging. CI run 1408checks / unitis a real run on the merge (Updating a1d0703..d2820e8, 84 PASS, test_freeze against the rig hwsim). Run 1409infra-pin / freshis also green.Verdict: the fix is right.
dayplan.py:166-168sets each step to the time left in the local hour (at least 1 µs) added to the UTC instant. Sotalways moves forward, and no wall time is converted back to UTC. I found nothing that blocks the merge.What I checked:
--memoryandtimeout, with a spy on every loop step:TypeError, as before. No caller passes it:nowisdatetime.now(timezone.utc)and run edges are fixed-offset.house_w(h)lookups inplan()(dayplan.py:201,:223), never as a dict key. I comparedplan()old and new at every quarter of 30 Oct-1 Nov 2027: 3 pack mixes, 3 price shapes (one with a dear run over the doubled 02:00 hour), a state-only and awh_periodforecast, in both tz modes. That is 10 368 comparisons with 0 differences._EUTimezone. The add-on image does not use it:amd64-base:3.20hastzdata-2025b-r0.aarch64-base:3.20layer hasP:tzdataandzoneinfo/Europe/Brussels.BILLING_TZ = ZoneInfo. There the old function returns on both days.p3_ls fc,p3_both_ch fcandmodes_off.goodwe-addon-checks:1with the hwsim.Findings (none blocking)
test_safety123.py:280-309,:409-421. The normal-day comparisons run withoutlimited().checks.yml:212, only the 70-min job limit), so the job would use up the shared runner's 70 min and print no FAIL line.limited().class Slow(Exception)(:63) would be swallowed by anyexcept Exceptionbetween the loop and the alarm. There is none today. Making it aBaseExceptioncloses that.dayplan.py:168. Removing.astimezone(timezone.utc)(mutation R1) is not caught, because every test input is UTC or a fixed offset. No caller passes anything else, and R1 does not stall. It only gives wrong slices for a start that carries a zone such asBILLING_TZ.price.py:116-119andstatic_tariff.py:165-170still say the shipped image has no tzdata. That is SAFETY-120's remaining item.BILLING_TZis always Europe/Brussels.