Skip to content

Commit c05f67c

Browse files
committed
fix(utils): compute midnight_utc with correct DST offset
On DST transition days, datetime.replace(hour=0) preserves the current UTC offset instead of the offset in effect at midnight. For example on UK spring-forward day, now at 14:00+01:00 produces midnight as 00:00+01:00 (actually 23:00 UTC previous day) instead of the correct 00:00+00:00. This shifts every rate minute index by 60 minutes, breaking charge/discharge schedules. Add a local_midnight() helper in utils.py that strips tzinfo and re-localises via pytz.localize(), which resolves the correct offset for the given date/time. For fixed-offset timezones (no DST) the simple replace() path is preserved. Replace all broken .replace(hour=0) patterns across predbat.py, gecloud.py, fox.py, octopus.py, solis.py, predheat.py, and the test_execute.py mock with calls to local_midnight(). Add a DST-specific test in test_fetch_octopus_rates.py verifying that rates with mixed +00:00/+01:00 offsets land at the correct minute indices.
1 parent c791d3a commit c05f67c

9 files changed

Lines changed: 108 additions & 16 deletions

File tree

apps/predbat/fox.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import random
2626
from component_base import ComponentBase
2727
from oauth_mixin import OAuthMixin
28+
from utils import local_midnight
2829

2930
# Define TIME_FORMAT_HA locally to avoid dependency issues
3031
TIME_FORMAT_HA = "%Y-%m-%dT%H:%M:%S%z"
@@ -1712,7 +1713,7 @@ def __init__(self):
17121713
self.now_utc = datetime.now(self.local_tz)
17131714
self.prefix = "predbat"
17141715
self.args = {}
1715-
self.midnight_utc = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
1716+
self.midnight_utc = local_midnight(datetime.now(self.local_tz), self.local_tz)
17161717
self.minutes_now = self.now_utc.hour * 60 + self.now_utc.minute
17171718
self.entities = {}
17181719

apps/predbat/gecloud.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
import aiohttp
1616
from datetime import timedelta, datetime
17-
from utils import str2time, dp1
17+
from utils import str2time, dp1, local_midnight
1818
from predbat_metrics import record_api_call
1919
import asyncio
2020
import json
@@ -1708,7 +1708,7 @@ def __init__(self):
17081708
self.now_utc = datetime.now(self.local_tz)
17091709
self.prefix = "predbat"
17101710
self.args = {}
1711-
self.midnight_utc = datetime.now(self.local_tz).replace(hour=0, minute=0, second=0, microsecond=0)
1711+
self.midnight_utc = local_midnight(datetime.now(self.local_tz), self.local_tz)
17121712
self.minutes_now = self.now_utc.hour * 60 + self.now_utc.minute
17131713
self.entities = {}
17141714
self.config_root = "./temp_gecloud"

apps/predbat/octopus.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from datetime import datetime, timedelta, timezone
2020
from predbat_metrics import record_api_call
2121
from const import TIME_FORMAT, TIME_FORMAT_OCTOPUS
22-
from utils import str2time, minutes_to_time, dp1, dp2, dp4, minute_data
22+
from utils import str2time, minutes_to_time, dp1, dp2, dp4, minute_data, local_midnight
2323
from component_base import ComponentBase
2424
import aiohttp
2525
import json
@@ -2718,7 +2718,7 @@ def __init__(self):
27182718
self.now_utc = datetime.now(self.local_tz)
27192719
self.prefix = "predbat"
27202720
self.args = {}
2721-
self.midnight_utc = datetime.now(self.local_tz).replace(hour=0, minute=0, second=0, microsecond=0)
2721+
self.midnight_utc = local_midnight(datetime.now(self.local_tz), self.local_tz)
27222722
self.minutes_now = self.now_utc.hour * 60 + self.now_utc.minute
27232723
self.entities = {}
27242724
self.config_root = "./temp_octopus"

apps/predbat/predbat.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@
7070
)
7171
from config import APPS_SCHEMA, CONFIG_ITEMS
7272
from prediction import reset_prediction_globals
73-
from utils import minutes_since_yesterday, dp1, dp2, dp3
73+
from utils import minutes_since_yesterday, dp1, dp2, dp3, local_midnight
7474
from predheat import PredHeat
7575
from octopus import Octopus
7676
from energydataservice import Energidataservice
@@ -709,7 +709,7 @@ def update_time(self, print=True):
709709
self.now_utc = now_utc
710710
self.now = now
711711
self.midnight = now.replace(hour=0, minute=0, second=0, microsecond=0)
712-
self.midnight_utc = now_utc.replace(hour=0, minute=0, second=0, microsecond=0)
712+
self.midnight_utc = local_midnight(now_utc, self.local_tz)
713713

714714
self.difference_minutes = minutes_since_yesterday(now)
715715
self.minutes_now = int((now - self.midnight).seconds / 60 / PREDICT_STEP) * PREDICT_STEP

apps/predbat/predheat.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121

2222
from datetime import datetime, timedelta
2323
import pytz
24-
from utils import str2time, dp2, dp3, minute_data
24+
from utils import str2time, dp2, dp3, minute_data, local_midnight
2525

2626
from const import TIME_FORMAT
2727

@@ -533,7 +533,7 @@ def update_pred(self, scheduled):
533533
self.forecast_days = self.get_arg("forecast_days", 2, domain="predheat")
534534
self.forecast_minutes = self.forecast_days * 60 * 24
535535
self.midnight = now.replace(hour=0, minute=0, second=0, microsecond=0)
536-
self.midnight_utc = now_utc.replace(hour=0, minute=0, second=0, microsecond=0)
536+
self.midnight_utc = local_midnight(now_utc, local_tz)
537537
self.minutes_now = int((now - self.midnight).seconds / 60 / PREDICT_STEP) * PREDICT_STEP
538538
self.metric_future_rate_offset_import = 0
539539

apps/predbat/solis.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from datetime import datetime, timedelta, UTC
1818
from predbat_metrics import record_api_call
1919
from component_base import ComponentBase
20+
from utils import local_midnight
2021

2122

2223
# API Endpoints
@@ -2869,7 +2870,7 @@ def __init__(self):
28692870
self.now_utc = datetime.now(self.local_tz)
28702871
self.prefix = "predbat"
28712872
self.args = {}
2872-
self.midnight_utc = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
2873+
self.midnight_utc = local_midnight(datetime.now(self.local_tz), self.local_tz)
28732874
self.minutes_now = self.now_utc.hour * 60 + self.now_utc.minute
28742875
self.entities = {}
28752876

apps/predbat/tests/test_execute.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,14 @@
99
# pylint: disable=attribute-defined-outside-init
1010

1111
from tests.test_infra import reset_inverter
12-
from utils import calc_percent_limit
12+
from utils import calc_percent_limit, local_midnight
1313

1414

1515
class ActiveTestInverter:
16-
def __init__(self, id, soc_kw, soc_max, now_utc):
16+
"""Mock inverter for execute tests."""
17+
18+
def __init__(self, id, soc_kw, soc_max, now_utc, local_tz=None):
19+
"""Initialise mock inverter."""
1720
self.soc_target = -1
1821
self.id = id
1922
self.isCharging = False
@@ -53,7 +56,7 @@ def __init__(self, id, soc_kw, soc_max, now_utc):
5356
self.battery_rate_max_discharge = 1 / 60.0
5457
self.reserve_max = 100.0
5558
self.now_utc = now_utc
56-
self.midnight_utc = now_utc.replace(hour=0, minute=0, second=0, microsecond=0)
59+
self.midnight_utc = local_midnight(now_utc, local_tz)
5760
self.count_register_writes = 0
5861
self.charge_window = []
5962
self.charge_limits = []
@@ -424,7 +427,7 @@ def run_execute_tests(my_predbat):
424427
export_limits_best3 = [50.5]
425428
export_limits_best_frz = [99]
426429

