Skip to content
Open
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
3 changes: 2 additions & 1 deletion apps/predbat/fox.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import random
from component_base import ComponentBase
from oauth_mixin import OAuthMixin
from utils import local_midnight

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

Expand Down
4 changes: 2 additions & 2 deletions apps/predbat/gecloud.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

import aiohttp
from datetime import timedelta, datetime
from utils import str2time, dp1, dp2
from utils import str2time, dp1, dp2, local_midnight
from predbat_metrics import record_api_call
import asyncio
import json
Expand Down Expand Up @@ -1711,7 +1711,7 @@ def __init__(self):
self.now_utc = datetime.now(self.local_tz)
self.prefix = "predbat"
self.args = {}
self.midnight_utc = datetime.now(self.local_tz).replace(hour=0, minute=0, second=0, microsecond=0)
self.midnight_utc = local_midnight(datetime.now(self.local_tz))
self.minutes_now = self.now_utc.hour * 60 + self.now_utc.minute
self.entities = {}
self.config_root = "./temp_gecloud"
Expand Down
4 changes: 2 additions & 2 deletions apps/predbat/octopus.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from datetime import datetime, timedelta, timezone
from predbat_metrics import record_api_call
from const import TIME_FORMAT, TIME_FORMAT_OCTOPUS
from utils import str2time, minutes_to_time, dp1, dp2, dp4, minute_data
from utils import str2time, minutes_to_time, dp1, dp2, dp4, minute_data, local_midnight
from component_base import ComponentBase
import aiohttp
import json
Expand Down Expand Up @@ -2718,7 +2718,7 @@ def __init__(self):
self.now_utc = datetime.now(self.local_tz)
self.prefix = "predbat"
self.args = {}
self.midnight_utc = datetime.now(self.local_tz).replace(hour=0, minute=0, second=0, microsecond=0)
self.midnight_utc = local_midnight(datetime.now(self.local_tz))
self.minutes_now = self.now_utc.hour * 60 + self.now_utc.minute
self.entities = {}
self.config_root = "./temp_octopus"
Expand Down
4 changes: 2 additions & 2 deletions apps/predbat/predbat.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@
)
from config import APPS_SCHEMA, CONFIG_ITEMS
from prediction import reset_prediction_globals
from utils import minutes_since_yesterday, dp1, dp2, dp3
from utils import minutes_since_yesterday, dp1, dp2, dp3, local_midnight
from predheat import PredHeat
from octopus import Octopus
from energydataservice import Energidataservice
Expand Down Expand Up @@ -709,7 +709,7 @@ def update_time(self, print=True):
self.now_utc = now_utc
self.now = now
self.midnight = now.replace(hour=0, minute=0, second=0, microsecond=0)
self.midnight_utc = now_utc.replace(hour=0, minute=0, second=0, microsecond=0)
self.midnight_utc = local_midnight(now_utc)

self.difference_minutes = minutes_since_yesterday(now)
self.minutes_now = int((now - self.midnight).seconds / 60 / PREDICT_STEP) * PREDICT_STEP
Expand Down
4 changes: 2 additions & 2 deletions apps/predbat/predheat.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@

from datetime import datetime, timedelta
import pytz
from utils import str2time, dp2, dp3, minute_data
from utils import str2time, dp2, dp3, minute_data, local_midnight

from const import TIME_FORMAT

Expand Down Expand Up @@ -533,7 +533,7 @@ def update_pred(self, scheduled):
self.forecast_days = self.get_arg("forecast_days", 2, domain="predheat")
self.forecast_minutes = self.forecast_days * 60 * 24
self.midnight = now.replace(hour=0, minute=0, second=0, microsecond=0)
self.midnight_utc = now_utc.replace(hour=0, minute=0, second=0, microsecond=0)
self.midnight_utc = local_midnight(now_utc)
self.minutes_now = int((now - self.midnight).seconds / 60 / PREDICT_STEP) * PREDICT_STEP
self.metric_future_rate_offset_import = 0

Expand Down
3 changes: 2 additions & 1 deletion apps/predbat/solis.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from datetime import datetime, timedelta, UTC
from predbat_metrics import record_api_call
from component_base import ComponentBase
from utils import local_midnight


# API Endpoints
Expand Down Expand Up @@ -2868,7 +2869,7 @@ def __init__(self):
self.now_utc = datetime.now(self.local_tz)
self.prefix = "predbat"
self.args = {}
self.midnight_utc = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
self.midnight_utc = local_midnight(datetime.now(self.local_tz))
self.minutes_now = self.now_utc.hour * 60 + self.now_utc.minute
self.entities = {}

Expand Down
7 changes: 5 additions & 2 deletions apps/predbat/tests/test_execute.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,14 @@
# pylint: disable=attribute-defined-outside-init

from tests.test_infra import reset_inverter
from utils import calc_percent_limit
from utils import calc_percent_limit, local_midnight


class ActiveTestInverter:
"""Mock inverter for execute tests."""

