Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 35 additions & 8 deletions apps/predbat/octopus.py
Original file line number Diff line number Diff line change
Expand Up @@ -629,6 +629,26 @@ def _tou_window_to_local(start_minute, end_minute, utc_offset_minutes):
return start_minute, end_minute


def _localise_wall_clock(local_tz, wall_clock):
"""
Attach a zone to a naive wall-clock time, taking the offset in force at that time.

For a pytz zone, a time in the repeated autumn hour takes its first occurrence and one in the
skipped spring hour moves to the moment the clocks jump, so later wall-clock times never map
to earlier instants and no interval can invert across a change. Any other tzinfo is attached
as it stands.
"""
if not hasattr(local_tz, "localize"):
return wall_clock.replace(tzinfo=local_tz)
while True:
try:
return local_tz.localize(wall_clock, is_dst=None)
except pytz.exceptions.AmbiguousTimeError:
return local_tz.localize(wall_clock, is_dst=True)
except pytz.exceptions.NonExistentTimeError:
wall_clock = wall_clock.replace(second=0, microsecond=0) + timedelta(minutes=1)


def _tou_windows_from_measurements(response_data):
"""
Derive the meter's off-peak windows from measurement TOU bucket labels.
Expand Down Expand Up @@ -2017,21 +2037,28 @@ async def async_get_day_night_rates(self, url, product_code="", tariff_code=""):
# British Summer Time; GO and Intelligent GO are local wall-clock windows and must not
# move with the seasons. A hand-configured schedule may mix the two.
utc_midnight = self.now_utc_exact.astimezone(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
local_midnight = self.now_utc_exact.replace(hour=0, minute=0, second=0, microsecond=0)
local_tz = self.now_utc_exact.tzinfo
local_midnight_naive = self.now_utc_exact.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=None)

# Build the off-peak intervals over the 8-day schedule, starting 2 days back. A ninth
# day is generated so the last day-rate gap has a following off-peak window to end at.
# Local windows are worked out in naive wall-clock time and only then localised, so each
# end takes the offset in force at that moment. Adding days or minutes to an aware
# datetime keeps the original offset, which across the autumn clock change put two nights
# on the same date and dropped the last day of the schedule.
night_intervals = []
for day in range(-2, 7):
utc_base = utc_midnight + timedelta(days=day)
local_base = local_midnight + timedelta(days=day)
# Re-normalise the local anchor so the wall-clock time holds across a DST change,
# since adding days to a localised datetime keeps the original UTC offset.
if hasattr(local_base.tzinfo, "normalize"):
local_base = local_base.tzinfo.normalize(local_base).replace(hour=0, minute=0, second=0, microsecond=0)
local_base_naive = local_midnight_naive + timedelta(days=day)
for start_minute, end_minute, window_is_utc in windows:
base = utc_base if window_is_utc else local_base
night_intervals.append((base + timedelta(minutes=start_minute), base + timedelta(minutes=end_minute)))
if window_is_utc:
night_intervals.append((utc_base + timedelta(minutes=start_minute), utc_base + timedelta(minutes=end_minute)))
else:
night_start = _localise_wall_clock(local_tz, local_base_naive + timedelta(minutes=start_minute))
night_end = _localise_wall_clock(local_tz, local_base_naive + timedelta(minutes=end_minute))
# A window lying wholly in the skipped spring hour never happens that night
if night_end > night_start:
night_intervals.append((night_start, night_end))
night_intervals.sort()

# The day rate fills each gap between consecutive off-peak windows, which generalises to
Expand Down
141 changes: 132 additions & 9 deletions apps/predbat/tests/test_octopus_tou_windows.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,13 @@
"""

import asyncio
from contextlib import contextmanager
from datetime import datetime, timezone
from unittest.mock import patch, PropertyMock

import pytz

from octopus import OctopusAPI, DATE_TIME_STR_FORMAT, _night_time_to_minutes, _windows_from_night_times
from octopus import OctopusAPI, DATE_TIME_STR_FORMAT, _night_time_to_minutes, _windows_from_night_times, _localise_wall_clock


LONDON = pytz.timezone("Europe/London")
Expand Down Expand Up @@ -121,6 +122,24 @@ async def mock_query(query, context, **kwargs):
api.async_graphql_query = mock_query


@contextmanager
def _night_times(my_predbat, value):
"""Set octopus_night_times on the shared fixture for the block (None clears it), restoring the prior state after."""
had_key = "octopus_night_times" in my_predbat.args
saved = my_predbat.args.get("octopus_night_times")
if value is None:
my_predbat.args.pop("octopus_night_times", None)
else:
my_predbat.args["octopus_night_times"] = value
try:
yield
finally:
if had_key:
my_predbat.args["octopus_night_times"] = saved
else:
my_predbat.args.pop("octopus_night_times", None)


