Repository navigation
fix(octopus): keep the day/night rate schedule whole across a clock change - #5655
Conversation
The local-anchored night windows (GO, Intelligent GO, manual local times and TOU-derived local windows) were built by adding days and minutes to an aware local midnight. That keeps the starting UTC offset, so in the week before the autumn change the last day's midnight normalised back onto the changeover day: two nights landed on 25 Oct, the 26th was lost and the 8-day schedule came out one entry short. Windows spanning the change were also an hour out at one end (ending 04:30 GMT in autumn, 06:30 BST in spring). Work the local windows out in naive wall-clock time and localise each end, so each takes the offset in force at that moment. The octopus_url day_night_rates test never pinned its clock (it set my_predbat.now_utc_exact, which the component does not read), so it started failing CI on every PR once the change came within the schedule's reach. Pin it clear of any DST change, drop the changeover-day exclusion from the TOU-window DST test, and add a GO regression for both clock changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ically pytz's localize() defaults to is_dst=False, which put a local window starting in the repeated autumn hour on its second (GMT) occurrence, an hour late, and let a window in the skipped spring hour end before it started. Take the first occurrence of a repeated time and move a skipped time to the moment the clocks jump, so later wall-clock times never map to earlier instants. Also stop the octopus_url day/night test writing now_utc_exact onto the shared fixture (the wrapper pins the clock), and cover IOG's cross-midnight window and manual windows in the changeover hour at both clock changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rop skipped-hour windows Move the closure into a module-level _localise_wall_clock so its clock-change rules are tested directly, including the non-pytz fallback. A local window lying wholly inside the skipped spring hour collapsed to zero length and was still emitted as a schedule entry; it is now dropped for that night and the day rate runs on across it. Test 15 restores octopus_night_times rather than deleting it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Share a context manager for the overrides, so the IOG cases in Test 15 run with the key cleared and every case restores whatever was there before. Fold Test 13's one-night-per-date check into its if/elif chain, so a failure no longer prints a PASS straight after it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
springfall2008
left a comment
There was a problem hiding this comment.
Automated comment from the triage bot.
Review at head 25d0292 (reading the diff, the enclosing code and the tests' data flow; I did not execute the PR — CI's pre-commit run is green at this head and covers both modified test modules). No correctness problems found. 0 findings inline, 1 minor note in the body.
What I checked and can confirm:
- The diagnosis reproduces. On current main,
tzinfo.normalize()on the day-6 local anchor rewinds 26 Oct 00:00+0100 to 25 Oct, so two nights share the 25th date, the final interval pair then overlaps (its end is after the next window's start), the last day-gap is not appended, and the schedule comes out at 15 entries — exactly the CI failure this week. The naive wall-clock rebuild takes the offset in force at each end and gives 16 contiguous entries across both 25 Oct and 29 Mar. _localise_wall_clockworks with the tzinfo it will actually receive. In productionnow_utc_exact.tzinfois a specific-offsetpytz.tzinfo.DstTzInfoinstance (whatdatetime.now(self.local_tz)returns), not the zone factory. I confirmed from the installed pytz source (pytz/tzinfo.py) that those instances still carry the full zone transition table (_utc_transition_timesis a class attribute,_tzinfosis shared), havelocalize, and raise Ambiguous/NonExistent underis_dst=Noneexactly as the zone factory does.is_dst=Trueon an ambiguous wall time picks the earliest-by-UTC occurrence — the first pass of the repeated hour — as the docstring states. The minute-walk overNonExistentTimeErroris bounded by the gap length (one minute per step), so thewhile Trueterminates.- The zero-length guard is sound. The walk is monotone (later wall-clock never maps to an earlier instant), so
night_end > night_startcan only be violated when both ends land on the spring jump instant, which is exactly the wholly-skipped window case; dropped windows are absorbed by the neighbouring day-rate gap. - Tests cover the change. Test 14 is the failing CI assertion itself (16 vs 15); Test 15 drives both the kept-after-jump spring window (01:30–02:15 becomes 02:00–02:15, matching a local-time meter) and the wholly-skipped drop; Test 16's eight unit-cases agree with the pytz semantics above, including the boundary-at-1:59:30 case. Manual
octopus_night_timesoverrides restore infinallyin the old tests and via the new_night_timeshelper (both the set and unset cases) in the new ones. Removing themy_predbat.now_utc_exact = ...write is safe: no test in the suite reads that attribute back (I grepped), so no later sub-test depended on the leaked value. - No collateral impact.
fetch_tariffs→get_octopus_rates_direct→minute_dataparses the offset-suffixed strings per row, so the mixed+0100/+0000offsets a changeover night now produces within one schedule are handled on the standard path; and neither golden case incoverage/cases/exercises day/night rates (grep for "day-unit-rates" finds nothing), so no golden expectation needed moving. No setting, entity or default changed, so no docs update is required by the project's rules. - Scope accepted as the PR itself states: TOU labels still being converted with "today's" offset for about a week after a change (pre-existing), and windows touching the repeated hour modelled once at the first occurrence (built-in windows are unaffected). Having the follow-up noted in the PR body is enough for now.
apps/predbat/octopus.py:1982 — stale docstring in the function this PR edits (pre-existing, body-only because the line is outside every hunk)
The docstring of async_get_day_night_rates still says the unknown-tariff code falls back to "00:30 to 07:30", but _fallback_night_window() returns OCTOPUS_NIGHT_RATE_WINDOWS["go"] for an unrecognised code — 00:30–05:30, local-anchored (utc: False) — and that is exactly the path tests/test_octopus_url.py's day/night sub-tests take (tariff_code=""). The same slip appears in Test 7's comment there ("Night rate: 00:30-07:30, Day rate: 07:30-00:30"; the assertion itself only checks alternation, so it is unaffected). Now that this PR pins the real behaviour in tests, the docstring one line away contradicts them; splitting the bullet (E-2R- → 00:30–07:30 UTC, unknown → GO 00:30–05:30 local) would settle it. No behaviour impact.
Posted by Claude on behalf of @chalfontchubby.
Summary
The weekly day/night rate schedule is built wrong in the week before the clocks go back. This currently fails CI on every PR, through
octopus_url/day_night_rates("Expected 16 rate entries (8 days), got 15"), and keeps failing until about 25 Oct. It is a real bug for users, not just a test problem.Who is affected: users on a dual-rate Octopus tariff whose off-peak window is fixed to local time. That means GO, Intelligent GO, an unrecognised tariff code (which falls back to GO),
octopus_night_timesentries in local time, and TOU-label windows on a local-time tariff. Economy 7 and other UTC-anchored windows are unaffected.The bug
async_get_day_night_ratesbuilt each local window by addingtimedelta(days=…)andtimedelta(minutes=…)to a pytz-aware local midnight. That arithmetic keeps the original UTC offset. Re-normalising and then snapping back to midnight moved the last day backwards once a change fell inside the 9-day span. With now = 20 Oct 2026, for example:The existing DST test (Test 13 in
test_octopus_tou_windows) had already noticed the changeover-day problem, but it excluded that day instead of fixing it.octopus_url's test never pinned its clock. It setmy_predbat.now_utc_exact, which the component doesn't read, becauseComponentBase.now_utc_exactcallsdatetime.now(). That is why the failure appeared only this week.The fix
_localise_wall_clock, so it takes the offset in force at that moment. UTC windows are unchanged.octopus_urlis pinned to 1 July 2024 and no longer writesnow_utc_exactonto the shared fixture._localise_wall_clockcalled directly, including a zone that isn't pytz.Evidence
main. In autumn they show 15 entries, a duplicated 25 Oct and a missing 26 Oct. In spring the changeover night ends at 06:30 local, and a manual window in the skipped hour inverts../run_all --quickpasses, andrun_pre_commitpasses.Known limits (not addressed here)
🤖 Generated with Claude Code