def __init__(self, id, soc_kw, soc_max, now_utc):
"""Initialise mock inverter."""
self.soc_target = -1
self.id = id
self.isCharging = False
Expand Down Expand Up @@ -53,7 +56,7 @@ def __init__(self, id, soc_kw, soc_max, now_utc):
self.battery_rate_max_discharge = 1 / 60.0
self.reserve_max = 100.0
self.now_utc = now_utc
self.midnight_utc = now_utc.replace(hour=0, minute=0, second=0, microsecond=0)
self.midnight_utc = local_midnight(now_utc)
self.count_register_writes = 0
self.charge_window = []
self.charge_limits = []
Expand Down
147 changes: 147 additions & 0 deletions apps/predbat/tests/test_fetch_octopus_rates.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@

from datetime import datetime

import pytz

from utils import local_midnight


def test_fetch_octopus_rates(my_predbat):
"""
Expand Down Expand Up @@ -244,6 +248,149 @@ def test_fetch_octopus_rates(my_predbat):
else:
print("Test 8 passed - sensor with no attributes returns empty dict")

# Test 9: DST transition day - mixed timezone offsets in rate data
print("*** Test 9: DST spring-forward day with mixed +00:00 / +01:00 offsets")

# On UK spring-forward day (last Sunday in March), clocks go from GMT (+00:00)
# to BST (+01:00) at 01:00 GMT. The Octopus integration delivers rates before
# the transition with +00:00 and rates after with +01:00.
# midnight_utc must carry the correct offset (+00:00) so that minute indices
# are computed correctly for both sets of rates.
#
# We simulate "now" at 14:00 BST and derive midnight_utc via
# local_midnight() to exercise the actual DST-offset fix (midnight was
# still in GMT +00:00 even though 14:00 is in BST +01:00).

london_tz = pytz.timezone("Europe/London")
# Simulate "now" at 14:00 BST on spring-forward day (13:00 UTC)
now_bst = london_tz.localize(datetime(2026, 3, 29, 14, 0, 0))
my_predbat.local_tz = london_tz
my_predbat.midnight_utc = local_midnight(now_bst)

# Verify local_midnight() picked the correct GMT offset for midnight
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())
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)

my_predbat.forecast_days = 2

entity_id_dst = "sensor.metric_octopus_import_dst"
dst_rates = [
# Pre-DST rates (GMT, +00:00)
{"start": "2026-03-29T00:00:00+00:00", "end": "2026-03-29T00:30:00+00:00", "value": 0.04138},
{"start": "2026-03-29T00:30:00+00:00", "end": "2026-03-29T01:00:00+00:00", "value": 0.04135},
# Post-DST rates (BST, +01:00) — 02:00 BST = 01:00 UTC
{"start": "2026-03-29T02:00:00+01:00", "end": "2026-03-29T02:30:00+01:00", "value": 0.04000},
{"start": "2026-03-29T02:30:00+01:00", "end": "2026-03-29T03:00:00+01:00", "value": 0.03991},
]

my_predbat.ha_interface.dummy_items[entity_id_dst] = {
"state": "0.04",
"raw_today": dst_rates,
}

rate_data = my_predbat.fetch_octopus_rates(entity_id_dst)

if not rate_data:
print("ERROR: No rate data returned for DST test")
failed = True
else:
# 00:00+00:00 is minute 0 from midnight
expected_min0 = 0.04138 * 100 # scale=100 for start/end format
if 0 not in rate_data:
print("ERROR: DST test - missing rate at minute 0")
failed = True
elif abs(rate_data[0] - expected_min0) > 0.01:
print("ERROR: DST test - minute 0 expected {}, got {}".format(expected_min0, rate_data[0]))
failed = True

# 02:00+01:00 = 01:00 UTC = minute 60 from midnight (+00:00)
expected_min60 = 0.04000 * 100
if 60 not in rate_data:
print("ERROR: DST test - missing rate at minute 60 (02:00 BST = 01:00 UTC)")
failed = True
elif abs(rate_data[60] - expected_min60) > 0.01:
print("ERROR: DST test - minute 60 expected {}, got {}".format(expected_min60, rate_data[60]))
failed = True

# 02:30+01:00 = 01:30 UTC = minute 90 from midnight (+00:00)
expected_min90 = 0.03991 * 100
if 90 not in rate_data:
print("ERROR: DST test - missing rate at minute 90 (02:30 BST = 01:30 UTC)")
failed = True
elif abs(rate_data[90] - expected_min90) > 0.01:
print("ERROR: DST test - minute 90 expected {}, got {}".format(expected_min90, rate_data[90]))
failed = True

if 0 in rate_data and 60 in rate_data and 90 in rate_data:
print("Test 9 passed - DST mixed-offset rates placed at correct minutes")

# Test 10: DST autumn fallback day - mixed timezone offsets in rate data
print("*** Test 10: DST fall-back day with mixed +01:00 / +00:00 offsets")

# On UK fall-back day (last Sunday in October), clocks go from BST (+01:00)
# to GMT (+00:00). Midnight is still in BST (+01:00), but later daytime is
# in GMT (+00:00).
#
# We simulate "now" at 14:00 GMT and derive midnight_utc via
# local_midnight() to verify it picks +01:00 for local midnight.
now_gmt = london_tz.localize(datetime(2026, 10, 25, 14, 0, 0))
my_predbat.midnight_utc = local_midnight(now_gmt)

# Verify local_midnight() picked the correct BST offset for midnight
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())
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)

entity_id_dst_fall = "sensor.metric_octopus_import_dst_fall"
dst_fall_rates = [
# Pre-fallback rates (BST, +01:00)
{"start": "2026-10-25T00:00:00+01:00", "end": "2026-10-25T00:30:00+01:00", "value": 0.05000},
{"start": "2026-10-25T00:30:00+01:00", "end": "2026-10-25T01:00:00+01:00", "value": 0.04900},
# Post-fallback rates (GMT, +00:00) — 01:00 GMT = minute 120 from midnight (+01:00)
{"start": "2026-10-25T01:00:00+00:00", "end": "2026-10-25T01:30:00+00:00", "value": 0.04800},
{"start": "2026-10-25T01:30:00+00:00", "end": "2026-10-25T02:00:00+00:00", "value": 0.04700},
]

my_predbat.ha_interface.dummy_items[entity_id_dst_fall] = {
"state": "0.05",
"raw_today": dst_fall_rates,
}

rate_data = my_predbat.fetch_octopus_rates(entity_id_dst_fall)

if not rate_data:
print("ERROR: No rate data returned for DST fall-back test")
failed = True
else:
# 00:00+01:00 is minute 0 from midnight
expected_min0 = 0.05000 * 100
if 0 not in rate_data:
print("ERROR: DST fall-back test - missing rate at minute 0")
failed = True
elif abs(rate_data[0] - expected_min0) > 0.01:
print("ERROR: DST fall-back test - minute 0 expected {}, got {}".format(expected_min0, rate_data[0]))
failed = True

# 01:00+00:00 = 01:00 UTC = minute 120 from midnight (+01:00)
expected_min120 = 0.04800 * 100
if 120 not in rate_data:
print("ERROR: DST fall-back test - missing rate at minute 120 (01:00 GMT = 120 mins from 00:00 BST)")
failed = True
elif abs(rate_data[120] - expected_min120) > 0.01:
print("ERROR: DST fall-back test - minute 120 expected {}, got {}".format(expected_min120, rate_data[120]))
failed = True

# 01:30+00:00 = 01:30 UTC = minute 150 from midnight (+01:00)
expected_min150 = 0.04700 * 100
if 150 not in rate_data:
print("ERROR: DST fall-back test - missing rate at minute 150 (01:30 GMT = 150 mins from 00:00 BST)")
failed = True
elif abs(rate_data[150] - expected_min150) > 0.01:
print("ERROR: DST fall-back test - minute 150 expected {}, got {}".format(expected_min150, rate_data[150]))
failed = True

if 0 in rate_data and 120 in rate_data and 150 in rate_data:
print("Test 10 passed - DST fall-back mixed-offset rates placed at correct minutes")

# Restore original values
my_predbat.forecast_days = old_forecast_days
my_predbat.midnight_utc = old_midnight_utc
Expand Down
32 changes: 32 additions & 0 deletions apps/predbat/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -876,6 +876,38 @@ def minutes_to_time(updated, now):
return minutes


def local_midnight(dt_aware):
"""Return midnight on the same local date as *dt_aware*, correctly localised.

