diff --git a/packages/conftest.py b/packages/conftest.py index abfa8dc5ea..d4cd5f8515 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,15 @@ 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) + 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 96c1e66c57..78f02ca335 100644 --- a/packages/control/optional.py +++ b/packages/control/optional.py @@ -11,7 +11,7 @@ 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 import timecheck from helpermodules.utils import thread_handler from modules.common.configurable_tariff import ConfigurableElectricityTariff from modules.common.configurable_monitoring import ConfigurableMonitoring @@ -64,37 +64,75 @@ def et_charging_allowed(self, max_price: float): log.exception("Fehler im Optional-Modul") return False - def et_get_current_price(self): + def __get_first_entry(self, prices: dict[str, float]) -> tuple[str, 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())) + 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]) + 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) + 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(first_timeslot_start) and + # ends before plan target time + int(timestamp) + price_timeslot_seconds <= int(first_timeslot_start) + remaining_time + ) + } + 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 cc517ef7ae..3784b99b2d 100644 --- a/packages/control/optional_test.py +++ b/packages/control/optional_test.py @@ -1,31 +1,204 @@ from unittest.mock import Mock from control.optional import Optional +from helpermodules import timecheck +import pytest +ONE_HOUR_SECONDS = 3600 +IGNORED = 0.0001 +CHEEP = 0.0002 +EXPENSIVE = 0.0003 -def test_et_get_loading_hours(monkeypatch): + +@pytest.mark.no_mock_full_hour +@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" + ), + 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, + 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( + timecheck, + "create_timestamp", + Mock(return_value=now_ts) + ) # execution - loading_hours = opt.et_get_loading_hours(3600, 7200) + 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 diff --git a/packages/helpermodules/timecheck.py b/packages/helpermodules/timecheck.py index 8b42a97d8e..1d9437313b 100644 --- a/packages/helpermodules/timecheck.py +++ b/packages/helpermodules/timecheck.py @@ -300,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])) @@ -316,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 @@ -336,3 +336,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 d45f0b8227..8f559705aa 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, diff --git a/packages/modules/common/component_state.py b/packages/modules/common/component_state.py index 7eb3ff5990..0da5d3ddda 100644 --- a/packages/modules/common/component_state.py +++ b/packages/modules/common/component_state.py @@ -232,7 +232,8 @@ def __init__(self, @auto_str class TariffState: def __init__(self, - prices: Optional[Dict[int, float]] = None) -> None: + prices: Optional[Dict[str, float]] = None + ) -> None: self.prices = prices diff --git a/packages/modules/common/configurable_tariff.py b/packages/modules/common/configurable_tariff.py index afb933eacd..af4ed944d5 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 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 @@ -9,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)) @@ -24,23 +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() - if len(tariff_state.prices) < 24: - self.fault_state.no_error( - f'Die Preisliste hat nicht 24, 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())) + 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 timestamp < 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.') + '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 a6509a91c2..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]): @@ -14,10 +19,23 @@ def set(self, state: TariffState) -> None: 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 __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]: return LoggingValueStore(TariffValueStoreBroker()) diff --git a/packages/modules/electricity_tariffs/tibber/tariff.py b/packages/modules/electricity_tariffs/tibber/tariff.py index 1bcb32283d..973ea876cc 100644 --- a/packages/modules/electricity_tariffs/tibber/tariff.py +++ b/packages/modules/electricity_tariffs/tibber/tariff.py @@ -1,47 +1,67 @@ #!/usr/bin/env python3 -from datetime import datetime -from typing import Dict +from typing import Callable from helpermodules import timecheck - 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 json # 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 _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[int, float]: +def fetch_prices(config: TibberTariffConfiguration) -> dict[str, 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() + 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 - 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}) + 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) - 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 updater diff --git a/packages/modules/electricity_tariffs/tibber/tariff_test.py b/packages/modules/electricity_tariffs/tibber/tariff_test.py index e8b9a66ae2..8e9fa6983f 100644 --- a/packages/modules/electricity_tariffs/tibber/tariff_test.py +++ b/packages/modules/electricity_tariffs/tibber/tariff_test.py @@ -1,61 +1,65 @@ 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 -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) - 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.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": 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, + "1698390900": 0.0023123999999999996, + "1698391800": 0.0023224, + "1698392700": 0.0023323999999999997, "1698393600": 0.0017204, "1698397200": 0.0017213, "1698400800": 0.0018697, @@ -71,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