Skip to content

Commit 929eeef

Browse files
committed
fix(utils): handle DST midnight offsets via tz-aware local_midnight
Implement local_midnight(dt_aware) to derive midnight from the datetime's own timezone and use pytz re-localisation when needed, so DST-transition days get the correct midnight offset. Update callers/tests accordingly and extend Octopus rate tests to cover both spring-forward and fall-back mixed-offset cases.
1 parent 4c5ba3c commit 929eeef

9 files changed

Lines changed: 196 additions & 12 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))
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, dp2
17+
from utils import str2time, dp1, dp2, local_midnight
1818
from predbat_metrics import record_api_call
1919
import asyncio
2020
import json
@@ -1711,7 +1711,7 @@ def __init__(self):
17111711
self.now_utc = datetime.now(self.local_tz)
17121712
self.prefix = "predbat"
17131713
self.args = {}
1714-
self.midnight_utc = datetime.now(self.local_tz).replace(hour=0, minute=0, second=0, microsecond=0)
1714+
self.midnight_utc = local_midnight(datetime.now(self.local_tz))
17151715
self.minutes_now = self.now_utc.hour * 60 + self.now_utc.minute
17161716
self.entities = {}
17171717
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))
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)
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)
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
@@ -2868,7 +2869,7 @@ def __init__(self):
28682869
self.now_utc = datetime.now(self.local_tz)
28692870
self.prefix = "predbat"
28702871
self.args = {}
2871-
self.midnight_utc = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
2872+
self.midnight_utc = local_midnight(datetime.now(self.local_tz))
28722873
self.minutes_now = self.now_utc.hour * 60 + self.now_utc.minute
28732874
self.entities = {}
28742875

apps/predbat/tests/test_execute.py

Lines changed: 5 additions & 2 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+
"""Mock inverter for execute tests."""
17+
1618
def __init__(self, id, soc_kw, soc_max, now_utc):
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)
5760
self.count_register_writes = 0
5861
self.charge_window = []
5962
self.charge_limits = []

apps/predbat/tests/test_fetch_octopus_rates.py

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@
1010

1111
from datetime import datetime
1212

13+
import pytz
14+
15+
from utils import local_midnight
16+
1317

