From ae79cceb971820dfa6ffa38ad78d9cb58d2ac8e7 Mon Sep 17 00:00:00 2001 From: Thomas Papendieck <14850347+tpd-opitz@users.noreply.github.com> Date: Tue, 30 Sep 2025 18:57:30 +0200 Subject: [PATCH 1/5] request quarter hourly prices - Co-authored-by: benderl --- packages/helpermodules/timecheck.py | 7 ++++ packages/helpermodules/timecheck_test.py | 32 +++++++++++++++++++ .../electricity_tariffs/tibber/tariff.py | 23 +++++++++++-- .../electricity_tariffs/tibber/tariff_test.py | 9 ++++++ 4 files changed, 69 insertions(+), 2 deletions(-) diff --git a/packages/helpermodules/timecheck.py b/packages/helpermodules/timecheck.py index 8b42a97d8e..a133ca1436 100644 --- a/packages/helpermodules/timecheck.py +++ b/packages/helpermodules/timecheck.py @@ -1,5 +1,6 @@ """prüft, ob Zeitfenster aktuell sind """ +import math import logging import datetime from typing import List, Optional, Tuple, TypeVar, Union @@ -236,6 +237,12 @@ def create_unix_timestamp_current_full_hour() -> int: return int(datetime.datetime.strptime(full_hour, "%m/%d/%Y, %H").timestamp()) +def create_unix_timestamp_current_quarter_hour() -> int: + def round_to_quarter_hour(current_time: float, quarter_hour: int = 900) -> float: + return math.floor(current_time / quarter_hour) * quarter_hour + return int(round_to_quarter_hour(create_timestamp())) + + def get_relative_date_string(date_string: str, day_offset: int = 0, month_offset: int = 0, year_offset: int = 0) -> str: print_format = "%Y%m%d" if len(date_string) > 6 else "%Y%m" my_date = datetime.datetime.strptime(date_string, print_format) diff --git a/packages/helpermodules/timecheck_test.py b/packages/helpermodules/timecheck_test.py index d45f0b8227..8dea6d1ead 100644 --- a/packages/helpermodules/timecheck_test.py +++ b/packages/helpermodules/timecheck_test.py @@ -1,4 +1,5 @@ import datetime +import logging from typing import List, Optional, Union from unittest.mock import MagicMock, Mock import pytest @@ -7,6 +8,8 @@ from helpermodules.abstract_plans import (AutolockPlan, FrequencyDate, FrequencyPeriod, ScheduledChargingPlan, TimeChargingPlan) +log = logging.getLogger(__name__) + class Params: def __init__(self, name: str, @@ -140,3 +143,32 @@ def test_convert_timestamp_delta_to_time_string(timestamp, expected): # evaluation assert time_string == expected + + +@pytest.mark.parametrize("timestamp, expected", + [ + pytest.param("2025-10-01 9:00", "2025-10-01 9:00", id="9:00"), + pytest.param("2025-10-01 9:01", "2025-10-01 9:00", id="9:01"), + pytest.param("2025-10-01 9:10", "2025-10-01 9:00", id="9:10"), + pytest.param("2025-10-01 9:14", "2025-10-01 9:00", id="9:14"), + pytest.param("2025-10-01 9:15", "2025-10-01 9:15", id="9:15"), + pytest.param("2025-10-01 9:41", "2025-10-01 9:30", id="9:41"), + pytest.param("2025-10-01 9:46", "2025-10-01 9:45", id="9:46") + ] + ) +def test_create_unix_timestamp_current_quarter_hour(timestamp, expected, monkeypatch): + # setup + datetime_mock = MagicMock(wraps=datetime.datetime) + current_time = datetime.datetime.strptime(timestamp, "%Y-%m-%d %H:%M") + datetime_mock.today.return_value = current_time + monkeypatch.setattr(datetime, "datetime", datetime_mock) + + # execution + qh= timecheck.create_unix_timestamp_current_quarter_hour() + log.debug(f"timestamp: {current_time} , from mock: {datetime.datetime.today().timestamp()}" + f" result: {qh}") + + current_quarter_hour = datetime.datetime.fromtimestamp(qh).strftime("%Y-%m-%d %H:%M") + + # evaluation + assert current_quarter_hour == expected diff --git a/packages/modules/electricity_tariffs/tibber/tariff.py b/packages/modules/electricity_tariffs/tibber/tariff.py index 1bcb32283d..33f8b2ae4d 100644 --- a/packages/modules/electricity_tariffs/tibber/tariff.py +++ b/packages/modules/electricity_tariffs/tibber/tariff.py @@ -2,6 +2,7 @@ from datetime import datetime from typing import Dict from helpermodules import timecheck +import json from modules.common.abstract_device import DeviceDescriptor from modules.common.component_state import TariffState @@ -21,8 +22,26 @@ def _get_sorted_price_data(response_json: Dict, key: str): def fetch_prices(config: TibberTariffConfiguration) -> Dict[int, float]: headers = {'Authorization': 'Bearer ' + config.token, 'Content-Type': 'application/json'} - data = '{ "query": "{viewer {home(id:\\"' + config.home_id + \ - '\\") {currentSubscription {priceInfo {today {total startsAt} tomorrow {total startsAt}}}}}}" }' + query = """ + query PriceInfo($homeId: ID!) { + viewer { + home(id: $homeId) { + currentSubscription { + priceInfo(resolution: QUARTER_HOURLY) { + today { total startsAt } + tomorrow { total startsAt } + } + } + } + } + } + """ + + payload = { + "query": query, + "variables": {"homeId": config.home_id}, + } + data = json.dumps(payload) response = req.get_http_session().post('https://api.tibber.com/v1-beta/gql', headers=headers, data=data, timeout=6) response_json = response.json() if response_json.get("errors") is None: diff --git a/packages/modules/electricity_tariffs/tibber/tariff_test.py b/packages/modules/electricity_tariffs/tibber/tariff_test.py index e8b9a66ae2..34e72c5d10 100644 --- a/packages/modules/electricity_tariffs/tibber/tariff_test.py +++ b/packages/modules/electricity_tariffs/tibber/tariff_test.py @@ -27,6 +27,9 @@ def test_fetch_prices(monkeypatch, requests_mock): {"priceInfo": {"today": [{"total": 0.8724, "startsAt": "2023-10-27T00:00:00.000+02:00"}, {"total": 0.8551, "startsAt": "2023-10-27T01:00:00.000+02:00"}, + {"total": 0.8552, "startsAt": "2023-10-27T01:15:00.000+02:00"}, + {"total": 0.8553, "startsAt": "2023-10-27T01:30:00.000+02:00"}, + {"total": 0.8554, "startsAt": "2023-10-27T01:45:00.000+02:00"}, {"total": 0.8389, "startsAt": "2023-10-27T02:00:00.000+02:00"}, {"total": 0.8189, "startsAt": "2023-10-27T03:00:00.000+02:00"}, {"total": 0.8544, "startsAt": "2023-10-27T04:00:00.000+02:00"}, @@ -35,6 +38,9 @@ def test_fetch_prices(monkeypatch, requests_mock): {"total": 1.6862, "startsAt": "2023-10-27T07:00:00.000+02:00"}, {"total": 2.3264, "startsAt": "2023-10-27T08:00:00.000+02:00"}, {"total": 2.3024, "startsAt": "2023-10-27T09:00:00.000+02:00"}, + {"total": 2.3124, "startsAt": "2023-10-27T09:15:00.000+02:00"}, + {"total": 2.3224, "startsAt": "2023-10-27T09:30:00.000+02:00"}, + {"total": 2.3324, "startsAt": "2023-10-27T09:45:00.000+02:00"}, {"total": 1.7204, "startsAt": "2023-10-27T10:00:00.000+02:00"}, {"total": 1.7213, "startsAt": "2023-10-27T11:00:00.000+02:00"}, {"total": 1.8697, "startsAt": "2023-10-27T12:00:00.000+02:00"}, @@ -56,6 +62,9 @@ def test_fetch_prices(monkeypatch, requests_mock): "1698382800": 0.0016862, "1698386400": 0.0023264, "1698390000": 0.0023024, + "1698390900": 0.0023123999999999996, + "1698391800": 0.0023224, + "1698392700": 0.0023323999999999997, "1698393600": 0.0017204, "1698397200": 0.0017213, "1698400800": 0.0018697, From 8342e20d3bae891e5bf522ec6005edc9fa4b41ba Mon Sep 17 00:00:00 2001 From: Thomas Papendieck <14850347+tpd-opitz@users.noreply.github.com> Date: Wed, 1 Oct 2025 23:05:41 +0200 Subject: [PATCH 2/5] Update timecheck_test.py --- packages/helpermodules/timecheck.py | 6 +++--- packages/helpermodules/timecheck_test.py | 16 ++++++++-------- packages/modules/common/store/_tariff.py | 5 ++++- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/packages/helpermodules/timecheck.py b/packages/helpermodules/timecheck.py index a133ca1436..adb5d0517a 100644 --- a/packages/helpermodules/timecheck.py +++ b/packages/helpermodules/timecheck.py @@ -1,6 +1,5 @@ """prüft, ob Zeitfenster aktuell sind """ -import math import logging import datetime from typing import List, Optional, Tuple, TypeVar, Union @@ -239,8 +238,9 @@ def create_unix_timestamp_current_full_hour() -> int: def create_unix_timestamp_current_quarter_hour() -> int: def round_to_quarter_hour(current_time: float, quarter_hour: int = 900) -> float: - return math.floor(current_time / quarter_hour) * quarter_hour - return int(round_to_quarter_hour(create_timestamp())) + log.debug(f"current time: {current_time} => modified: {current_time - (current_time % quarter_hour)}") + return current_time - (current_time % quarter_hour) + return int(round_to_quarter_hour(datetime.datetime.today().timestamp())) def get_relative_date_string(date_string: str, day_offset: int = 0, month_offset: int = 0, year_offset: int = 0) -> str: diff --git a/packages/helpermodules/timecheck_test.py b/packages/helpermodules/timecheck_test.py index 8dea6d1ead..a1e60fe794 100644 --- a/packages/helpermodules/timecheck_test.py +++ b/packages/helpermodules/timecheck_test.py @@ -147,13 +147,13 @@ def test_convert_timestamp_delta_to_time_string(timestamp, expected): @pytest.mark.parametrize("timestamp, expected", [ - pytest.param("2025-10-01 9:00", "2025-10-01 9:00", id="9:00"), - pytest.param("2025-10-01 9:01", "2025-10-01 9:00", id="9:01"), - pytest.param("2025-10-01 9:10", "2025-10-01 9:00", id="9:10"), - pytest.param("2025-10-01 9:14", "2025-10-01 9:00", id="9:14"), - pytest.param("2025-10-01 9:15", "2025-10-01 9:15", id="9:15"), - pytest.param("2025-10-01 9:41", "2025-10-01 9:30", id="9:41"), - pytest.param("2025-10-01 9:46", "2025-10-01 9:45", id="9:46") + pytest.param("2025-10-01 09:00", "2025-10-01 09:00", id="9:00"), + pytest.param("2025-10-01 09:01", "2025-10-01 09:00", id="9:01"), + pytest.param("2025-10-01 09:10", "2025-10-01 09:00", id="9:10"), + pytest.param("2025-10-01 09:14", "2025-10-01 09:00", id="9:14"), + pytest.param("2025-10-01 09:15", "2025-10-01 09:15", id="9:15"), + pytest.param("2025-10-01 09:41", "2025-10-01 09:30", id="9:41"), + pytest.param("2025-10-01 09:46", "2025-10-01 09:45", id="9:46") ] ) def test_create_unix_timestamp_current_quarter_hour(timestamp, expected, monkeypatch): @@ -164,7 +164,7 @@ def test_create_unix_timestamp_current_quarter_hour(timestamp, expected, monkeyp monkeypatch.setattr(datetime, "datetime", datetime_mock) # execution - qh= timecheck.create_unix_timestamp_current_quarter_hour() + qh = timecheck.create_unix_timestamp_current_quarter_hour() log.debug(f"timestamp: {current_time} , from mock: {datetime.datetime.today().timestamp()}" f" result: {qh}") diff --git a/packages/modules/common/store/_tariff.py b/packages/modules/common/store/_tariff.py index a6509a91c2..da3698ae3f 100644 --- a/packages/modules/common/store/_tariff.py +++ b/packages/modules/common/store/_tariff.py @@ -12,12 +12,15 @@ def __init__(self): def set(self, state: TariffState) -> None: self.state = state - def update(self): + def __update(self): try: pub_to_broker("openWB/set/optional/et/get/prices", self.state.prices) except Exception as e: raise FaultState.from_exception(e) + def update(self): + self.__update(self) + def get_electricity_tariff_value_store() -> ValueStore[TariffState]: return LoggingValueStore(TariffValueStoreBroker()) From 19a53c83dd34d224269ca0970a1887ef28a62bb3 Mon Sep 17 00:00:00 2001 From: Thomas Papendieck <14850347+tpd-opitz@users.noreply.github.com> Date: Sat, 4 Oct 2025 22:34:56 +0200 Subject: [PATCH 3/5] expect shorter price time slots in charge time calculation --- packages/conftest.py | 17 +++++- packages/control/optional.py | 58 ++++++++++++++----- packages/control/optional_test.py | 9 ++- packages/helpermodules/timecheck.py | 4 ++ packages/helpermodules/timecheck_test.py | 1 + packages/modules/common/component_state.py | 4 +- .../modules/common/configurable_tariff.py | 28 ++++++--- .../electricity_tariffs/tibber/tariff.py | 29 ++++++---- .../electricity_tariffs/tibber/tariff_test.py | 6 +- 9 files changed, 113 insertions(+), 43 deletions(-) diff --git a/packages/conftest.py b/packages/conftest.py index abfa8dc5ea..9eeb5b8930 100644 --- a/packages/conftest.py +++ b/packages/conftest.py @@ -22,6 +22,11 @@ from modules.common.store._api import LoggingValueStore +def pytest_configure(config): + config.addinivalue_line("markers", "no_mock_full_hour: mark test to disable full_hour mocking.") + config.addinivalue_line("markers", "no_mock_quarter_hour: mark test to disable quarter_hour mocking.") + + @pytest.fixture(autouse=True) def mock_open_file(monkeypatch) -> None: mock_config = Mock(return_value={"dc_charging": False, "openwb-version": 1, "max_c_socket": 32}) @@ -29,13 +34,19 @@ def mock_open_file(monkeypatch) -> None: @pytest.fixture(autouse=True) -def mock_today(monkeypatch) -> None: +def mock_today(monkeypatch, request) -> None: datetime_mock = MagicMock(wraps=datetime.datetime) # Montag 16.05.2022, 8:40:52 "05/16/2022, 08:40:52" Unix: 1652683252 datetime_mock.today.return_value = datetime.datetime(2022, 5, 16, 8, 40, 52) monkeypatch.setattr(datetime, "datetime", datetime_mock) - mock_today_timestamp = Mock(return_value=1652683252) - monkeypatch.setattr(timecheck, "create_timestamp", mock_today_timestamp) + now_timestamp = Mock(return_value=1652683252) + monkeypatch.setattr(timecheck, "create_timestamp", now_timestamp) + if "no_mock_full_hour" not in request.keywords: + full_hour_timestamp = Mock(return_value=int(datetime.datetime(2022, 5, 16, 8, 0, 0).timestamp())) + monkeypatch.setattr(timecheck, "create_unix_timestamp_current_full_hour", full_hour_timestamp) + if "no_mock_quarter_hour" not in request.keywords: + quarter_hour_timesatmp = Mock(return_value=int(datetime.datetime(2022, 5, 16, 8, 30, 0).timestamp())) + monkeypatch.setattr(timecheck, "create_unix_timestamp_current_quarter_hour", quarter_hour_timesatmp) @pytest.fixture(autouse=True) diff --git a/packages/control/optional.py b/packages/control/optional.py index 96c1e66c57..9518ad8d4f 100644 --- a/packages/control/optional.py +++ b/packages/control/optional.py @@ -1,6 +1,7 @@ """Optionale Module """ import logging +import datetime from math import ceil from threading import Thread from typing import List @@ -11,10 +12,13 @@ from helpermodules import hardware_configuration from helpermodules.constants import NO_ERROR from helpermodules.pub import Pub -from helpermodules.timecheck import create_unix_timestamp_current_full_hour from helpermodules.utils import thread_handler from modules.common.configurable_tariff import ConfigurableElectricityTariff from modules.common.configurable_monitoring import ConfigurableMonitoring +from helpermodules.timecheck import ( + create_unix_timestamp_current_quarter_hour, + create_unix_timestamp_current_full_hour +) log = logging.getLogger(__name__) @@ -64,37 +68,61 @@ def et_charging_allowed(self, max_price: float): log.exception("Fehler im Optional-Modul") return False - def et_get_current_price(self): + def et_get_current_price(self) -> float: if self.et_provider_available(): - return self.data.et.get.prices[str(int(create_unix_timestamp_current_full_hour()))] + prices = self.data.et.get.prices + timestamp, first = next(iter(prices.items())) + log.debug(f"first in prices list: {first} from " + + f"{datetime.datetime.fromtimestamp(int(timestamp)).strftime('%Y-%m-%d %H:%M')}") + return first else: raise Exception("Kein Anbieter für strompreisbasiertes Laden konfiguriert.") + def __calculate_price_timeslot_length(self, prices: dict) -> int: + first_timestamps = list(prices.keys())[:2] + return int(first_timestamps[1]) - int(first_timestamps[0]) + def et_get_loading_hours(self, duration: float, remaining_time: float) -> List[int]: """ Parameter --------- duration: float benötigte Ladezeit - + remaining_time: float + Restzeit bis Termin (von wo an gerechnet???) Return ------ - list: Key des Dictionary (Unix-Sekunden der günstigen Stunden) + list: Key des Dictionary (Unix-Sekunden der günstigen Zeit-Slots) """ if self.et_provider_available() is False: raise Exception("Kein Anbieter für strompreisbasiertes Laden konfiguriert.") try: prices = self.data.et.get.prices - prices_in_scheduled_time = {} - i = 0 - for timestamp, price in prices.items(): - if i < ceil((duration+remaining_time)/3600): - prices_in_scheduled_time.update({timestamp: price}) - i += 1 - else: - break - ordered = sorted(prices_in_scheduled_time.items(), key=lambda x: x[1]) - return [int(i[0]) for i in ordered][:ceil(duration/3600)] + price_timeslot_seconds = self.__calculate_price_timeslot_length(prices) + now = ( + create_unix_timestamp_current_full_hour() + if 3600 == price_timeslot_seconds + else create_unix_timestamp_current_quarter_hour() + ) + + log.debug(f"current full hour: " + f"{int(now)} {datetime.datetime.fromtimestamp(int(now)).strftime('%Y-%m-%d %H:%M')} " + f"Plan target Date: {int(now) + remaining_time} " + f"{datetime.datetime.fromtimestamp(int(now) + remaining_time).strftime('%Y-%m-%d %H:%M')}") + + prices = { + timestamp: price + for timestamp, price in prices.items() + if ( # is current timeslot or futur + int(timestamp) >= int(now) and + # ends before plan target time + int(timestamp) + price_timeslot_seconds <= int(now) + remaining_time + ) + } + log.debug(f"shrinked prices list to {len(prices)} time lots before " + + f"{datetime.datetime.fromtimestamp(int(now) + remaining_time).strftime('%Y-%m-%d %H:%M')}") + ordered_by_price = sorted(prices.items(), key=lambda x: x[1]) + return sorted([int(i[0]) for i in ordered_by_price][:ceil(duration/price_timeslot_seconds)]) except Exception: log.exception("Fehler im Optional-Modul") return [] diff --git a/packages/control/optional_test.py b/packages/control/optional_test.py index cc517ef7ae..ee924a7429 100644 --- a/packages/control/optional_test.py +++ b/packages/control/optional_test.py @@ -1,16 +1,23 @@ from unittest.mock import Mock from control.optional import Optional +import pytest +ONE_HOUR_SECONDS = 3600 + +@pytest.mark.no_mock_full_hour def test_et_get_loading_hours(monkeypatch): # setup opt = Optional() opt.data.et.get.prices = PRICE_LIST mock_et_provider_available = Mock(return_value=True) monkeypatch.setattr(opt, "et_provider_available", mock_et_provider_available) + monkeypatch.setattr("control.optional.create_unix_timestamp_current_full_hour", + Mock(return_value=1698228000)) # execution - loading_hours = opt.et_get_loading_hours(3600, 7200) + loading_hours = opt.et_get_loading_hours(duration=ONE_HOUR_SECONDS, + remaining_time=3 * ONE_HOUR_SECONDS) # evaluation assert loading_hours == [1698231600] diff --git a/packages/helpermodules/timecheck.py b/packages/helpermodules/timecheck.py index adb5d0517a..5bfc47c2ea 100644 --- a/packages/helpermodules/timecheck.py +++ b/packages/helpermodules/timecheck.py @@ -343,3 +343,7 @@ def convert_timestamp_delta_to_time_string(timestamp: int, delta: int) -> str: return f"{minute_diff} Min." elif seconds_diff > 0: return f"{seconds_diff} Sek." + + +def convert_to_timestamp(timestring: str) -> int: + return int(datetime.datetime.fromisoformat(timestring).timestamp()) diff --git a/packages/helpermodules/timecheck_test.py b/packages/helpermodules/timecheck_test.py index a1e60fe794..2523b12c9b 100644 --- a/packages/helpermodules/timecheck_test.py +++ b/packages/helpermodules/timecheck_test.py @@ -145,6 +145,7 @@ def test_convert_timestamp_delta_to_time_string(timestamp, expected): assert time_string == expected +@pytest.mark.no_mock_quarter_hour @pytest.mark.parametrize("timestamp, expected", [ pytest.param("2025-10-01 09:00", "2025-10-01 09:00", id="9:00"), diff --git a/packages/modules/common/component_state.py b/packages/modules/common/component_state.py index 7eb3ff5990..620418000c 100644 --- a/packages/modules/common/component_state.py +++ b/packages/modules/common/component_state.py @@ -232,8 +232,10 @@ def __init__(self, @auto_str class TariffState: def __init__(self, - prices: Optional[Dict[int, float]] = None) -> None: + prices: Optional[Dict[str, float]] = None, + prices_per_hour: int = 24) -> None: self.prices = prices + self.prices_per_hour = prices_per_hour @auto_str diff --git a/packages/modules/common/configurable_tariff.py b/packages/modules/common/configurable_tariff.py index afb933eacd..f42b65bc1c 100644 --- a/packages/modules/common/configurable_tariff.py +++ b/packages/modules/common/configurable_tariff.py @@ -1,6 +1,8 @@ from typing import TypeVar, Generic, Callable -from helpermodules.timecheck import create_unix_timestamp_current_full_hour - +from helpermodules.timecheck import ( + create_unix_timestamp_current_quarter_hour, + create_unix_timestamp_current_full_hour +) from modules.common import store from modules.common.component_context import SingleComponentUpdateContext from modules.common.component_state import TariffState @@ -31,16 +33,26 @@ def update(self): tariff_state = self._remove_outdated_prices(self._component_updater()) self.store.set(tariff_state) self.store.update() - if len(tariff_state.prices) < 24: + expected_time_slots = 24 * tariff_state.prices_per_hour + if len(tariff_state.prices) < expected_time_slots: self.fault_state.no_error( - f'Die Preisliste hat nicht 24, sondern {len(tariff_state.prices)} Einträge. ' + f'Die Preisliste hat nicht {expected_time_slots}, ' + f'sondern {len(tariff_state.prices)} Einträge. ' 'Die Strompreise werden vom Anbieter erst um 14:00 für den Folgetag aktualisiert.') - def _remove_outdated_prices(self, tariff_state: TariffState) -> TariffState: - current_hour = str(int(create_unix_timestamp_current_full_hour())) + def _remove_outdated_prices(self, tariff_state: TariffState, ONE_HOUR_SECONDS: int = 3600) -> TariffState: + first_timestamps = list(tariff_state.prices.keys())[:2] + timeslot_length_seconds = int(first_timestamps[1]) - int(first_timestamps[0]) + is_hourely_prices = ONE_HOUR_SECONDS == timeslot_length_seconds + current_hour = ( + create_unix_timestamp_current_full_hour() + if is_hourely_prices + else create_unix_timestamp_current_quarter_hour() + ) for timestamp in list(tariff_state.prices.keys()): - if timestamp < current_hour: + if int(timestamp) < int(current_hour): self.fault_state.warning( - 'Die Preisliste startet nicht mit der aktuellen Stunde. Abgelaufene Einträge wurden entfernt.') + 'Die Preisliste startet nicht mit der aktuellen Stunde. ' + 'Abgelaufene Einträge wurden entfernt.') tariff_state.prices.pop(timestamp) return tariff_state diff --git a/packages/modules/electricity_tariffs/tibber/tariff.py b/packages/modules/electricity_tariffs/tibber/tariff.py index 33f8b2ae4d..9dd12f7a64 100644 --- a/packages/modules/electricity_tariffs/tibber/tariff.py +++ b/packages/modules/electricity_tariffs/tibber/tariff.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 -from datetime import datetime -from typing import Dict +from typing import Dict, Callable from helpermodules import timecheck import json @@ -9,18 +8,23 @@ from modules.common import req from modules.electricity_tariffs.tibber.config import TibberTariffConfiguration from modules.electricity_tariffs.tibber.config import TibberTariff +import logging +import datetime # Demo-Token: 5K4MVS-OjfWhK_4yrjOlFe1F6kJXPVf7eQYggo8ebAE # Demo Home-ID: 96a14971-525a-4420-aae9-e5aedaa129ff +AS_EURO_PER_Wh = 1000 +log = logging.getLogger(__name__) + def _get_sorted_price_data(response_json: Dict, key: str): return sorted(response_json['data']['viewer']['home']['currentSubscription'] ['priceInfo'][key], key=lambda k: (k['startsAt'], k['total'])) -def fetch_prices(config: TibberTariffConfiguration) -> Dict[int, float]: +def fetch_prices(config: TibberTariffConfiguration) -> Dict[str, float]: headers = {'Authorization': 'Bearer ' + config.token, 'Content-Type': 'application/json'} query = """ query PriceInfo($homeId: ID!) { @@ -48,21 +52,22 @@ def fetch_prices(config: TibberTariffConfiguration) -> Dict[int, float]: today_prices = _get_sorted_price_data(response_json, 'today') tomorrow_prices = _get_sorted_price_data(response_json, 'tomorrow') sorted_market_prices = today_prices + tomorrow_prices - prices: Dict[int, float] = {} - current_hour = timecheck.create_unix_timestamp_current_full_hour() - for price_data in sorted_market_prices: - start_time_epoch = datetime.fromisoformat(price_data['startsAt']).timestamp() - if current_hour <= start_time_epoch: - prices.update({str(int(start_time_epoch)): price_data['total'] / 1000}) + current_hour = timecheck.create_unix_timestamp_current_quarter_hour() + log.debug(f"current full hour: {int(current_hour)} " + f"{datetime.datetime.fromtimestamp(int(current_hour)).strftime('%Y-%m-%d %H:%M')} ") + return { + str(timecheck.convert_to_timestamp(timeslot['startsAt'])): float(timeslot['total']) / AS_EURO_PER_Wh + for timeslot in sorted_market_prices + if timecheck.convert_to_timestamp(timeslot['startsAt']) >= int(current_hour) # is current timeslot or futur + } else: error = response_json['errors'][0]['message'] raise Exception(error) - return prices -def create_electricity_tariff(config: TibberTariff): +def create_electricity_tariff(config: TibberTariff) -> Callable[[], TariffState]: def updater(): - return TariffState(prices=fetch_prices(config.configuration)) + return TariffState(prices=fetch_prices(config.configuration), prices_per_hour=4) return updater diff --git a/packages/modules/electricity_tariffs/tibber/tariff_test.py b/packages/modules/electricity_tariffs/tibber/tariff_test.py index 34e72c5d10..bf5a5d732b 100644 --- a/packages/modules/electricity_tariffs/tibber/tariff_test.py +++ b/packages/modules/electricity_tariffs/tibber/tariff_test.py @@ -2,15 +2,15 @@ from helpermodules import timecheck from modules.electricity_tariffs.tibber.config import TibberTariffConfiguration from modules.electricity_tariffs.tibber.tariff import fetch_prices +import pytest +@pytest.mark.no_mock_quarter_hour def test_fetch_prices(monkeypatch, requests_mock): # setup config = TibberTariffConfiguration(token="5K4MVS-OjfWhK_4yrjOlFe1F6kJXPVf7eQYggo8ebAE", home_id="96a14971-525a-4420-aae9-e5aedaa129ff") - mock_create_unix_timestamp_current_full_hour = Mock(return_value=1698382800) - monkeypatch.setattr(timecheck, "create_unix_timestamp_current_full_hour", - mock_create_unix_timestamp_current_full_hour) + monkeypatch.setattr(timecheck, "create_unix_timestamp_current_quarter_hour", Mock(return_value=1698382800)) requests_mock.post('https://api.tibber.com/v1-beta/gql', json=SAMPLE_DATA) # execution From 939a371bde0cb92a330838531c7d9fc1974c5c8a Mon Sep 17 00:00:00 2001 From: Thomas Papendieck <14850347+tpd-opitz@users.noreply.github.com> Date: Mon, 6 Oct 2025 23:59:20 +0200 Subject: [PATCH 4/5] unittests for 15 minuts charging time slots selection mock quarter hour timestamp --- packages/control/optional_test.py | 137 +++++++++++++++++++++++++----- 1 file changed, 115 insertions(+), 22 deletions(-) diff --git a/packages/control/optional_test.py b/packages/control/optional_test.py index ee924a7429..063531ff04 100644 --- a/packages/control/optional_test.py +++ b/packages/control/optional_test.py @@ -3,36 +3,129 @@ import pytest ONE_HOUR_SECONDS = 3600 +IGNORED = 0.0001 +CHEEP = 0.0002 +EXPENSIVE = 0.0003 @pytest.mark.no_mock_full_hour -def test_et_get_loading_hours(monkeypatch): +@pytest.mark.parametrize( + "granularity, now_ts, duration, remaining_time, price_list, expected_loading_hours", + [ + pytest.param( + "full_hour", + 1698228000, + ONE_HOUR_SECONDS, + 3 * ONE_HOUR_SECONDS, + { + "1698224400": 0.00012499, + "1698228000": 0.00011737999999999999, # matching now + "1698231600": 0.00011562000000000001, + "1698235200": 0.00012447, # last before plan target + "1698238800": 0.00013813, + "1698242400": 0.00014751, + "1698246000": 0.00015372999999999998, + "1698249600": 0.00015462, + "1698253200": 0.00015771, + "1698256800": 0.00013708, + "1698260400": 0.00012355, + "1698264000": 0.00012006, + "1698267600": 0.00011279999999999999, + }, + [1698231600], + id="select single time slot of one hour length" + ), + pytest.param( + "quarter_hour", + 1698226200, + 2 * ONE_HOUR_SECONDS, + 4 * ONE_HOUR_SECONDS, + { + # first hour + "1698224400": IGNORED, + "1698225300": IGNORED, + "1698226200": EXPENSIVE, # current quarert hour + "1698227100": CHEEP, + # second hour + "1698228000": EXPENSIVE, + "1698228900": EXPENSIVE, + "1698229800": CHEEP, + "1698230700": EXPENSIVE, + # third hour + "1698231600": CHEEP, + "1698232500": CHEEP, + "1698233400": CHEEP, + "1698234300": EXPENSIVE, + # fourth hour + "1698235200": CHEEP, + "1698236100": EXPENSIVE, + "1698237000": EXPENSIVE, + "1698237900": EXPENSIVE, + # fifth hour + "1698238800": CHEEP, + "1698239700": CHEEP, # last before plan target + "1698240600": IGNORED, + "1698241500": IGNORED, + }, + [1698227100, 1698229800, 1698231600, 1698232500, 1698233400, 1698235200, 1698238800, 1698239700], + id="select 8 time slots of 15 minutes lenght, include last before plan target" + ), + pytest.param( + "quarter_hour", + 1698227100, + 2 * ONE_HOUR_SECONDS, + 4 * ONE_HOUR_SECONDS, + { + # first hour + "1698224400": IGNORED, + "1698225300": IGNORED, + "1698226200": EXPENSIVE, + "1698227100": CHEEP, # current quarert hour + # second hour + "1698228000": EXPENSIVE, + "1698228900": EXPENSIVE, + "1698229800": CHEEP, + "1698230700": EXPENSIVE, + # third hour + "1698231600": CHEEP, + "1698232500": CHEEP, + "1698233400": CHEEP, + "1698234300": EXPENSIVE, + # fourth hour + "1698235200": CHEEP, + "1698236100": EXPENSIVE, + "1698237000": EXPENSIVE, + "1698237900": EXPENSIVE, + # fifth hour + "1698238800": CHEEP, + "1698239700": CHEEP, + "1698240600": EXPENSIVE, # last before plan target + "1698241500": IGNORED, + }, + [1698227100, 1698229800, 1698231600, 1698232500, 1698233400, 1698235200, 1698238800, 1698239700], + id="select 8 time slots of 15 minutes lenght, include current quarter hour" + ), + ], +) +def test_et_get_loading_hours(granularity, + now_ts, + duration, + remaining_time, + price_list, + expected_loading_hours, + monkeypatch): # setup opt = Optional() - opt.data.et.get.prices = PRICE_LIST + opt.data.et.get.prices = price_list mock_et_provider_available = Mock(return_value=True) monkeypatch.setattr(opt, "et_provider_available", mock_et_provider_available) - monkeypatch.setattr("control.optional.create_unix_timestamp_current_full_hour", - Mock(return_value=1698228000)) + monkeypatch.setattr( + f"control.optional.create_unix_timestamp_current_{granularity}", + Mock(return_value=now_ts) + ) # execution - loading_hours = opt.et_get_loading_hours(duration=ONE_HOUR_SECONDS, - remaining_time=3 * ONE_HOUR_SECONDS) + loading_hours = opt.et_get_loading_hours(duration=duration, remaining_time=remaining_time) # evaluation - assert loading_hours == [1698231600] - - -PRICE_LIST = {"1698224400": 0.00012499, - "1698228000": 0.00011737999999999999, - "1698231600": 0.00011562000000000001, - "1698235200": 0.00012447, - "1698238800": 0.00013813, - "1698242400": 0.00014751, - "1698246000": 0.00015372999999999998, - "1698249600": 0.00015462, - "1698253200": 0.00015771, - "1698256800": 0.00013708, - "1698260400": 0.00012355, - "1698264000": 0.00012006, - "1698267600": 0.00011279999999999999} + assert loading_hours == expected_loading_hours From fe9d917daf6d7840b37d24d11988a8d190779754 Mon Sep 17 00:00:00 2001 From: Thomas aus Welkers <14850347+tpd-opitz@users.noreply.github.com> Date: Thu, 9 Oct 2025 01:29:09 +0200 Subject: [PATCH 5/5] =?UTF-8?q?lokale=20=C3=84nderungen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/conftest.py | 8 +- packages/control/optional.py | 66 ++++++---- packages/control/optional_test.py | 75 ++++++++++- packages/helpermodules/timecheck.py | 15 +-- packages/helpermodules/timecheck_test.py | 30 ----- packages/modules/common/component_state.py | 5 +- .../modules/common/configurable_tariff.py | 72 ++++++---- .../common/configurable_tariff_test.py | 38 +++++- packages/modules/common/store/_tariff.py | 23 +++- .../electricity_tariffs/tibber/tariff.py | 32 ++--- .../electricity_tariffs/tibber/tariff_test.py | 124 ++++++++++-------- 11 files changed, 303 insertions(+), 185 deletions(-) diff --git a/packages/conftest.py b/packages/conftest.py index 9eeb5b8930..d4cd5f8515 100644 --- a/packages/conftest.py +++ b/packages/conftest.py @@ -41,12 +41,8 @@ def mock_today(monkeypatch, request) -> None: monkeypatch.setattr(datetime, "datetime", datetime_mock) now_timestamp = Mock(return_value=1652683252) monkeypatch.setattr(timecheck, "create_timestamp", now_timestamp) - if "no_mock_full_hour" not in request.keywords: - full_hour_timestamp = Mock(return_value=int(datetime.datetime(2022, 5, 16, 8, 0, 0).timestamp())) - monkeypatch.setattr(timecheck, "create_unix_timestamp_current_full_hour", full_hour_timestamp) - if "no_mock_quarter_hour" not in request.keywords: - quarter_hour_timesatmp = Mock(return_value=int(datetime.datetime(2022, 5, 16, 8, 30, 0).timestamp())) - monkeypatch.setattr(timecheck, "create_unix_timestamp_current_quarter_hour", quarter_hour_timesatmp) + full_hour_timestamp = Mock(return_value=int(datetime.datetime(2022, 5, 16, 8, 0, 0).timestamp())) + monkeypatch.setattr(timecheck, "create_unix_timestamp_current_full_hour", full_hour_timestamp) @pytest.fixture(autouse=True) diff --git a/packages/control/optional.py b/packages/control/optional.py index 9518ad8d4f..78f02ca335 100644 --- a/packages/control/optional.py +++ b/packages/control/optional.py @@ -1,7 +1,6 @@ """Optionale Module """ import logging -import datetime from math import ceil from threading import Thread from typing import List @@ -12,13 +11,10 @@ from helpermodules import hardware_configuration from helpermodules.constants import NO_ERROR from helpermodules.pub import Pub +from helpermodules import timecheck from helpermodules.utils import thread_handler from modules.common.configurable_tariff import ConfigurableElectricityTariff from modules.common.configurable_monitoring import ConfigurableMonitoring -from helpermodules.timecheck import ( - create_unix_timestamp_current_quarter_hour, - create_unix_timestamp_current_full_hour -) log = logging.getLogger(__name__) @@ -68,16 +64,31 @@ def et_charging_allowed(self, max_price: float): log.exception("Fehler im Optional-Modul") return False - def et_get_current_price(self) -> float: + def __get_first_entry(self, prices: dict[str, float]) -> tuple[str, float]: if self.et_provider_available(): prices = self.data.et.get.prices timestamp, first = next(iter(prices.items())) - log.debug(f"first in prices list: {first} from " + - f"{datetime.datetime.fromtimestamp(int(timestamp)).strftime('%Y-%m-%d %H:%M')}") - return first + price_timeslot_seconds = self.__calculate_price_timeslot_length(prices) + now = int(timecheck.create_timestamp()) + prices = { + price[0]: price[1] + for price in prices.items() + if int(price[0]) > now - (price_timeslot_seconds - 1) + } + self.data.et.get.prices = prices + timestamp, first = next(iter(prices.items())) + return timestamp, first else: raise Exception("Kein Anbieter für strompreisbasiertes Laden konfiguriert.") + def __get_current_timeslot_start(self, prices: dict[str, float]) -> float: + timestamp, first = self.__get_first_entry(prices) + return timestamp + + def et_get_current_price(self, prices: dict[str, float]) -> float: + timestamp, first = self.__get_first_entry(prices) + return first + def __calculate_price_timeslot_length(self, prices: dict) -> int: first_timestamps = list(prices.keys())[:2] return int(first_timestamps[1]) - int(first_timestamps[0]) @@ -99,30 +110,29 @@ def et_get_loading_hours(self, duration: float, remaining_time: float) -> List[i try: prices = self.data.et.get.prices price_timeslot_seconds = self.__calculate_price_timeslot_length(prices) - now = ( - create_unix_timestamp_current_full_hour() - if 3600 == price_timeslot_seconds - else create_unix_timestamp_current_quarter_hour() - ) - - log.debug(f"current full hour: " - f"{int(now)} {datetime.datetime.fromtimestamp(int(now)).strftime('%Y-%m-%d %H:%M')} " - f"Plan target Date: {int(now) + remaining_time} " - f"{datetime.datetime.fromtimestamp(int(now) + remaining_time).strftime('%Y-%m-%d %H:%M')}") - - prices = { + first_timeslot_start = self.__get_current_timeslot_start(prices) + price_candidates = { timestamp: price for timestamp, price in prices.items() - if ( # is current timeslot or futur - int(timestamp) >= int(now) and + if ( + # is current timeslot or futur + int(timestamp) >= int(first_timeslot_start) and # ends before plan target time - int(timestamp) + price_timeslot_seconds <= int(now) + remaining_time + int(timestamp) + price_timeslot_seconds <= int(first_timeslot_start) + remaining_time ) } - log.debug(f"shrinked prices list to {len(prices)} time lots before " + - f"{datetime.datetime.fromtimestamp(int(now) + remaining_time).strftime('%Y-%m-%d %H:%M')}") - ordered_by_price = sorted(prices.items(), key=lambda x: x[1]) - return sorted([int(i[0]) for i in ordered_by_price][:ceil(duration/price_timeslot_seconds)]) + now = int(timecheck.create_timestamp()) + ordered_by_date_reverse = reversed(sorted(price_candidates.items(), key=lambda x: x[0])) + ordered_by_price = sorted(ordered_by_date_reverse, key=lambda x: x[1]) + selected_time_slots = {int(i[0]): float(i[1]) + for i in ordered_by_price[:1 + ceil(duration/price_timeslot_seconds)]} + selected_lenght = price_timeslot_seconds * ( + len(selected_time_slots)-1) - (int(now) - min(selected_time_slots)) + return sorted(selected_time_slots.keys() + if not (min(selected_time_slots) > now or duration <= selected_lenght) + else [timestamp[0] for timestamp in iter(selected_time_slots.items())][:-1] + ) + # if sum() sorted([int(i[0]) for i in ordered_by_price][:ceil(duration/price_timeslot_seconds)]) except Exception: log.exception("Fehler im Optional-Modul") return [] diff --git a/packages/control/optional_test.py b/packages/control/optional_test.py index 063531ff04..3784b99b2d 100644 --- a/packages/control/optional_test.py +++ b/packages/control/optional_test.py @@ -1,5 +1,6 @@ from unittest.mock import Mock from control.optional import Optional +from helpermodules import timecheck import pytest ONE_HOUR_SECONDS = 3600 @@ -105,6 +106,77 @@ [1698227100, 1698229800, 1698231600, 1698232500, 1698233400, 1698235200, 1698238800, 1698239700], id="select 8 time slots of 15 minutes lenght, include current quarter hour" ), + pytest.param( + "quarter_hour", + 1698227900, + 2 * ONE_HOUR_SECONDS, + 4 * ONE_HOUR_SECONDS, + { + # first hour + "1698224400": IGNORED, + "1698225300": IGNORED, + "1698226200": EXPENSIVE, + "1698227100": CHEEP, # current quarert hour + # second hour + "1698228000": EXPENSIVE, + "1698228900": EXPENSIVE, + "1698229800": CHEEP, + "1698230700": EXPENSIVE, + # third hour + "1698231600": CHEEP, + "1698232500": CHEEP, + "1698233400": CHEEP, + "1698234300": EXPENSIVE, + # fourth hour + "1698235200": CHEEP, + "1698236100": EXPENSIVE, + "1698237000": EXPENSIVE, + "1698237900": EXPENSIVE, + # fifth hour + "1698238800": CHEEP, + "1698239700": CHEEP, + "1698240600": EXPENSIVE, # last before plan target + "1698241500": IGNORED, + }, + [1698227100, 1698229800, 1698231600, 1698232500, + 1698233400, 1698235200, 1698238800, 1698239700, 1698240600], + id="select additional if time elapsed in current slot makes selection too short" + ), + pytest.param( + "quarter_hour", + 1698226600, + 2 * ONE_HOUR_SECONDS, + 4 * ONE_HOUR_SECONDS, + { + # first hour + "1698224400": IGNORED, + "1698225300": IGNORED, + "1698226200": EXPENSIVE, + "1698227100": CHEEP, # current quarert hour + # second hour + "1698228000": EXPENSIVE, + "1698228900": EXPENSIVE, + "1698229800": CHEEP, + "1698230700": EXPENSIVE, + # third hour + "1698231600": CHEEP, + "1698232500": CHEEP, + "1698233400": CHEEP, + "1698234300": EXPENSIVE, + # fourth hour + "1698235200": CHEEP, + "1698236100": EXPENSIVE, + "1698237000": EXPENSIVE, + "1698237900": EXPENSIVE, + # fifth hour + "1698238800": EXPENSIVE, + "1698239700": EXPENSIVE, + "1698240600": EXPENSIVE, # last before plan target + "1698241500": IGNORED, + }, + [1698227100, 1698229800, 1698231600, 1698232500, 1698233400, 1698235200, 1698238800, 1698239700], + id="select latest if most expensive candidates have same price" + ), ], ) def test_et_get_loading_hours(granularity, @@ -120,7 +192,8 @@ def test_et_get_loading_hours(granularity, mock_et_provider_available = Mock(return_value=True) monkeypatch.setattr(opt, "et_provider_available", mock_et_provider_available) monkeypatch.setattr( - f"control.optional.create_unix_timestamp_current_{granularity}", + timecheck, + "create_timestamp", Mock(return_value=now_ts) ) diff --git a/packages/helpermodules/timecheck.py b/packages/helpermodules/timecheck.py index 5bfc47c2ea..1d9437313b 100644 --- a/packages/helpermodules/timecheck.py +++ b/packages/helpermodules/timecheck.py @@ -236,13 +236,6 @@ def create_unix_timestamp_current_full_hour() -> int: return int(datetime.datetime.strptime(full_hour, "%m/%d/%Y, %H").timestamp()) -def create_unix_timestamp_current_quarter_hour() -> int: - def round_to_quarter_hour(current_time: float, quarter_hour: int = 900) -> float: - log.debug(f"current time: {current_time} => modified: {current_time - (current_time % quarter_hour)}") - return current_time - (current_time % quarter_hour) - return int(round_to_quarter_hour(datetime.datetime.today().timestamp())) - - def get_relative_date_string(date_string: str, day_offset: int = 0, month_offset: int = 0, year_offset: int = 0) -> str: print_format = "%Y%m%d" if len(date_string) > 6 else "%Y%m" my_date = datetime.datetime.strptime(date_string, print_format) @@ -307,14 +300,14 @@ def duration_sum(first: str, second: str) -> str: return "00:00" -def __get_timedelta_obj(time: str) -> datetime.timedelta: +def __get_timedelta_obj(time_str: str) -> datetime.timedelta: """ erstellt aus einem String ein timedelta-Objekt. Parameter --------- - time: str + time_str: str Zeitstrings HH:MM ggf DD:HH:MM """ - time_charged = time.split(":") + time_charged = time_str.split(":") if len(time_charged) == 2: delta = datetime.timedelta(hours=int(time_charged[0]), minutes=int(time_charged[1])) @@ -323,7 +316,7 @@ def __get_timedelta_obj(time: str) -> datetime.timedelta: hours=int(time_charged[1]), minutes=int(time_charged[2])) else: - raise Exception("Unknown charge duration: "+time) + raise Exception(f"Unknown charge duration: {time_str}") return delta diff --git a/packages/helpermodules/timecheck_test.py b/packages/helpermodules/timecheck_test.py index 2523b12c9b..8f559705aa 100644 --- a/packages/helpermodules/timecheck_test.py +++ b/packages/helpermodules/timecheck_test.py @@ -143,33 +143,3 @@ def test_convert_timestamp_delta_to_time_string(timestamp, expected): # evaluation assert time_string == expected - - -@pytest.mark.no_mock_quarter_hour -@pytest.mark.parametrize("timestamp, expected", - [ - pytest.param("2025-10-01 09:00", "2025-10-01 09:00", id="9:00"), - pytest.param("2025-10-01 09:01", "2025-10-01 09:00", id="9:01"), - pytest.param("2025-10-01 09:10", "2025-10-01 09:00", id="9:10"), - pytest.param("2025-10-01 09:14", "2025-10-01 09:00", id="9:14"), - pytest.param("2025-10-01 09:15", "2025-10-01 09:15", id="9:15"), - pytest.param("2025-10-01 09:41", "2025-10-01 09:30", id="9:41"), - pytest.param("2025-10-01 09:46", "2025-10-01 09:45", id="9:46") - ] - ) -def test_create_unix_timestamp_current_quarter_hour(timestamp, expected, monkeypatch): - # setup - datetime_mock = MagicMock(wraps=datetime.datetime) - current_time = datetime.datetime.strptime(timestamp, "%Y-%m-%d %H:%M") - datetime_mock.today.return_value = current_time - monkeypatch.setattr(datetime, "datetime", datetime_mock) - - # execution - qh = timecheck.create_unix_timestamp_current_quarter_hour() - log.debug(f"timestamp: {current_time} , from mock: {datetime.datetime.today().timestamp()}" - f" result: {qh}") - - current_quarter_hour = datetime.datetime.fromtimestamp(qh).strftime("%Y-%m-%d %H:%M") - - # evaluation - assert current_quarter_hour == expected diff --git a/packages/modules/common/component_state.py b/packages/modules/common/component_state.py index 620418000c..0da5d3ddda 100644 --- a/packages/modules/common/component_state.py +++ b/packages/modules/common/component_state.py @@ -232,10 +232,9 @@ def __init__(self, @auto_str class TariffState: def __init__(self, - prices: Optional[Dict[str, float]] = None, - prices_per_hour: int = 24) -> None: + prices: Optional[Dict[str, float]] = None + ) -> None: self.prices = prices - self.prices_per_hour = prices_per_hour @auto_str diff --git a/packages/modules/common/configurable_tariff.py b/packages/modules/common/configurable_tariff.py index f42b65bc1c..af4ed944d5 100644 --- a/packages/modules/common/configurable_tariff.py +++ b/packages/modules/common/configurable_tariff.py @@ -1,8 +1,8 @@ from typing import TypeVar, Generic, Callable -from helpermodules.timecheck import ( - create_unix_timestamp_current_quarter_hour, - create_unix_timestamp_current_full_hour -) +from datetime import datetime, timedelta +from helpermodules import timecheck +import random +import logging from modules.common import store from modules.common.component_context import SingleComponentUpdateContext from modules.common.component_state import TariffState @@ -11,12 +11,16 @@ T_TARIFF_CONFIG = TypeVar("T_TARIFF_CONFIG") +ONE_HOUR_SECONDS: int = 3600 +log = logging.getLogger(__name__) class ConfigurableElectricityTariff(Generic[T_TARIFF_CONFIG]): def __init__(self, config: T_TARIFF_CONFIG, component_initializer: Callable[[], float]) -> None: + self.__next_query_time = datetime.fromtimestamp(1) + self.__tariff_state: TariffState = None self.config = config self.store = store.get_electricity_tariff_value_store() self.fault_state = FaultState(ComponentInfo(None, self.config.name, ComponentType.ELECTRICITY_TARIFF.value)) @@ -26,33 +30,49 @@ def __init__(self, with SingleComponentUpdateContext(self.fault_state): self._component_updater = component_initializer(config) + def __calulate_next_query_time(self) -> None: + self.__next_query_time = datetime.now().replace( + hour=14, minute=0, second=0 + ) + timedelta( + # aktually ET providers issue next day prices up to half an hour earlier then 14:00 + # reduce serverload on their site by randomizing query time + minutes=random.randint(-7, 7), + seconds=random.randint(0, 59) + ) + if datetime.now() > self.__next_query_time: + self.__next_query_time += timedelta(days=1) + def update(self): if hasattr(self, "_component_updater"): - # Wenn beim Initialisieren etwas schief gelaufen ist, ursprüngliche Fehlermeldung beibehalten - with SingleComponentUpdateContext(self.fault_state): - tariff_state = self._remove_outdated_prices(self._component_updater()) - self.store.set(tariff_state) - self.store.update() - expected_time_slots = 24 * tariff_state.prices_per_hour - if len(tariff_state.prices) < expected_time_slots: - self.fault_state.no_error( - f'Die Preisliste hat nicht {expected_time_slots}, ' - f'sondern {len(tariff_state.prices)} Einträge. ' - 'Die Strompreise werden vom Anbieter erst um 14:00 für den Folgetag aktualisiert.') - - def _remove_outdated_prices(self, tariff_state: TariffState, ONE_HOUR_SECONDS: int = 3600) -> TariffState: - first_timestamps = list(tariff_state.prices.keys())[:2] - timeslot_length_seconds = int(first_timestamps[1]) - int(first_timestamps[0]) - is_hourely_prices = ONE_HOUR_SECONDS == timeslot_length_seconds - current_hour = ( - create_unix_timestamp_current_full_hour() - if is_hourely_prices - else create_unix_timestamp_current_quarter_hour() - ) + if datetime.now() > self.__next_query_time: + # Wenn beim Initialisieren etwas schief gelaufen ist, ursprüngliche Fehlermeldung beibehalten + with SingleComponentUpdateContext(self.fault_state): + self.__tariff_state = self._component_updater() + self.__calulate_next_query_time() + log.debug(f'nächster Abruf der Strompreise nach {self.__next_query_time.strftime("%Y%m%d-%H:%M")}') + timeslot_length_seconds = self.__calculate_price_timeslot_length() + self.__tariff_state = self._remove_outdated_prices(self.__tariff_state, timeslot_length_seconds) + self.store.set(self.__tariff_state) + self.store.update() + expected_time_slots = int(24 * ONE_HOUR_SECONDS / timeslot_length_seconds) + if len(self.__tariff_state.prices) < expected_time_slots: + self.fault_state.no_error( + f'Die Preisliste hat nicht {expected_time_slots}, ' + f'sondern {len(self.__tariff_state.prices)} Einträge. ' + f'nächster Abruf der Strompreise nach {self.__next_query_time.strftime("%Y%m%d-%H:%M")}') + + def __calculate_price_timeslot_length(self) -> int: + first_timestamps = list(self.__tariff_state.prices.keys())[:2] + return int(first_timestamps[1]) - int(first_timestamps[0]) + + def _remove_outdated_prices(self, tariff_state: TariffState, timeslot_length_seconds: int) -> TariffState: + now = timecheck.create_timestamp() for timestamp in list(tariff_state.prices.keys()): - if int(timestamp) < int(current_hour): + if int(timestamp) < now - (timeslot_length_seconds - 1): # keep current time slot self.fault_state.warning( 'Die Preisliste startet nicht mit der aktuellen Stunde. ' 'Abgelaufene Einträge wurden entfernt.') tariff_state.prices.pop(timestamp) + self.fault_state.no_error( + f'Die Preisliste hat {len(tariff_state.prices)} Einträge. ') return tariff_state diff --git a/packages/modules/common/configurable_tariff_test.py b/packages/modules/common/configurable_tariff_test.py index c2b0f7e3c8..38e34516b7 100644 --- a/packages/modules/common/configurable_tariff_test.py +++ b/packages/modules/common/configurable_tariff_test.py @@ -1,5 +1,6 @@ from unittest.mock import Mock +from helpermodules import timecheck import pytest from modules.common.component_state import TariffState @@ -8,27 +9,54 @@ @pytest.mark.parametrize( - "tariff_state, expected", + "now, tariff_state, expected", [ - pytest.param(TariffState(prices={"1652680800": -5.87e-06, + pytest.param(1652680800, + TariffState(prices={"1652680800": -5.87e-06, "1652684400": 5.467e-05, "1652688000": 10.72e-05}), TariffState(prices={"1652680800": -5.87e-06, "1652684400": 5.467e-05, "1652688000": 10.72e-05}), id="keine veralteten Einträge"), - pytest.param(TariffState(prices={"1652677200": -5.87e-06, + pytest.param(1652680800, + TariffState(prices={"1652677200": -5.87e-06, "1652680800": 5.467e-05, "1652684400": 10.72e-05}), TariffState(prices={"1652680800": 5.467e-05, "1652684400": 10.72e-05}), id="Lösche ersten Eintrag"), + pytest.param(1652684000, + TariffState(prices={"1652680800": -5.87e-06, + "1652684400": 5.467e-05, + "1652688000": 10.72e-05}), + TariffState(prices={"1652680800": -5.87e-06, + "1652684400": 5.467e-05, + "1652688000": 10.72e-05}), id="erster time slot noch nicht zu Ende"), + pytest.param(1652684000, + TariffState(prices={"1652680000": -5.87e-06, + "1652681200": 5.467e-05, + "1652682400": 10.72e-05, + "1652683600": 10.72e-05, + "1652684800": 10.72e-05, + "1652686000": 10.72e-05, + "1652687200": 10.72e-05}), + TariffState(prices={"1652683600": 10.72e-05, + "1652684800": 10.72e-05, + "1652686000": 10.72e-05, + "1652687200": 10.72e-05}), id="20 Minuten time slots"), ], ) -def test_remove_outdated_prices(tariff_state: TariffState, expected: TariffState, monkeypatch): +def test_remove_outdated_prices(now: int, tariff_state: TariffState, expected: TariffState, monkeypatch): # setup tariff = ConfigurableElectricityTariff(AwattarTariff(), Mock()) + time_slot_seconds = [int(timestamp) for timestamp in tariff_state.prices.keys()][:2] + + # Montag 16.05.2022, 8:40:52 "05/16/2022, 08:40:52" Unix: 1652683252 + monkeypatch.setattr(timecheck, + "create_timestamp", + Mock(return_value=now)) # test - result = tariff._remove_outdated_prices(tariff_state) + result = tariff._remove_outdated_prices(tariff_state, time_slot_seconds[1]-time_slot_seconds[0]) # assert assert result.prices == expected.prices diff --git a/packages/modules/common/store/_tariff.py b/packages/modules/common/store/_tariff.py index da3698ae3f..927537d2f9 100644 --- a/packages/modules/common/store/_tariff.py +++ b/packages/modules/common/store/_tariff.py @@ -3,6 +3,11 @@ from modules.common.store import ValueStore from modules.common.store._api import LoggingValueStore from modules.common.store._broker import pub_to_broker +import logging +import threading +from datetime import datetime + +log = logging.getLogger(__name__) class TariffValueStoreBroker(ValueStore[TariffState]): @@ -12,14 +17,24 @@ def __init__(self): def set(self, state: TariffState) -> None: self.state = state - def __update(self): + def update(self): try: - pub_to_broker("openWB/set/optional/et/get/prices", self.state.prices) + prices = self.state.prices + pub_to_broker("openWB/set/optional/et/get/prices", prices) + log.debug(f"published prices list to MQTT having {len(prices)} entries") except Exception as e: raise FaultState.from_exception(e) - def update(self): - self.__update(self) + def __republish(self, full_hour: int, quarter_index: int, fifteen_minutes: int = 900) -> None: + now = datetime.now() + target_time = datetime.datetime.fromtimestamp( + full_hour + (quarter_index * fifteen_minutes)) + if now < target_time: + delay = (target_time - now).total_seconds() + log.debug(f"reduce prices list and push to MQTT at {target_time.strftime('%m/%d/%Y, %H:%M')}") + # self.state.prices removes outdated entries itself + timer = threading.Timer(delay, self.__update()) + timer.start() def get_electricity_tariff_value_store() -> ValueStore[TariffState]: diff --git a/packages/modules/electricity_tariffs/tibber/tariff.py b/packages/modules/electricity_tariffs/tibber/tariff.py index 9dd12f7a64..973ea876cc 100644 --- a/packages/modules/electricity_tariffs/tibber/tariff.py +++ b/packages/modules/electricity_tariffs/tibber/tariff.py @@ -1,15 +1,13 @@ #!/usr/bin/env python3 -from typing import Dict, Callable +from typing import Callable from helpermodules import timecheck -import json - from modules.common.abstract_device import DeviceDescriptor from modules.common.component_state import TariffState from modules.common import req from modules.electricity_tariffs.tibber.config import TibberTariffConfiguration from modules.electricity_tariffs.tibber.config import TibberTariff import logging -import datetime +import json # Demo-Token: 5K4MVS-OjfWhK_4yrjOlFe1F6kJXPVf7eQYggo8ebAE @@ -19,12 +17,15 @@ log = logging.getLogger(__name__) -def _get_sorted_price_data(response_json: Dict, key: str): - return sorted(response_json['data']['viewer']['home']['currentSubscription'] - ['priceInfo'][key], key=lambda k: (k['startsAt'], k['total'])) +def _get_sorted_price_data(response_json: dict, day: str) -> dict[str, float]: + prices = sorted(response_json['data']['viewer']['home']['currentSubscription'] + ['priceInfo'][day], key=lambda k: (k['startsAt'], k['total'])) + return {str(timecheck.convert_to_timestamp(price['startsAt'])): # timestamp + float(price['total']) / AS_EURO_PER_Wh # prices + for price in prices} -def fetch_prices(config: TibberTariffConfiguration) -> Dict[str, float]: +def fetch_prices(config: TibberTariffConfiguration) -> dict[str, float]: headers = {'Authorization': 'Bearer ' + config.token, 'Content-Type': 'application/json'} query = """ query PriceInfo($homeId: ID!) { @@ -48,18 +49,13 @@ def fetch_prices(config: TibberTariffConfiguration) -> Dict[str, float]: data = json.dumps(payload) response = req.get_http_session().post('https://api.tibber.com/v1-beta/gql', headers=headers, data=data, timeout=6) response_json = response.json() + log.debug(json.dumps(response_json)) if response_json.get("errors") is None: today_prices = _get_sorted_price_data(response_json, 'today') tomorrow_prices = _get_sorted_price_data(response_json, 'tomorrow') - sorted_market_prices = today_prices + tomorrow_prices - current_hour = timecheck.create_unix_timestamp_current_quarter_hour() - log.debug(f"current full hour: {int(current_hour)} " - f"{datetime.datetime.fromtimestamp(int(current_hour)).strftime('%Y-%m-%d %H:%M')} ") - return { - str(timecheck.convert_to_timestamp(timeslot['startsAt'])): float(timeslot['total']) / AS_EURO_PER_Wh - for timeslot in sorted_market_prices - if timecheck.convert_to_timestamp(timeslot['startsAt']) >= int(current_hour) # is current timeslot or futur - } + sorted_market_prices = {**today_prices, **tomorrow_prices} + log.debug(f"tibber response: {sorted_market_prices}") + return sorted_market_prices else: error = response_json['errors'][0]['message'] raise Exception(error) @@ -67,7 +63,7 @@ def fetch_prices(config: TibberTariffConfiguration) -> Dict[str, float]: def create_electricity_tariff(config: TibberTariff) -> Callable[[], TariffState]: def updater(): - return TariffState(prices=fetch_prices(config.configuration), prices_per_hour=4) + return TariffState(prices=fetch_prices(config.configuration)) return updater diff --git a/packages/modules/electricity_tariffs/tibber/tariff_test.py b/packages/modules/electricity_tariffs/tibber/tariff_test.py index bf5a5d732b..8e9fa6983f 100644 --- a/packages/modules/electricity_tariffs/tibber/tariff_test.py +++ b/packages/modules/electricity_tariffs/tibber/tariff_test.py @@ -1,64 +1,59 @@ from unittest.mock import Mock +from datetime import datetime from helpermodules import timecheck from modules.electricity_tariffs.tibber.config import TibberTariffConfiguration from modules.electricity_tariffs.tibber.tariff import fetch_prices import pytest -@pytest.mark.no_mock_quarter_hour -def test_fetch_prices(monkeypatch, requests_mock): - # setup - config = TibberTariffConfiguration(token="5K4MVS-OjfWhK_4yrjOlFe1F6kJXPVf7eQYggo8ebAE", - home_id="96a14971-525a-4420-aae9-e5aedaa129ff") - monkeypatch.setattr(timecheck, "create_unix_timestamp_current_quarter_hour", Mock(return_value=1698382800)) - requests_mock.post('https://api.tibber.com/v1-beta/gql', json=SAMPLE_DATA) - - # execution - prices = fetch_prices(config) - - # evaluation - assert prices == EXPECTED_PRICES - - -SAMPLE_DATA = {"data": - {"viewer": - {"home": - {"currentSubscription": - {"priceInfo": - {"today": [{"total": 0.8724, "startsAt": "2023-10-27T00:00:00.000+02:00"}, - {"total": 0.8551, "startsAt": "2023-10-27T01:00:00.000+02:00"}, - {"total": 0.8552, "startsAt": "2023-10-27T01:15:00.000+02:00"}, - {"total": 0.8553, "startsAt": "2023-10-27T01:30:00.000+02:00"}, - {"total": 0.8554, "startsAt": "2023-10-27T01:45:00.000+02:00"}, - {"total": 0.8389, "startsAt": "2023-10-27T02:00:00.000+02:00"}, - {"total": 0.8189, "startsAt": "2023-10-27T03:00:00.000+02:00"}, - {"total": 0.8544, "startsAt": "2023-10-27T04:00:00.000+02:00"}, - {"total": 0.8473, "startsAt": "2023-10-27T05:00:00.000+02:00"}, - {"total": 1.1724, "startsAt": "2023-10-27T06:00:00.000+02:00"}, - {"total": 1.6862, "startsAt": "2023-10-27T07:00:00.000+02:00"}, - {"total": 2.3264, "startsAt": "2023-10-27T08:00:00.000+02:00"}, - {"total": 2.3024, "startsAt": "2023-10-27T09:00:00.000+02:00"}, - {"total": 2.3124, "startsAt": "2023-10-27T09:15:00.000+02:00"}, - {"total": 2.3224, "startsAt": "2023-10-27T09:30:00.000+02:00"}, - {"total": 2.3324, "startsAt": "2023-10-27T09:45:00.000+02:00"}, - {"total": 1.7204, "startsAt": "2023-10-27T10:00:00.000+02:00"}, - {"total": 1.7213, "startsAt": "2023-10-27T11:00:00.000+02:00"}, - {"total": 1.8697, "startsAt": "2023-10-27T12:00:00.000+02:00"}, - {"total": 1.69, "startsAt": "2023-10-27T13:00:00.000+02:00"}, - {"total": 1.5725, "startsAt": "2023-10-27T14:00:00.000+02:00"}, - {"total": 1.2997, "startsAt": "2023-10-27T15:00:00.000+02:00"}, - {"total": 1.4289, "startsAt": "2023-10-27T16:00:00.000+02:00"}, - {"total": 1.9176, "startsAt": "2023-10-27T17:00:00.000+02:00"}, - {"total": 2.1175, "startsAt": "2023-10-27T18:00:00.000+02:00"}, - {"total": 1.8902, "startsAt": "2023-10-27T19:00:00.000+02:00"}, - {"total": 1.2104, "startsAt": "2023-10-27T20:00:00.000+02:00"}, - {"total": 1.1332, "startsAt": "2023-10-27T21:00:00.000+02:00"}, - {"total": 0.8015, "startsAt": "2023-10-27T22:00:00.000+02:00"}, - {"total": 0.7698, "startsAt": "2023-10-27T23:00:00.000+02:00"} - ], - "tomorrow": []}}}}}} +SAMPLE_DATA_TODAY = { + "data": { + "viewer": { + "home": {"currentSubscription": { + "priceInfo": {"today": [ + {"total": 0.8724, "startsAt": "2023-10-27T00:00:00.000+02:00"}, + {"total": 0.8551, "startsAt": "2023-10-27T01:00:00.000+02:00"}, + {"total": 0.8552, "startsAt": "2023-10-27T01:15:00.000+02:00"}, + {"total": 0.8553, "startsAt": "2023-10-27T01:30:00.000+02:00"}, + {"total": 0.8554, "startsAt": "2023-10-27T01:45:00.000+02:00"}, + {"total": 0.8389, "startsAt": "2023-10-27T02:00:00.000+02:00"}, + {"total": 0.8189, "startsAt": "2023-10-27T03:00:00.000+02:00"}, + {"total": 0.8544, "startsAt": "2023-10-27T04:00:00.000+02:00"}, + {"total": 0.8473, "startsAt": "2023-10-27T05:00:00.000+02:00"}, + {"total": 1.1724, "startsAt": "2023-10-27T06:00:00.000+02:00"}, + {"total": 1.6862, "startsAt": "2023-10-27T07:00:00.000+02:00"}, + {"total": 2.3264, "startsAt": "2023-10-27T08:00:00.000+02:00"}, + {"total": 2.3024, "startsAt": "2023-10-27T09:00:00.000+02:00"}, + {"total": 2.3124, "startsAt": "2023-10-27T09:15:00.000+02:00"}, + {"total": 2.3224, "startsAt": "2023-10-27T09:30:00.000+02:00"}, + {"total": 2.3324, "startsAt": "2023-10-27T09:45:00.000+02:00"}, + {"total": 1.7204, "startsAt": "2023-10-27T10:00:00.000+02:00"}, + {"total": 1.7213, "startsAt": "2023-10-27T11:00:00.000+02:00"}, + {"total": 1.8697, "startsAt": "2023-10-27T12:00:00.000+02:00"}, + {"total": 1.69, "startsAt": "2023-10-27T13:00:00.000+02:00"}, + {"total": 1.5725, "startsAt": "2023-10-27T14:00:00.000+02:00"}, + {"total": 1.2997, "startsAt": "2023-10-27T15:00:00.000+02:00"}, + {"total": 1.4289, "startsAt": "2023-10-27T16:00:00.000+02:00"}, + {"total": 1.9176, "startsAt": "2023-10-27T17:00:00.000+02:00"}, + {"total": 2.1175, "startsAt": "2023-10-27T18:00:00.000+02:00"}, + {"total": 1.8902, "startsAt": "2023-10-27T19:00:00.000+02:00"}, + {"total": 1.2104, "startsAt": "2023-10-27T20:00:00.000+02:00"}, + {"total": 1.1332, "startsAt": "2023-10-27T21:00:00.000+02:00"}, + {"total": 0.8015, "startsAt": "2023-10-27T22:00:00.000+02:00"}, + {"total": 0.7698, "startsAt": "2023-10-27T23:00:00.000+02:00"} + ], "tomorrow": []}}}}}} -EXPECTED_PRICES = { +EXPECTED_PRICES_TODAY = { + '1698357600': 0.0008724, + '1698361200': 0.0008551, + '1698362100': 0.0008552, + '1698363000': 0.0008552999999999999, + '1698363900': 0.0008554000000000001, + '1698364800': 0.0008389, + '1698368400': 0.0008189, + '1698372000': 0.0008544000000000001, + '1698375600': 0.0008473, + '1698379200': 0.0011724, "1698382800": 0.0016862, "1698386400": 0.0023264, "1698390000": 0.0023024, @@ -80,3 +75,26 @@ def test_fetch_prices(monkeypatch, requests_mock): "1698436800": 0.0008015, "1698440400": 0.0007698000000000001, } + + +@pytest.mark.parametrize( + "now, tibber_response, expected", + [ + pytest.param(datetime.fromisoformat('2023-10-27T07:00:00.000+02:00'), + SAMPLE_DATA_TODAY, + EXPECTED_PRICES_TODAY, + id="return all prices, outdated will be filtered in ConfigurableElectricityTariff"), + ] +) +def test_fetch_prices(now, tibber_response, expected, monkeypatch, requests_mock): + # setup + config = TibberTariffConfiguration(token="5K4MVS-OjfWhK_4yrjOlFe1F6kJXPVf7eQYggo8ebAE", + home_id="96a14971-525a-4420-aae9-e5aedaa129ff") + monkeypatch.setattr(timecheck, "create_timestamp", Mock(return_value=int(now.timestamp()))) + requests_mock.post('https://api.tibber.com/v1-beta/gql', json=tibber_response) + + # execution + prices = fetch_prices(config) + + # evaluation + assert prices == expected