427-
inverters = [ActiveTestInverter(0, 0, 10.0, my_predbat.now_utc), ActiveTestInverter(1, 0, 10.0, my_predbat.now_utc)]
430+
inverters = [ActiveTestInverter(0, 0, 10.0, my_predbat.now_utc, my_predbat.local_tz), ActiveTestInverter(1, 0, 10.0, my_predbat.now_utc, my_predbat.local_tz)]
428431
my_predbat.inverters = inverters
429432
my_predbat.args["num_inverters"] = 2
430433

@@ -1249,7 +1252,7 @@ def run_execute_tests(my_predbat):
12491252
my_predbat.debug_enable = False
12501253

12511254
# Reset inverters
1252-
inverters = [ActiveTestInverter(0, 0, 10.0, my_predbat.now_utc), ActiveTestInverter(1, 0, 10.0, my_predbat.now_utc)]
1255+
inverters = [ActiveTestInverter(0, 0, 10.0, my_predbat.now_utc, my_predbat.local_tz), ActiveTestInverter(1, 0, 10.0, my_predbat.now_utc, my_predbat.local_tz)]
12531256
my_predbat.inverters = inverters
12541257

12551258
failed |= run_execute_test(my_predbat, "calibration", in_calibration=True, assert_status="Calibration", assert_charge_time_enable=False, assert_reserve=0, assert_soc_target=100)
@@ -1758,7 +1761,7 @@ def run_execute_tests(my_predbat):
17581761
failed |= run_execute_test(my_predbat, "charge_later2", charge_window_best=charge_window_best6, charge_limit_best=charge_limit_best, assert_charge_time_enable=False, set_charge_window=True, set_export_window=True, assert_status="Demand")
17591762
failed |= run_execute_test(my_predbat, "no_charge5", set_charge_window=True, set_export_window=True, assert_immediate_soc_target=0)
17601763
# Reset inverters
1761-
inverters = [ActiveTestInverter(0, 0, 10.0, my_predbat.now_utc), ActiveTestInverter(1, 0, 10.0, my_predbat.now_utc)]
1764+
inverters = [ActiveTestInverter(0, 0, 10.0, my_predbat.now_utc, my_predbat.local_tz), ActiveTestInverter(1, 0, 10.0, my_predbat.now_utc, my_predbat.local_tz)]
17621765
my_predbat.inverters = inverters
17631766

17641767
failed |= run_execute_test(my_predbat, "no_discharge", export_window_best=export_window_best, export_limits_best=export_limits_best, assert_reserve=-1)

apps/predbat/tests/test_fetch_octopus_rates.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,74 @@ def test_fetch_octopus_rates(my_predbat):
244244
else:
245245
print("Test 8 passed - sensor with no attributes returns empty dict")
246246

