From e0f4667ad9b79fea88ce9c9a8c60b28f0c47ca5c Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Tue, 7 Apr 2026 10:22:15 +0200 Subject: [PATCH 1/2] octopus energy: fix timezone handling --- .../flexible_tariffs/octopusenergy/tariff.py | 38 +++--- .../octopusenergy/tariff_test.py | 108 ++++++++++++++++++ 2 files changed, 132 insertions(+), 14 deletions(-) create mode 100644 packages/modules/electricity_pricing/flexible_tariffs/octopusenergy/tariff_test.py diff --git a/packages/modules/electricity_pricing/flexible_tariffs/octopusenergy/tariff.py b/packages/modules/electricity_pricing/flexible_tariffs/octopusenergy/tariff.py index a410159e4f..fde09ce430 100644 --- a/packages/modules/electricity_pricing/flexible_tariffs/octopusenergy/tariff.py +++ b/packages/modules/electricity_pricing/flexible_tariffs/octopusenergy/tariff.py @@ -1,16 +1,19 @@ #!/usr/bin/env python3 import logging +from datetime import datetime, timedelta, timezone +from typing import Dict +from zoneinfo import ZoneInfo from modules.electricity_pricing.flexible_tariffs.octopusenergy.config import (OctopusEnergyTariffConfiguration, OctopusEnergyTariff) from modules.common import req from modules.common.abstract_device import DeviceDescriptor from modules.common.component_state import TariffState -from typing import Dict -from datetime import datetime, timedelta, timezone log = logging.getLogger(__name__) +GERMAN_TZ = ZoneInfo("Europe/Berlin") + class OctopusEnergyClient: def __init__(self, email: str, password: str, base_url="https://api.oeg-kraken.energy/v1/graphql/"): @@ -115,45 +118,52 @@ def get_rate_from_simple_product(unit_rate_info: dict) -> float: return float(unit_rate_info['latestGrossUnitRateCentsPerKwh']) / 100 / 1000 -def get_rate_from_time_of_use_product(unit_rate_info: dict, hour_time: datetime) -> float: +def get_rate_from_time_of_use_product(unit_rate_info: dict, hour_time_utc: datetime) -> float: + local_hour_time = hour_time_utc.astimezone(GERMAN_TZ).time() # hour_time is UTC, time of use returns local time + for rate_info in unit_rate_info['rates']: active_from = datetime.strptime(rate_info['timeslotActivationRules'][0]['activeFromTime'], '%H:%M:%S').time() active_to = datetime.strptime(rate_info['timeslotActivationRules'][0]['activeToTime'], '%H:%M:%S').time() - local_hour_time = hour_time.astimezone().time() # hour_time is UTC, time of use returns local time - if active_from <= local_hour_time < active_to or ( - active_to == datetime.min.time() and hour_time.time() >= active_from): + # Mitternachts-Überlauf korrekt behandeln + if active_from <= active_to: + in_slot = active_from <= local_hour_time < active_to + else: + in_slot = local_hour_time >= active_from or local_hour_time < active_to + + if in_slot: return float(rate_info['latestGrossUnitRateCentsPerKwh']) / 100 / 1000 return None -def process_agreement(agreement: dict, hour_time: datetime, prices: Dict[str, float]): +def process_agreement(agreement: dict, hour_time_utc: datetime, prices: Dict[str, float]): if agreement['validTo'] is None: valid = True else: valid_from = parse_datetime(agreement['validFrom']) valid_to = parse_datetime(agreement['validTo']) - valid = valid_from <= hour_time <= valid_to + valid = valid_from <= hour_time_utc <= valid_to if valid: unit_rate_info = agreement['unitRateInformation'] - timestamp = str(int(hour_time.replace(minute=0, second=0, microsecond=0).timestamp())) + local_hour_time = hour_time_utc.astimezone(GERMAN_TZ) + timestamp = str(int(local_hour_time.replace(minute=0, second=0, microsecond=0).timestamp())) if unit_rate_info['__typename'] == 'SimpleProductUnitRateInformation': prices[timestamp] = get_rate_from_simple_product(unit_rate_info) elif unit_rate_info['__typename'] == 'TimeOfUseProductUnitRateInformation': - rate = get_rate_from_time_of_use_product(unit_rate_info, hour_time) + rate = get_rate_from_time_of_use_product(unit_rate_info, hour_time_utc) if rate is not None: - log.debug(f"Adding rate: {rate} for timestamp: {timestamp} with hour_time: {hour_time}") + log.debug(f"Adding rate: {rate} for timestamp: {timestamp} with hour_time_utc: {hour_time_utc}") prices[timestamp] = rate def build_tariff_state(data) -> Dict[str, float]: - current_time = datetime.now(timezone.utc) + current_utc = datetime.now(timezone.utc) prices: Dict[str, float] = {} for hour in range(28): - hour_time = current_time + timedelta(hours=hour) + hour_time_utc = current_utc + timedelta(hours=hour) for agreement in data['account']['property']['electricityMalos'][0]['agreements']: - process_agreement(agreement, hour_time, prices) + process_agreement(agreement, hour_time_utc, prices) sorted_prices = dict(sorted(prices.items())) return sorted_prices diff --git a/packages/modules/electricity_pricing/flexible_tariffs/octopusenergy/tariff_test.py b/packages/modules/electricity_pricing/flexible_tariffs/octopusenergy/tariff_test.py new file mode 100644 index 0000000000..50160c2d8d --- /dev/null +++ b/packages/modules/electricity_pricing/flexible_tariffs/octopusenergy/tariff_test.py @@ -0,0 +1,108 @@ + +from datetime import datetime, timezone +from typing import Dict +from unittest.mock import MagicMock + +import pytest + +from modules.electricity_pricing.flexible_tariffs.octopusenergy import tariff +from modules.electricity_pricing.flexible_tariffs.octopusenergy.tariff import build_tariff_state + + +TEST_DATA = {'data': {'account': {'property': {'electricityMalos': [ + {'agreements': [{'id': 1, + 'unitRateInformation': {'__typename': 'TimeOfUseProductUnitRateInformation', + 'rates': [{'latestGrossUnitRateCentsPerKwh': '20', + 'timeslotActivationRules': [{'activeFromTime': '00:00:00', + 'activeToTime': '05:00:00'}], + 'timeslotName': 'GO'}, + {'latestGrossUnitRateCentsPerKwh': '30', + 'timeslotActivationRules': [{'activeFromTime': '05:00:00', + 'activeToTime': '00:00:00'}], + 'timeslotName': 'STANDARD'}]}, + 'validFrom': '2026-01-06T23:00:00+00:00', + 'validTo': '2027-01-06T23:00:00+00:00'}, + {'id': 2, + 'unitRateInformation': {'__typename': 'SimpleProductUnitRateInformation', + 'latestGrossUnitRateCentsPerKwh': '26'}, + 'validFrom': '2026-01-01T23:00:00+00:00', + 'validTo': '2026-01-06T23:00:00+00:00'}]}]}}}} + +EXPECTED_PRICES_SUMMER_TIME = {'1775131200': 0.0003, # 2026-04-02 14:00 + '1775134800': 0.0003, # 2026-04-02 15:00 + '1775138400': 0.0003, # 2026-04-02 16:00 + '1775142000': 0.0003, # 2026-04-02 17:00 + '1775145600': 0.0003, # 2026-04-02 18:00 + '1775149200': 0.0003, # 2026-04-02 19:00 + '1775152800': 0.0003, # 2026-04-02 20:00 + '1775156400': 0.0003, # 2026-04-02 21:00 + '1775160000': 0.0003, # 2026-04-02 22:00 + '1775163600': 0.0003, # 2026-04-02 23:00 + '1775167200': 0.0002, # 2026-04-03 00:00 + '1775170800': 0.0002, # 2026-04-03 01:00 + '1775174400': 0.0002, # 2026-04-03 02:00 + '1775178000': 0.0002, # 2026-04-03 03:00 + '1775181600': 0.0002, # 2026-04-03 04:00 + '1775185200': 0.0003, # 2026-04-03 05:00 + '1775188800': 0.0003, # 2026-04-03 06:00 + '1775192400': 0.0003, # 2026-04-03 07:00 + '1775196000': 0.0003, # 2026-04-03 08:00 + '1775199600': 0.0003, # 2026-04-03 09:00 + '1775203200': 0.0003, # 2026-04-03 10:00 + '1775206800': 0.0003, # 2026-04-03 11:00 + '1775210400': 0.0003, # 2026-04-03 12:00 + '1775214000': 0.0003, # 2026-04-03 13:00 + '1775217600': 0.0003, # 2026-04-03 14:00 + '1775221200': 0.0003, # 2026-04-03 15:00 + '1775224800': 0.0003, # 2026-04-03 16:00 + '1775228400': 0.0003} # 2026-04-03 17:00 + +EXPECTED_PRICES_WINTER_TIME = {'1793624400': 0.0003, # 2026-11-02 14:00 + '1793628000': 0.0003, # 2026-11-02 15:00 + '1793631600': 0.0003, # 2026-11-02 16:00 + '1793635200': 0.0003, # 2026-11-02 17:00 + '1793638800': 0.0003, # 2026-11-02 18:00 + '1793642400': 0.0003, # 2026-11-02 19:00 + '1793646000': 0.0003, # 2026-11-02 20:00 + '1793649600': 0.0003, # 2026-11-02 21:00 + '1793653200': 0.0003, # 2026-11-02 22:00 + '1793656800': 0.0003, # 2026-11-02 23:00 + '1793660400': 0.0002, # 2026-11-03 00:00 + '1793664000': 0.0002, # 2026-11-03 01:00 + '1793667600': 0.0002, # 2026-11-03 02:00 + '1793671200': 0.0002, # 2026-11-03 03:00 + '1793674800': 0.0002, # 2026-11-03 04:00 + '1793678400': 0.0003, # 2026-11-03 05:00 + '1793682000': 0.0003, # 2026-11-03 06:00 + '1793685600': 0.0003, # 2026-11-03 07:00 + '1793689200': 0.0003, # 2026-11-03 08:00 + '1793692800': 0.0003, # 2026-11-03 09:00 + '1793696400': 0.0003, # 2026-11-03 10:00 + '1793700000': 0.0003, # 2026-11-03 11:00 + '1793703600': 0.0003, # 2026-11-03 12:00 + '1793707200': 0.0003, # 2026-11-03 13:00 + '1793710800': 0.0003, # 2026-11-03 14:00 + '1793714400': 0.0003, # 2026-11-03 15:00 + '1793718000': 0.0003, # 2026-11-03 16:00 + '1793721600': 0.0003} # 2026-11-03 17:00 + + +@pytest.mark.parametrize( + "now, expected_prices", + [ + # 2 Stunden fürher wegen UTC + pytest.param(datetime(2026, 4, 2, 12, 3, 0, tzinfo=timezone.utc), EXPECTED_PRICES_SUMMER_TIME), + # 1 Stunde fürher wegen UTC + pytest.param(datetime(2026, 11, 2, 13, 3, 0, tzinfo=timezone.utc), EXPECTED_PRICES_WINTER_TIME) + ]) +def test_build_tariff_state(now: datetime, expected_prices: Dict[str, float], monkeypatch): + # setup + datetime_mock = MagicMock(wraps=datetime) + datetime_mock.now.return_value = now + monkeypatch.setattr(tariff, "datetime", datetime_mock) + + # execution + prices = build_tariff_state(TEST_DATA["data"]) + + # assert + assert prices == expected_prices From 4cb2c7f8a679684fa9962e35fcad10a28f0f39c8 Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Tue, 7 Apr 2026 10:57:34 +0200 Subject: [PATCH 2/2] use installed dependency, typo --- .../flexible_tariffs/octopusenergy/tariff.py | 8 ++++---- .../flexible_tariffs/octopusenergy/tariff_test.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/modules/electricity_pricing/flexible_tariffs/octopusenergy/tariff.py b/packages/modules/electricity_pricing/flexible_tariffs/octopusenergy/tariff.py index fde09ce430..8cb6237213 100644 --- a/packages/modules/electricity_pricing/flexible_tariffs/octopusenergy/tariff.py +++ b/packages/modules/electricity_pricing/flexible_tariffs/octopusenergy/tariff.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 import logging +import pytz from datetime import datetime, timedelta, timezone -from typing import Dict -from zoneinfo import ZoneInfo +from typing import Dict, Optional from modules.electricity_pricing.flexible_tariffs.octopusenergy.config import (OctopusEnergyTariffConfiguration, OctopusEnergyTariff) @@ -12,7 +12,7 @@ log = logging.getLogger(__name__) -GERMAN_TZ = ZoneInfo("Europe/Berlin") +GERMAN_TZ = pytz.timezone("Europe/Berlin") class OctopusEnergyClient: @@ -118,7 +118,7 @@ def get_rate_from_simple_product(unit_rate_info: dict) -> float: return float(unit_rate_info['latestGrossUnitRateCentsPerKwh']) / 100 / 1000 -def get_rate_from_time_of_use_product(unit_rate_info: dict, hour_time_utc: datetime) -> float: +def get_rate_from_time_of_use_product(unit_rate_info: dict, hour_time_utc: datetime) -> Optional[float]: local_hour_time = hour_time_utc.astimezone(GERMAN_TZ).time() # hour_time is UTC, time of use returns local time for rate_info in unit_rate_info['rates']: diff --git a/packages/modules/electricity_pricing/flexible_tariffs/octopusenergy/tariff_test.py b/packages/modules/electricity_pricing/flexible_tariffs/octopusenergy/tariff_test.py index 50160c2d8d..ebd8e8772b 100644 --- a/packages/modules/electricity_pricing/flexible_tariffs/octopusenergy/tariff_test.py +++ b/packages/modules/electricity_pricing/flexible_tariffs/octopusenergy/tariff_test.py @@ -90,9 +90,9 @@ @pytest.mark.parametrize( "now, expected_prices", [ - # 2 Stunden fürher wegen UTC + # 2 Stunden früher wegen UTC pytest.param(datetime(2026, 4, 2, 12, 3, 0, tzinfo=timezone.utc), EXPECTED_PRICES_SUMMER_TIME), - # 1 Stunde fürher wegen UTC + # 1 Stunde früher wegen UTC pytest.param(datetime(2026, 11, 2, 13, 3, 0, tzinfo=timezone.utc), EXPECTED_PRICES_WINTER_TIME) ]) def test_build_tariff_state(now: datetime, expected_prices: Dict[str, float], monkeypatch):