1418
def test_fetch_octopus_rates(my_predbat):
1519
"""
@@ -244,6 +248,149 @@ def test_fetch_octopus_rates(my_predbat):
244248
else:
245249
print("Test 8 passed - sensor with no attributes returns empty dict")
246250

251+
# Test 9: DST transition day - mixed timezone offsets in rate data
252+
print("*** Test 9: DST spring-forward day with mixed +00:00 / +01:00 offsets")
253+
254+
# On UK spring-forward day (last Sunday in March), clocks go from GMT (+00:00)
255+
# to BST (+01:00) at 01:00 GMT. The Octopus integration delivers rates before
256+
# the transition with +00:00 and rates after with +01:00.
257+
# midnight_utc must carry the correct offset (+00:00) so that minute indices
258+
# are computed correctly for both sets of rates.
259+
#
260+
# We simulate "now" at 14:00 BST and derive midnight_utc via
261+
# local_midnight() to exercise the actual DST-offset fix (midnight was
262+
# still in GMT +00:00 even though 14:00 is in BST +01:00).
263+
264+
london_tz = pytz.timezone("Europe/London")
265+
# Simulate "now" at 14:00 BST on spring-forward day (13:00 UTC)
266+
now_bst = london_tz.localize(datetime(2026, 3, 29, 14, 0, 0))
267+
my_predbat.local_tz = london_tz
268+
my_predbat.midnight_utc = local_midnight(now_bst)
269+
270+
# Verify local_midnight() picked the correct GMT offset for midnight
271+
assert my_predbat.midnight_utc.utcoffset().total_seconds() == 0, "midnight_utc should have +00:00 offset on spring-forward day, got {}".format(my_predbat.midnight_utc.utcoffset())
272+
assert my_predbat.midnight_utc.hour == 0 and my_predbat.midnight_utc.minute == 0, "midnight_utc should be 00:00, got {}".format(my_predbat.midnight_utc)
273+
274+
my_predbat.forecast_days = 2
275+
276+
entity_id_dst = "sensor.metric_octopus_import_dst"
277+
dst_rates = [
278+
# Pre-DST rates (GMT, +00:00)
279+
{"start": "2026-03-29T00:00:00+00:00", "end": "2026-03-29T00:30:00+00:00", "value": 0.04138},
280+
{"start": "2026-03-29T00:30:00+00:00", "end": "2026-03-29T01:00:00+00:00", "value": 0.04135},
281+
# Post-DST rates (BST, +01:00) — 02:00 BST = 01:00 UTC
282+
{"start": "2026-03-29T02:00:00+01:00", "end": "2026-03-29T02:30:00+01:00", "value": 0.04000},
283+
{"start": "2026-03-29T02:30:00+01:00", "end": "2026-03-29T03:00:00+01:00", "value": 0.03991},
284+
]
285+
286+
my_predbat.ha_interface.dummy_items[entity_id_dst] = {
287+
"state": "0.04",
288+
"raw_today": dst_rates,
289+
}
290+
291+
rate_data = my_predbat.fetch_octopus_rates(entity_id_dst)
292+
293+
if not rate_data:
294+
print("ERROR: No rate data returned for DST test")
295+
failed = True
296+
else:
297+
# 00:00+00:00 is minute 0 from midnight
298+
expected_min0 = 0.04138 * 100 # scale=100 for start/end format
299+
if 0 not in rate_data:
300+
print("ERROR: DST test - missing rate at minute 0")
301+
failed = True
302+
elif abs(rate_data[0] - expected_min0) > 0.01:
303+
print("ERROR: DST test - minute 0 expected {}, got {}".format(expected_min0, rate_data[0]))
304+
failed = True
305+
306+
# 02:00+01:00 = 01:00 UTC = minute 60 from midnight (+00:00)
307+
expected_min60 = 0.04000 * 100
308+
if 60 not in rate_data:
309+
print("ERROR: DST test - missing rate at minute 60 (02:00 BST = 01:00 UTC)")
310+
failed = True
311+
elif abs(rate_data[60] - expected_min60) > 0.01:
312+
print("ERROR: DST test - minute 60 expected {}, got {}".format(expected_min60, rate_data[60]))
313+
failed = True
314+
315+
# 02:30+01:00 = 01:30 UTC = minute 90 from midnight (+00:00)
316+
expected_min90 = 0.03991 * 100
317+
if 90 not in rate_data:
318+
print("ERROR: DST test - missing rate at minute 90 (02:30 BST = 01:30 UTC)")
319+
failed = True
320+
elif abs(rate_data[90] - expected_min90) > 0.01:
321+
print("ERROR: DST test - minute 90 expected {}, got {}".format(expected_min90, rate_data[90]))
322+
failed = True
323+
324+
if 0 in rate_data and 60 in rate_data and 90 in rate_data:
325+
print("Test 9 passed - DST mixed-offset rates placed at correct minutes")
326+
327+
# Test 10: DST autumn fallback day - mixed timezone offsets in rate data
328+
print("*** Test 10: DST fall-back day with mixed +01:00 / +00:00 offsets")
329+
330+
# On UK fall-back day (last Sunday in October), clocks go from BST (+01:00)
331+
# to GMT (+00:00). Midnight is still in BST (+01:00), but later daytime is
332+
# in GMT (+00:00).
333+
#
334+
# We simulate "now" at 14:00 GMT and derive midnight_utc via
335+
# local_midnight() to verify it picks +01:00 for local midnight.
336+
now_gmt = london_tz.localize(datetime(2026, 10, 25, 14, 0, 0))
337+
my_predbat.midnight_utc = local_midnight(now_gmt)
338+
339+
# Verify local_midnight() picked the correct BST offset for midnight
340+
assert my_predbat.midnight_utc.utcoffset().total_seconds() == 3600, "midnight_utc should have +01:00 offset on fall-back day, got {}".format(my_predbat.midnight_utc.utcoffset())
341+
assert my_predbat.midnight_utc.hour == 0 and my_predbat.midnight_utc.minute == 0, "midnight_utc should be 00:00, got {}".format(my_predbat.midnight_utc)
342+
343+
entity_id_dst_fall = "sensor.metric_octopus_import_dst_fall"
344+
dst_fall_rates = [
345+
# Pre-fallback rates (BST, +01:00)
346+
{"start": "2026-10-25T00:00:00+01:00", "end": "2026-10-25T00:30:00+01:00", "value": 0.05000},
347+
{"start": "2026-10-25T00:30:00+01:00", "end": "2026-10-25T01:00:00+01:00", "value": 0.04900},
348+
# Post-fallback rates (GMT, +00:00) — 01:00 GMT = minute 120 from midnight (+01:00)
349+
{"start": "2026-10-25T01:00:00+00:00", "end": "2026-10-25T01:30:00+00:00", "value": 0.04800},
350+
{"start": "2026-10-25T01:30:00+00:00", "end": "2026-10-25T02:00:00+00:00", "value": 0.04700},
351+
]
352+
353+
my_predbat.ha_interface.dummy_items[entity_id_dst_fall] = {
354+
"state": "0.05",
355+
"raw_today": dst_fall_rates,
356+
}
357+
358+
rate_data = my_predbat.fetch_octopus_rates(entity_id_dst_fall)
359+
360+
if not rate_data:
361+
print("ERROR: No rate data returned for DST fall-back test")
362+
failed = True
363+
else:
364+
# 00:00+01:00 is minute 0 from midnight
365+
expected_min0 = 0.05000 * 100
366+
if 0 not in rate_data:
367+
print("ERROR: DST fall-back test - missing rate at minute 0")
368+
failed = True
369+
elif abs(rate_data[0] - expected_min0) > 0.01:
370+
print("ERROR: DST fall-back test - minute 0 expected {}, got {}".format(expected_min0, rate_data[0]))
371+
failed = True
372+
373+
# 01:00+00:00 = 01:00 UTC = minute 120 from midnight (+01:00)
374+
expected_min120 = 0.04800 * 100
375+
if 120 not in rate_data:
376+
print("ERROR: DST fall-back test - missing rate at minute 120 (01:00 GMT = 120 mins from 00:00 BST)")
377+
failed = True
378+
elif abs(rate_data[120] - expected_min120) > 0.01:
379+
print("ERROR: DST fall-back test - minute 120 expected {}, got {}".format(expected_min120, rate_data[120]))
380+
failed = True
381+
382+
# 01:30+00:00 = 01:30 UTC = minute 150 from midnight (+01:00)
383+
expected_min150 = 0.04700 * 100
384+
if 150 not in rate_data:
385+
print("ERROR: DST fall-back test - missing rate at minute 150 (01:30 GMT = 150 mins from 00:00 BST)")
386+
failed = True
387+
elif abs(rate_data[150] - expected_min150) > 0.01:
388+
print("ERROR: DST fall-back test - minute 150 expected {}, got {}".format(expected_min150, rate_data[150]))
389+
failed = True
390+
391+
if 0 in rate_data and 120 in rate_data and 150 in rate_data:
392+
print("Test 10 passed - DST fall-back mixed-offset rates placed at correct minutes")
393+
247394
# Restore original values
248395
my_predbat.forecast_days = old_forecast_days
249396
my_predbat.midnight_utc = old_midnight_utc

apps/predbat/utils.py

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

878878

879+
def local_midnight(dt_aware):
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+
The timezone is extracted from *dt_aware* itself, so the datetime must
889+
already be timezone-aware.
890+
891+
For non-pytz tzinfo (e.g. ``zoneinfo`` or fixed-offset tzinfo), the
892+
simple ``replace()`` path is used. This is safe because those tzinfo
893+
implementations compute the UTC offset from the resulting wall time,
894+
unlike pytz's offset-bound ``DstTzInfo`` instances.
895+
"""
896+
effective_tz = getattr(dt_aware, "tzinfo", None)
897+
if effective_tz is not None and hasattr(effective_tz, "localize"):
898+
# effective_tz might be a pytz _UTCclass / StaticTzInfo / DstTzInfo.
899+
# For DstTzInfo we need the *zone* (canonical) timezone, not the
900+
# offset-bound instance that .tzinfo returns.
901+
zone = getattr(effective_tz, "zone", None)
902+
if zone is not None:
903+
import pytz
904+
905+
effective_tz = pytz.timezone(zone)
906+
naive_midnight = dt_aware.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=None)
907+
return effective_tz.localize(naive_midnight)
908+
return dt_aware.replace(hour=0, minute=0, second=0, microsecond=0)
909+
910+
879911
def str2time(str):
880912
if "." in str:
881913
tdata = datetime.strptime(str, TIME_FORMAT_SECONDS)

0 commit comments

Comments
 (0)