247+
# Test 9: DST transition day - mixed timezone offsets in rate data
248+
print("*** Test 9: DST spring-forward day with mixed +00:00 / +01:00 offsets")
249+
250+
# On UK spring-forward day (last Sunday in March), clocks go from GMT (+00:00)
251+
# to BST (+01:00) at 01:00 GMT. The Octopus integration delivers rates before
252+
# the transition with +00:00 and rates after with +01:00.
253+
# midnight_utc must carry the correct offset (+00:00) so that minute indices
254+
# are computed correctly for both sets of rates.
255+
256+
import pytz
257+
258+
london_tz = pytz.timezone("Europe/London")
259+
# Simulate "now" at 14:00 BST on spring-forward day
260+
my_predbat.local_tz = london_tz
261+
my_predbat.midnight_utc = london_tz.localize(datetime(2026, 3, 29, 0, 0, 0))
262+
my_predbat.forecast_days = 2
263+
264+
entity_id_dst = "sensor.metric_octopus_import_dst"
265+
dst_rates = [
266+
# Pre-DST rates (GMT, +00:00)
267+
{"start": "2026-03-29T00:00:00+00:00", "end": "2026-03-29T00:30:00+00:00", "value": 0.04138},
268+
{"start": "2026-03-29T00:30:00+00:00", "end": "2026-03-29T01:00:00+00:00", "value": 0.04135},
269+
# Post-DST rates (BST, +01:00) — 02:00 BST = 01:00 UTC
270+
{"start": "2026-03-29T02:00:00+01:00", "end": "2026-03-29T02:30:00+01:00", "value": 0.04000},
271+
{"start": "2026-03-29T02:30:00+01:00", "end": "2026-03-29T03:00:00+01:00", "value": 0.03991},
272+
]
273+
274+
my_predbat.ha_interface.dummy_items[entity_id_dst] = {
275+
"state": "0.04",
276+
"raw_today": dst_rates,
277+
}
278+
279+
rate_data = my_predbat.fetch_octopus_rates(entity_id_dst)
280+
281+
if not rate_data:
282+
print("ERROR: No rate data returned for DST test")
283+
failed = True
284+
else:
285+
# 00:00+00:00 is minute 0 from midnight
286+
expected_min0 = 0.04138 * 100 # scale=100 for start/end format
287+
if 0 not in rate_data:
288+
print("ERROR: DST test - missing rate at minute 0")
289+
failed = True
290+
elif abs(rate_data[0] - expected_min0) > 0.01:
291+
print("ERROR: DST test - minute 0 expected {}, got {}".format(expected_min0, rate_data[0]))
292+
failed = True
293+
294+
# 02:00+01:00 = 01:00 UTC = minute 60 from midnight (+00:00)
295+
expected_min60 = 0.04000 * 100
296+
if 60 not in rate_data:
297+
print("ERROR: DST test - missing rate at minute 60 (02:00 BST = 01:00 UTC)")
298+
failed = True
299+
elif abs(rate_data[60] - expected_min60) > 0.01:
300+
print("ERROR: DST test - minute 60 expected {}, got {}".format(expected_min60, rate_data[60]))
301+
failed = True
302+
303+
# 02:30+01:00 = 01:30 UTC = minute 90 from midnight (+00:00)
304+
expected_min90 = 0.03991 * 100
305+
if 90 not in rate_data:
306+
print("ERROR: DST test - missing rate at minute 90 (02:30 BST = 01:30 UTC)")
307+
failed = True
308+
elif abs(rate_data[90] - expected_min90) > 0.01:
309+
print("ERROR: DST test - minute 90 expected {}, got {}".format(expected_min90, rate_data[90]))
310+
failed = True
311+
312+
if 0 in rate_data and 60 in rate_data and 90 in rate_data:
313+
print("Test 9 passed - DST mixed-offset rates placed at correct minutes")
314+
247315
# Restore original values
248316
my_predbat.forecast_days = old_forecast_days
249317
my_predbat.midnight_utc = old_midnight_utc

apps/predbat/utils.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -876,6 +876,25 @@ def minutes_to_time(updated, now):
876876
return minutes
877877

878878

879+
def local_midnight(dt_aware, tz=None):
880+
"""Return midnight on the same local date as *dt_aware*, correctly localised.
881+
882+
``datetime.replace(hour=0)`` preserves the original UTC offset, which is
883+
wrong on DST-transition days (e.g. spring-forward: the current offset is
884+
+01:00 but midnight was still +00:00). Stripping the tzinfo and
885+
re-localising lets pytz pick the offset that was actually in effect at
886+
midnight.
887+
888+
*tz* should be a pytz timezone (has ``.localize()``). When *tz* is
889+
``None`` or a fixed-offset timezone the simple ``replace()`` path is used,
890+
which is safe because fixed-offset zones never change their UTC offset.
891+
"""
892+
if tz is not None and hasattr(tz, "localize"):
893+
naive_midnight = dt_aware.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=None)
894+
return tz.localize(naive_midnight)
895+
return dt_aware.replace(hour=0, minute=0, second=0, microsecond=0)
896+
897+
879898
def str2time(str):
880899
if "." in str:
881900
tdata = datetime.strptime(str, TIME_FORMAT_SECONDS)

0 commit comments

Comments
 (0)