def _stub_tou_unavailable(api_and_now):
"""Stub the measurements query so it returns nothing, as it would for a meter with no history."""
api = api_and_now[0]
Expand Down Expand Up @@ -155,6 +174,9 @@ async def test_octopus_tou_windows(my_predbat):
- Test 11: an hour of 24 is normalised to 0, matching how basic_rates parses times
- Test 12: an IOG meter's TOU labels never become a recurring schedule (issue: phantom slot)
- Test 13: a TOU-derived window on a local wall-clock tariff holds across a DST change
- Test 14: the hard-wired GO window keeps 8 contiguous nights across both clock changes
- Test 15: IOG and manual windows in the changeover hour stay contiguous and never invert
- Test 16: _localise_wall_clock directly, and a window wholly inside the skipped hour
"""
print("\n**** Running Octopus TOU window tests ****")
failed = False
Expand Down Expand Up @@ -405,23 +427,124 @@ async def test_octopus_tou_windows(my_predbat):
print("\n*** Test 13: TOU window on a local-anchored tariff holds across DST ***")
api13 = _make_api(my_predbat, day_rate=29.14, night_rate=7.00, now=LONDON.localize(datetime(2026, 10, 22, 12, 0, 0)))
_stub_tou(api13, [(1410, 1440), (0, 270)]) # 23:30-04:30 UTC, which is 00:30-05:30 BST
all_slots13 = _night_slots_local(await _run(api13, base_url, "E-1R-GO-VAR-22-10-14-M"), 7.00)

# The changeover day itself is excluded: a window that spans the 02:00 change comes out an hour
# short, because the schedule builder adds a timedelta to an already-localised midnight and so
# keeps that day's opening UTC offset for the whole window. That is pre-existing behaviour of
# every local-anchored window - the hard-wired GO window produces the same two entries on this
# date with no TOU labels involved at all - so it is not what this test is pinning down.
slots13 = [(s, e) for s, e in all_slots13 if (s.month, s.day) != (10, 25)]
slots13 = _night_slots_local(await _run(api13, base_url, "E-1R-GO-VAR-22-10-14-M"), 7.00)

# The changeover day is included: the window opens at 00:30 BST and closes at 05:30 GMT.
nights13 = [(s.month, s.day) for s, _ in slots13]
drifted = [(s, e) for s, e in slots13 if (s.hour, s.minute) != (0, 30) or (e.hour, e.minute) != (5, 30)]
if not slots13:
print("ERROR: no night slots produced for the GO meter across the DST change")
failed = True
elif len(nights13) != len(set(nights13)) or (10, 25) not in nights13 or (10, 26) not in nights13:
print("ERROR: expected one night per date including 25 and 26 Oct, got {}".format(nights13))
failed = True
elif drifted:
print("ERROR: TOU window drifted off local time across the DST change - {} of {} slots wrong, e.g. {}".format(len(drifted), len(slots13), [(s.strftime("%d %H:%M"), e.strftime("%H:%M")) for s, e in drifted[:3]]))
failed = True
else:
print("PASS: TOU-derived window stays 00:30-05:30 local on both sides of the DST change")

# ------------------------------------------------------------------
# Test 14: the hard-wired GO window across both clock changes
#
# Adding days to a localised midnight keeps the starting UTC offset, so across the autumn change
# the last day of the schedule landed on the day before and the 8-day schedule lost an entry.
# ------------------------------------------------------------------
for label14, now14, changeover14 in (
("autumn", LONDON.localize(datetime(2026, 10, 20, 9, 0, 0)), (10, 25)),
("spring", LONDON.localize(datetime(2026, 3, 25, 9, 0, 0)), (3, 29)),
):
print("\n*** Test 14: hard-wired GO window across the {} clock change ***".format(label14))
api14 = _make_api(my_predbat, day_rate=29.14, night_rate=7.00, now=now14)
_stub_tou_unavailable(api14)
mdata14 = await _run(api14, base_url, "E-1R-GO-VAR-22-10-14-M")
slots14 = _night_slots_local(mdata14, 7.00)
nights14 = [(s.month, s.day) for s, _ in slots14]
drifted14 = [(s, e) for s, e in slots14 if (s.hour, s.minute) != (0, 30) or (e.hour, e.minute) != (5, 30)]
gaps14 = [index for index in range(len(mdata14) - 1) if datetime.strptime(mdata14[index]["valid_to"], DATE_TIME_STR_FORMAT) != datetime.strptime(mdata14[index + 1]["valid_from"], DATE_TIME_STR_FORMAT)]
if len(mdata14) != 16:
print("ERROR: {}: expected 16 entries (8 days), got {} - nights {}".format(label14, len(mdata14), nights14))
failed = True
elif len(nights14) != len(set(nights14)) or changeover14 not in nights14:
print("ERROR: {}: expected one night per date including the changeover, got {}".format(label14, nights14))
failed = True
elif drifted14:
print("ERROR: {}: night window drifted off 00:30-05:30 local, e.g. {}".format(label14, [(s.strftime("%d %H:%M"), e.strftime("%H:%M")) for s, e in drifted14[:3]]))
failed = True
elif gaps14:
print("ERROR: {}: schedule is not contiguous at entries {}".format(label14, gaps14))
failed = True
else:
print("PASS: {}: 8 nights at 00:30-05:30 local, contiguous, changeover night included".format(label14))

# ------------------------------------------------------------------
# Test 15: windows that cross midnight or sit in the changeover hour itself
#
# IOG's 23:30-05:30 spans the change. A manual window starting in the repeated autumn hour takes
# its first occurrence, and one starting in the skipped spring hour opens when the clocks jump -
# in neither case may an interval end before it starts or the schedule leave a gap.
# ------------------------------------------------------------------
cases15 = [
("IOG autumn", LONDON.localize(datetime(2026, 10, 20, 9, 0, 0)), "E-1R-IOG-SMB-TOU-25-12-12-H", None, datetime(2026, 10, 24, 22, 30, tzinfo=timezone.utc), datetime(2026, 10, 25, 5, 30, tzinfo=timezone.utc)),
("IOG spring", LONDON.localize(datetime(2026, 3, 25, 9, 0, 0)), "E-1R-IOG-SMB-TOU-25-12-12-H", None, datetime(2026, 3, 28, 23, 30, tzinfo=timezone.utc), datetime(2026, 3, 29, 4, 30, tzinfo=timezone.utc)),
("manual 01:30 autumn", LONDON.localize(datetime(2026, 10, 20, 9, 0, 0)), eco7, [{"start": "01:30:00", "end": "05:30:00"}], datetime(2026, 10, 25, 0, 30, tzinfo=timezone.utc), datetime(2026, 10, 25, 5, 30, tzinfo=timezone.utc)),
("manual 01:30 spring", LONDON.localize(datetime(2026, 3, 25, 9, 0, 0)), eco7, [{"start": "01:30:00", "end": "02:15:00"}], datetime(2026, 3, 29, 1, 0, tzinfo=timezone.utc), datetime(2026, 3, 29, 1, 15, tzinfo=timezone.utc)),
]
for label15, now15, tariff15, manual15, want_start15, want_end15 in cases15:
print("\n*** Test 15: {} ***".format(label15))
api15 = _make_api(my_predbat, day_rate=29.14, night_rate=7.00, now=now15)
_stub_tou_unavailable(api15)
with _night_times(my_predbat, manual15):
mdata15 = await _run(api15, base_url, tariff15)
entries15 = [(datetime.strptime(entry["valid_from"], DATE_TIME_STR_FORMAT), datetime.strptime(entry["valid_to"], DATE_TIME_STR_FORMAT)) for entry in mdata15]
inverted15 = [(s, e) for s, e in entries15 if e <= s]
gaps15 = [index for index in range(len(entries15) - 1) if entries15[index][1] != entries15[index + 1][0]]
if len(entries15) != 16 or inverted15 or gaps15:
print("ERROR: {}: expected 16 contiguous forward entries, got {} with inverted {} and gaps at {}".format(label15, len(entries15), inverted15[:2], gaps15))
failed = True
elif (want_start15, want_end15) not in _night_slots_utc(mdata15, 7.00):
print("ERROR: {}: changeover night {} - {} UTC missing, got {}".format(label15, want_start15, want_end15, [(s.strftime("%d %H:%M"), e.strftime("%d %H:%M")) for s, e in _night_slots_utc(mdata15, 7.00)]))
failed = True
else:
print("PASS: {}: changeover night runs {} - {} UTC, schedule contiguous".format(label15, want_start15.strftime("%d %H:%M"), want_end15.strftime("%d %H:%M")))

# ------------------------------------------------------------------
# Test 16: _localise_wall_clock on its own, at both changes and for a non-pytz zone
# ------------------------------------------------------------------
print("\n*** Test 16: _localise_wall_clock resolves repeated and skipped wall-clock times ***")
cases16 = [
("before the autumn change", LONDON, datetime(2026, 10, 25, 0, 59), datetime(2026, 10, 24, 23, 59, tzinfo=timezone.utc)),
("repeated autumn hour takes the first occurrence", LONDON, datetime(2026, 10, 25, 1, 30), datetime(2026, 10, 25, 0, 30, tzinfo=timezone.utc)),
("after the autumn change", LONDON, datetime(2026, 10, 25, 2, 0), datetime(2026, 10, 25, 2, 0, tzinfo=timezone.utc)),
("before the spring change", LONDON, datetime(2026, 3, 29, 0, 59), datetime(2026, 3, 29, 0, 59, tzinfo=timezone.utc)),
("skipped spring hour moves to the jump", LONDON, datetime(2026, 3, 29, 1, 0), datetime(2026, 3, 29, 1, 0, tzinfo=timezone.utc)),
("late in the skipped spring hour moves to the jump", LONDON, datetime(2026, 3, 29, 1, 59, 30), datetime(2026, 3, 29, 1, 0, tzinfo=timezone.utc)),
("after the spring change", LONDON, datetime(2026, 3, 29, 2, 15), datetime(2026, 3, 29, 1, 15, tzinfo=timezone.utc)),
("non-pytz zone is attached as it stands", timezone.utc, datetime(2026, 10, 25, 1, 30), datetime(2026, 10, 25, 1, 30, tzinfo=timezone.utc)),
]
for label16, tz16, wall16, want16 in cases16:
got16 = _localise_wall_clock(tz16, wall16)
if got16 != want16:
print("ERROR: {}: {} localised to {}, expected {}".format(label16, wall16, got16, want16))
failed = True
else:
print("PASS: {}: {} -> {} UTC".format(label16, wall16.strftime("%d %b %H:%M:%S"), got16.astimezone(timezone.utc).strftime("%H:%M")))

# A manual window lying wholly in the skipped hour is dropped that night rather than emitted
# with zero length, and the day rate runs on across it.
api16 = _make_api(my_predbat, day_rate=29.14, night_rate=7.00, now=LONDON.localize(datetime(2026, 3, 25, 9, 0, 0)))
_stub_tou_unavailable(api16)
with _night_times(my_predbat, [{"start": "01:00:00", "end": "01:30:00"}]):
mdata16 = await _run(api16, base_url, eco7)
entries16 = [(datetime.strptime(entry["valid_from"], DATE_TIME_STR_FORMAT), datetime.strptime(entry["valid_to"], DATE_TIME_STR_FORMAT)) for entry in mdata16]
nights16 = [s.astimezone(LONDON).day for s, _ in _night_slots_utc(mdata16, 7.00)]
if any(e <= s for s, e in entries16) or any(entries16[index][1] != entries16[index + 1][0] for index in range(len(entries16) - 1)):
print("ERROR: skipped-hour window left an empty or broken schedule: {}".format([(s.strftime("%d %H:%M%z"), e.strftime("%d %H:%M%z")) for s, e in entries16]))
failed = True
elif 29 in nights16 or 28 not in nights16 or 30 not in nights16:
print("ERROR: skipped-hour window should be dropped on 29 Mar only, got nights on {}".format(nights16))
failed = True
else:
print("PASS: a window wholly inside the skipped hour is dropped on 29 Mar, schedule contiguous")

return failed
14 changes: 9 additions & 5 deletions apps/predbat/tests/test_octopus_url.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
import asyncio
import json
from datetime import datetime, timezone, timedelta
from unittest.mock import Mock, patch
from unittest.mock import Mock, patch, PropertyMock
import pytz
from octopus import OctopusAPI, DATE_TIME_STR_FORMAT
from const import TIME_FORMAT_OCTOPUS
from utils import dp4
Expand Down Expand Up @@ -211,8 +212,13 @@ async def test_download_octopus_url(my_predbat):
def _test_async_get_day_night_rates_wrapper(my_predbat):
"""
Wrapper to run the async test function for day/night rates

The clock is pinned clear of a DST change: the 8-day schedule is built from the component's own
now_utc_exact, so an unpinned run inside a week of the clocks changing sees a different shape.
"""
return asyncio.run(test_async_get_day_night_rates(my_predbat))
pinned_now = pytz.timezone("Europe/London").localize(datetime(2024, 7, 1, 12, 0, 0))
with patch.object(OctopusAPI, "now_utc_exact", new_callable=PropertyMock, return_value=pinned_now):
return asyncio.run(test_async_get_day_night_rates(my_predbat))


async def test_async_get_day_night_rates(my_predbat):
Expand All @@ -234,9 +240,7 @@ async def test_async_get_day_night_rates(my_predbat):
# Create API instance
api = OctopusAPI(my_predbat, key="", account_id="", automatic=False)

# Set a fixed current time for predictable testing via my_predbat mock
fixed_time = datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)
my_predbat.now_utc_exact = fixed_time
# The clock is pinned by the wrapper, _test_async_get_day_night_rates_wrapper

# Test 1: Successful fetch with valid day and night rates
print("\n*** Test 1: Successful fetch with valid day and night rates ***")
Expand Down
Loading