``datetime.replace(hour=0)`` preserves the original UTC offset, which is
wrong on DST-transition days (e.g. spring-forward: the current offset is
+01:00 but midnight was still +00:00). Stripping the tzinfo and
re-localising lets pytz pick the offset that was actually in effect at
midnight.

The timezone is extracted from *dt_aware* itself, so the datetime must
already be timezone-aware.

For non-pytz tzinfo (e.g. ``zoneinfo`` or fixed-offset tzinfo), the
simple ``replace()`` path is used. This is safe because those tzinfo
implementations compute the UTC offset from the resulting wall time,
unlike pytz's offset-bound ``DstTzInfo`` instances.
"""
effective_tz = getattr(dt_aware, "tzinfo", None)
if effective_tz is not None and hasattr(effective_tz, "localize"):
# effective_tz might be a pytz _UTCclass / StaticTzInfo / DstTzInfo.
# For DstTzInfo we need the *zone* (canonical) timezone, not the
# offset-bound instance that .tzinfo returns.
zone = getattr(effective_tz, "zone", None)
if zone is not None:
import pytz

effective_tz = pytz.timezone(zone)
naive_midnight = dt_aware.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=None)
return effective_tz.localize(naive_midnight)
return dt_aware.replace(hour=0, minute=0, second=0, microsecond=0)


def str2time(str):
if "." in str:
tdata = datetime.strptime(str, TIME_FORMAT_SECONDS)
Expand Down
Loading