Skip to content

fix(octopus): keep the day/night rate schedule whole across a clock change - #5655

Merged
springfall2008 merged 4 commits into
mainfrom
fix/day-night-rates-autumn-dst
Oct 20, 2026
Merged

springfall2008 merged 4 commits into
mainfrom
fix/day-night-rates-autumn-dst

Conversation

@chalfontchubby

Copy link
Copy Markdown
Collaborator

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_times entries 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_rates built each local window by adding timedelta(days=…) and timedelta(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:

  • two nights fall on 25 Oct and none on 26 Oct;
  • the schedule has 15 entries instead of 16, so the last day's rates are missing;
  • the changeover night's window ends an hour out at one end: 04:30 GMT instead of 05:30 in autumn, and 06:30 BST instead of 05:30 in spring.

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 set my_predbat.now_utc_exact, which the component doesn't read, because ComponentBase.now_utc_exact calls datetime.now(). That is why the failure appeared only this week.

The fix

  • Local windows are worked out in naive wall-clock time, and each end is then localised through a new module-level _localise_wall_clock, so it takes the offset in force at that moment. UTC windows are unchanged.
  • Changeover hour: a time in the repeated autumn hour takes its first occurrence, and a time in the skipped spring hour moves to the moment the clocks jump. Later wall-clock times therefore never map to earlier instants, and no interval can invert. A local window lying wholly inside the skipped hour is dropped for that night, rather than emitted with zero length.
  • Tests:
    • octopus_url is pinned to 1 July 2024 and no longer writes now_utc_exact onto the shared fixture.
    • Test 13 no longer excludes the changeover day.
    • New Tests 14–16 cover:
      • GO at both changes: 16 entries with no gaps;
      • IOG crossing midnight;
      • manual windows in the repeated and skipped hours;
      • _localise_wall_clock called directly, including a zone that isn't pytz.

Evidence

  • The new tests fail on 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.
  • They pass on this branch.
  • ./run_all --quick passes, and run_pre_commit passes.

Known limits (not addressed here)

  • TOU windows after a change: TOU-label windows on a local tariff are converted from UTC labels using today's offset. For about a week after a change, the 7-day label sample still reflects the old offset. This predates this PR and needs its own follow-up.
  • Repeated hour modelled once: a hand-configured window touching 01:00–02:00 local on autumn change night is modelled as one block at the first occurrence. A meter on local time actually passes through that hour twice. This is a deliberate simplification, and none of the built-in windows is affected.

🤖 Generated with Claude Code

chalfontchubby and others added 4 commits October 20, 2026 08:48
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>
@github-actions github-actions Bot added the BOT_REVIEW Trigger an autotriage label Oct 20, 2026

@springfall2008 springfall2008 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_clock works with the tzinfo it will actually receive. In production now_utc_exact.tzinfo is a specific-offset pytz.tzinfo.DstTzInfo instance (what datetime.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_times is a class attribute, _tzinfos is shared), have localize, and raise Ambiguous/NonExistent under is_dst=None exactly as the zone factory does. is_dst=True on an ambiguous wall time picks the earliest-by-UTC occurrence — the first pass of the repeated hour — as the docstring states. The minute-walk over NonExistentTimeError is bounded by the gap length (one minute per step), so the while True terminates.
  • The zero-length guard is sound. The walk is monotone (later wall-clock never maps to an earlier instant), so night_end > night_start can 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_times overrides restore in finally in the old tests and via the new _night_times helper (both the set and unset cases) in the new ones. Removing the my_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_data parses the offset-suffixed strings per row, so the mixed +0100/+0000 offsets a changeover night now produces within one schedule are handled on the standard path; and neither golden case in coverage/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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants