diff --git a/packages/conftest.py b/packages/conftest.py index 959c842090..28c5f9ea26 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}) @@ -35,8 +40,10 @@ def mock_today(monkeypatch) -> None: datetime_mock.today.return_value = datetime.datetime(2022, 5, 16, 8, 40, 52) datetime_mock.now.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/ev/charge_template.py b/packages/control/ev/charge_template.py index e4cc2929fe..5cd3fb49f0 100644 --- a/packages/control/ev/charge_template.py +++ b/packages/control/ev/charge_template.py @@ -293,7 +293,7 @@ def eco_charging(self, sub_mode = "stop" message = self.AMOUNT_REACHED elif data.data.optional_data.et_provider_available(): - if data.data.optional_data.et_charging_allowed(eco_charging.max_price): + if data.data.optional_data.et_is_charging_allowed_price_threshold(eco_charging.max_price): sub_mode = "instant_charging" message = self.CHARGING_PRICE_LOW phases = max_phases_hw @@ -498,28 +498,34 @@ def _calculate_duration(self, duration = missing_amount/(current * phases*230) * 3600 return duration, missing_amount - SCHEDULED_REACHED_LIMIT_SOC = ("Kein Zielladen, da noch Zeit bis zum Zieltermin ist. " - "Kein Zielladen mit Überschuss, da das SoC-Limit für Überschuss-Laden " + - "erreicht wurde. ") - SCHEDULED_CHARGING_REACHED_LIMIT_SOC = ("Kein Zielladen, da das Limit für Fahrzeug Laden mit Überschuss (SoC-Limit)" - " sowie der Fahrzeug-SoC (Ziel-SoC) bereits erreicht wurde. ") - SCHEDULED_CHARGING_REACHED_AMOUNT = "Kein Zielladen, da die Energiemenge bereits erreicht wurde. " + SCHEDULED_REACHED_MAX_SOC = ("Zielladen ausstehend, da noch Zeit bis zum Zieltermin ist. " + "Kein Zielladen mit Überschuss, da das SoC-Limit für Überschuss-Laden " + + "erreicht wurde. ") + SCHEDULED_CHARGING_REACHED_MAX_AND_LIMIT_SOC = ( + "Zielladen abgeschlossen, da das Limit für Fahrzeug Laden mit Überschuss (SoC-Limit)" + " sowie der Fahrzeug-SoC (Ziel-SoC) bereits erreicht wurde. ") + SCHEDULED_CHARGING_REACHED_AMOUNT = "Zielladen abgeschlossen, da die Energiemenge bereits erreicht wurde. " SCHEDULED_CHARGING_REACHED_SCHEDULED_SOC = ("Falls vorhanden wird mit EVU-Überschuss geladen, da der Ziel-Soc " "für Zielladen bereits erreicht wurde. ") SCHEDULED_CHARGING_BIDI = ("Der Ziel-Soc für Zielladen wurde bereits erreicht. Das Auto wird " "bidirektional ge-/entladen, sodass möglichst weder Bezug noch " "Einspeisung erfolgt. ") - SCHEDULED_CHARGING_NO_PLANS_CONFIGURED = "Keine Ladung, da keine Ziel-Termine konfiguriert sind." + SCHEDULED_CHARGING_NO_PLANS_CONFIGURED = "Kein Zielladen, da keine Ziel-Termine konfiguriert sind." SCHEDULED_CHARGING_NO_DATE_PENDING = "Kein Zielladen, da kein Ziel-Termin ansteht. " - SCHEDULED_CHARGING_USE_PV = "Laden startet {}. Falls vorhanden, wird mit Überschuss geladen. " + SCHEDULED_CHARGING_USE_PV = "Zielladen startet {}. Falls vorhanden, wird mit Überschuss geladen. " SCHEDULED_CHARGING_MAX_CURRENT = "Zielladen mit {}A. Der Ladestrom wurde erhöht, um das Ziel zu erreichen. " SCHEDULED_CHARGING_LIMITED_BY_SOC = 'einen SoC von {}%' SCHEDULED_CHARGING_LIMITED_BY_AMOUNT = '{}kWh geladene Energie' SCHEDULED_CHARGING_IN_TIME = ('Zielladen mit mindestens {}A, um {} um {} zu erreichen. Falls vorhanden wird ' 'zusätzlich EVU-Überschuss geladen. ') SCHEDULED_CHARGING_CHEAP_HOUR = "Zielladen, da ein günstiger Zeitpunkt zum preisbasierten Laden ist. {}" - SCHEDULED_CHARGING_EXPENSIVE_HOUR = ("Zielladen ausstehend, da jetzt kein günstiger Zeitpunkt zum preisbasierten " - "Laden ist. {} Falls vorhanden, wird mit Überschuss geladen. ") + SCHEDULED_CHARGING_EXPENSIVE_HOUR = ( + "Zielladen ausstehend, da jetzt kein günstiger Zeitpunkt zum preisbasierten " + "Laden ist. {} Falls vorhanden, wird mit Überschuss geladen. ") + SCHEDULED_CHARGING_EXPENSIVE_HOUR_REACHED_MAX_SOC = ( + "Zielladen ausstehend, da jetzt kein günstiger Zeitpunkt zum preisbasierten " + "Laden ist. {} " + + "Kein Zielladen mit Überschuss, da das SoC-Limit für Überschuss-Laden erreicht wurde.") def scheduled_charging_calc_current(self, selected_plan: Optional[SelectedPlan], @@ -554,7 +560,7 @@ def scheduled_charging_calc_current(self, (soc > limit.soc_limit if (plan.bidi_charging_enabled and bidi_state == BidiState.BIDI_CAPABLE) else soc >= limit.soc_limit) and soc >= limit.soc_scheduled): - message = self.SCHEDULED_CHARGING_REACHED_LIMIT_SOC + message = self.SCHEDULED_CHARGING_REACHED_MAX_AND_LIMIT_SOC elif limit.selected == "soc" and limit.soc_scheduled <= soc <= limit.soc_limit: if plan.bidi_charging_enabled and bidi_state == BidiState.BIDI_CAPABLE: message = self.SCHEDULED_CHARGING_BIDI @@ -596,29 +602,46 @@ def scheduled_charging_calc_current(self, # Wenn dynamische Tarife aktiv sind, prüfen, ob jetzt ein günstiger Zeitpunkt zum Laden # ist. if plan.et_active: + def get_hours_message() -> str: + def is_loading_hour(hour: int) -> bool: + return data.data.optional_data.et_is_charging_allowed_hours_list(hour) + return ("Geladen wird "+("jetzt und " + if is_loading_hour(hour_list) + else '') + + "zu folgenden Uhrzeiten: " + + ", ".join([tomorrow(hour) + + datetime.datetime.fromtimestamp(hour).strftime('%-H:%M') + for hour in (sorted(hour_list) + if not is_loading_hour(hour_list) + else (sorted(hour_list)[1:] if len(hour_list) > 1 else []))]) + + ".") + + def end_of_today_timestamp() -> int: + return datetime.datetime.now().replace( + hour=23, minute=59, second=59, microsecond=999000).timestamp() + + def tomorrow(timestamp: int) -> str: + return 'morgen ' if end_of_today_timestamp() < timestamp else '' hour_list = data.data.optional_data.et_get_loading_hours( selected_plan.duration, selected_plan.remaining_time) - hours_message = ("Geladen wird zu folgenden Uhrzeiten: " + - ", ".join([datetime.datetime.fromtimestamp(hour).strftime('%-H:%M') - for hour in sorted(hour_list)]) - + ".") + log.debug(f"Günstige Ladezeiten: {hour_list}") - if timecheck.is_list_valid(hour_list): - message = self.SCHEDULED_CHARGING_CHEAP_HOUR.format(hours_message) + if data.data.optional_data.et_is_charging_allowed_hours_list(hour_list): + message = self.SCHEDULED_CHARGING_CHEAP_HOUR.format(get_hours_message()) current = plan_current submode = "instant_charging" elif ((limit.selected == "soc" and soc <= limit.soc_limit) or (limit.selected == "amount" and used_amount < limit.amount)): - message = self.SCHEDULED_CHARGING_EXPENSIVE_HOUR.format(hours_message) + message = self.SCHEDULED_CHARGING_EXPENSIVE_HOUR.format(get_hours_message()) current = min_current submode = "pv_charging" phases = plan.phases_to_use_pv else: - message = self.SCHEDULED_REACHED_LIMIT_SOC + message = self.SCHEDULED_CHARGING_EXPENSIVE_HOUR_REACHED_MAX_SOC.format(get_hours_message()) else: # Wenn SoC-Limit erreicht wurde, soll nicht mehr mit Überschuss geladen werden if limit.selected == "soc" and soc >= limit.soc_limit: - message = self.SCHEDULED_REACHED_LIMIT_SOC + message = self.SCHEDULED_REACHED_MAX_SOC else: now = datetime.datetime.today() start_time = now + datetime.timedelta(seconds=selected_plan.remaining_time) diff --git a/packages/control/ev/charge_template_test.py b/packages/control/ev/charge_template_test.py index 672915e33d..6cd0fb7eb2 100644 --- a/packages/control/ev/charge_template_test.py +++ b/packages/control/ev/charge_template_test.py @@ -14,7 +14,7 @@ from control.general import General from control.text import BidiState from helpermodules import timecheck -from helpermodules.abstract_plans import Limit, ScheduledChargingPlan, TimeChargingPlan +from helpermodules.abstract_plans import Limit, ScheduledChargingPlan, TimeChargingPlan, ScheduledLimit @pytest.fixture(autouse=True) @@ -233,7 +233,7 @@ def test_scheduled_charging_recent_plan(end_time_mock, pytest.param(None, 0, 0, "none", False, (0, "stop", ChargeTemplate.SCHEDULED_CHARGING_NO_DATE_PENDING, 3), id="no date pending"), pytest.param(SelectedPlan(duration=3600), 90, 0, "soc", False, (0, "stop", - ChargeTemplate.SCHEDULED_CHARGING_REACHED_LIMIT_SOC, 1), id="reached limit soc"), + ChargeTemplate.SCHEDULED_CHARGING_REACHED_MAX_AND_LIMIT_SOC, 1), id="reached limit soc"), pytest.param(SelectedPlan(duration=3600), 80, 0, "soc", False, (6, "pv_charging", ChargeTemplate.SCHEDULED_CHARGING_REACHED_SCHEDULED_SOC, 0), id="reached scheduled soc"), pytest.param(SelectedPlan(duration=3600), 80, 0, "soc", True, (6, "bidi_charging", @@ -296,32 +296,96 @@ def test_scheduled_charging_calc_current_no_plans(): assert ret == (0, "stop", ChargeTemplate.SCHEDULED_CHARGING_NO_PLANS_CONFIGURED, 3) +LOADING_HOURS_TODAY = [datetime.datetime( + year=2022, month=5, day=16, hour=8, minute=0).timestamp()] + +LOADING_HOURS_TOMORROW = [datetime.datetime( + year=2022, month=5, day=17, hour=8, minute=0).timestamp()] + + @pytest.mark.parametrize( - "loading_hour, expected", + "is_loading_hour, current_soc, soc_scheduled, sco_limit, loading_hours, expected", [ - pytest.param(True, (14, "instant_charging", ChargeTemplate.SCHEDULED_CHARGING_CHEAP_HOUR.format( - "Geladen wird zu folgenden Uhrzeiten: 8:00."), 3)), - pytest.param(False, (6, "pv_charging", ChargeTemplate.SCHEDULED_CHARGING_EXPENSIVE_HOUR.format( - "Geladen wird zu folgenden Uhrzeiten: 8:00."), 0)), + pytest.param(True, 79, 80, 90, LOADING_HOURS_TODAY + LOADING_HOURS_TOMORROW, + ( + 14, + "instant_charging", + ChargeTemplate.SCHEDULED_CHARGING_CHEAP_HOUR.format( + "Geladen wird jetzt und zu folgenden Uhrzeiten: morgen 8:00."), + 3), + id="cheap_hour_charge_with_instant_charging"), + pytest.param(True, 79, 80, 70, LOADING_HOURS_TODAY, + ( + 14, + "instant_charging", + ChargeTemplate.SCHEDULED_CHARGING_CHEAP_HOUR.format( + "Geladen wird jetzt und zu folgenden Uhrzeiten: ."), + 3), + id="SOC limit reached but scheduled SOC not, no further loading hours"), + pytest.param(False, 79, 80, 90, LOADING_HOURS_TODAY, + ( + 6, + "pv_charging", + ChargeTemplate.SCHEDULED_CHARGING_EXPENSIVE_HOUR.format( + "Geladen wird zu folgenden Uhrzeiten: 8:00."), + 0), + id="expensive_hour_charge_with_pv"), + pytest.param(False, 79, 80, 70, LOADING_HOURS_TODAY, + ( + 0, + "stop", + ChargeTemplate.SCHEDULED_CHARGING_EXPENSIVE_HOUR_REACHED_MAX_SOC.format( + "Geladen wird zu folgenden Uhrzeiten: 8:00."), + 3), + id="expensive_hour_no_charge_with_pv "), + pytest.param(False, 79, 80, 70, LOADING_HOURS_TODAY + LOADING_HOURS_TOMORROW, + ( + 0, + "stop", + ChargeTemplate.SCHEDULED_CHARGING_EXPENSIVE_HOUR_REACHED_MAX_SOC.format( + "Geladen wird zu folgenden Uhrzeiten: 8:00, morgen 8:00."), + 3), + id="expensive_hour_no_charge_with_pv scheduled for tomorrow"), + pytest.param(False, 79, 60, 80, LOADING_HOURS_TODAY, + ( + 6, + "pv_charging", + ChargeTemplate.SCHEDULED_CHARGING_REACHED_SCHEDULED_SOC.format( + ""), + 0), + id="expensive_hour_pv_charging"), + pytest.param(False, 79, 60, 50, LOADING_HOURS_TODAY, + ( + 0, + "stop", + ChargeTemplate.SCHEDULED_CHARGING_REACHED_MAX_AND_LIMIT_SOC.format( + ""), + 3), + id="scheduled and limit SOC reached"), ]) -def test_scheduled_charging_calc_current_electricity_tariff(loading_hour, expected, monkeypatch): +def test_scheduled_charging_calc_current_electricity_tariff( + is_loading_hour, current_soc, soc_scheduled, sco_limit, loading_hours, expected, monkeypatch): # setup + datetime_mock = Mock(wraps=datetime.datetime) + datetime_mock.now.return_value = datetime.datetime.fromtimestamp(LOADING_HOURS_TODAY[0]) + monkeypatch.setattr(datetime, "datetime", datetime_mock) + ct = ChargeTemplate() - plan = ScheduledChargingPlan(active=True) + plan = ScheduledChargingPlan(active=True, + limit=ScheduledLimit(selected="soc", soc_scheduled=soc_scheduled, soc_limit=sco_limit)) plan.et_active = True plan.limit.selected = "soc" ct.data.chargemode.scheduled_charging.plans = [plan] # für Github-Test keinen Zeitstempel verwenden - mock_et_get_loading_hours = Mock(return_value=[datetime.datetime( - year=2022, month=5, day=16, hour=8, minute=0).timestamp()]) + mock_et_get_loading_hours = Mock(return_value=loading_hours) monkeypatch.setattr(data.data.optional_data, "et_get_loading_hours", mock_et_get_loading_hours) - mock_is_list_valid = Mock(return_value=loading_hour) - monkeypatch.setattr(timecheck, "is_list_valid", mock_is_list_valid) + mock_is_list_valid = Mock(return_value=is_loading_hour) + monkeypatch.setattr(data.data.optional_data, "et_is_charging_allowed_hours_list", mock_is_list_valid) # execution - ret = ct.scheduled_charging_calc_current(SelectedPlan( - plan=plan, remaining_time=301, phases=3, duration=3600), - 79, 0, 3, 6, 0, ChargingType.AC.value, EvTemplate(), BidiState.BIDI_CAPABLE) + ret = ct.scheduled_charging_calc_current( + SelectedPlan(plan=plan, remaining_time=301, phases=3, duration=3600), + current_soc, 0, 3, 6, 0, ChargingType.AC.value, EvTemplate(), BidiState.BIDI_CAPABLE) # evaluation assert ret == expected diff --git a/packages/control/optional.py b/packages/control/optional.py index 96c1e66c57..8f17a1137e 100644 --- a/packages/control/optional.py +++ b/packages/control/optional.py @@ -3,7 +3,8 @@ import logging from math import ceil from threading import Thread -from typing import List +from typing import List, Optional +from datetime import datetime from control import data from control.ocpp import OcppMixin @@ -11,25 +12,50 @@ 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 log = logging.getLogger(__name__) +AS_EURO_PER_KWH = 1000.0 # Umrechnung von €/Wh in €/kWh class Optional(OcppMixin): def __init__(self): try: self.data = OptionalData() - self.et_module: ConfigurableElectricityTariff = None + # guarded et_module stored in a private attribute + self._et_module: Optional[ConfigurableElectricityTariff] = None self.monitoring_module: ConfigurableMonitoring = None self.data.dc_charging = hardware_configuration.get_hardware_configuration_setting("dc_charging") Pub().pub("openWB/optional/dc_charging", self.data.dc_charging) except Exception: log.exception("Fehler im Optional-Modul") + @property + def et_module(self) -> Optional[ConfigurableElectricityTariff]: + """Getter for the electricity tariff module (may be None).""" + return self._et_module + + @et_module.setter + def et_module(self, value: Optional[ConfigurableElectricityTariff]): + """Setter with basic type-guarding and logging. + + Accepts either None or a ConfigurableElectricityTariff instance. Logs when set/cleared. + """ + if self._et_module and (value is None or self._et_module.config.name == value.config.name): + log.debug("Replacing existing et_module on Optional not allowed!") + else: + if value is not None and not isinstance(value, ConfigurableElectricityTariff): + raise TypeError("et_module must be a ConfigurableElectricityTariff instance or None") + self._et_module = value + if value is not None: + log.info("et_module set on Optional: %s", value.config.name) + self.et_get_prices() + else: + log.info("et_module cleared in Optional") + def monitoring_start(self): if self.monitoring_module is not None: self.monitoring_module.start_monitoring() @@ -41,7 +67,30 @@ def monitoring_stop(self): def et_provider_available(self) -> bool: return self.et_module is not None - def et_charging_allowed(self, max_price: float): + def et_is_charging_allowed_hours_list(self, selected_hours: list[int]) -> bool: + """ prüft, ob das strompreisbasiertes Laden aktiviert und ein günstiger Zeitpunkt ist. + + Parameter + --------- + selected_hours: list[int] + Liste der ausgewählten günstigen Zeitslots (Unix-Timestamps) + + Return + ------ + True: Der aktuelle Zeitpunkt liegt in einem ausgewählten günstigen Zeitslot + False: Der aktuelle Zeitpunkt liegt in keinem günstigen Zeitslot + """ + try: + if self.et_provider_available(): + return self.__get_current_timeslot_start(self.data.et.get.prices) in selected_hours + else: + log.info("Prüfe strompreisbasiertes Laden: Nicht konfiguriert") + return False + except Exception as e: + log.exception(f"Fehler im Optional-Modul: {e}") + return False + + def et_is_charging_allowed_price_threshold(self, max_price: float) -> bool: """ prüft, ob der aktuelle Strompreis niedriger oder gleich der festgelegten Preisgrenze ist. Return @@ -51,72 +100,124 @@ def et_charging_allowed(self, max_price: float): """ try: if self.et_provider_available(): - if self.et_get_current_price() <= max_price: - return True - else: - return False + current_price = self.et_get_current_price(prices=self.data.et.get.prices) + log.info("Prüfe strompreisbasiertes Laden mit Preisgrenze %.5f €/kWh, aktueller Preis: %.5f €/kWh", + max_price * AS_EURO_PER_KWH, + current_price * AS_EURO_PER_KWH + ) + return current_price <= max_price else: return True - except KeyError: - log.exception("Fehler beim strompreisbasierten Laden") - self.et_get_prices() - except Exception: - log.exception("Fehler im Optional-Modul") + except KeyError as e: + log.exception("Fehler beim strompreisbasierten Laden: %s", e) + return False + except Exception as e: + log.exception("Fehler im Optional-Modul: %s", e) 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 + if prices is None or len(prices) == 0: + raise Exception("Keine Preisdaten für strompreisbasiertes Laden vorhanden.") + else: + 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]) -> int: + timestamp, first = self.__get_first_entry(prices) + return int(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)] - except Exception: - log.exception("Fehler im Optional-Modul") + price_timeslot_seconds = self.__calculate_price_timeslot_length(prices) + now = int(timecheck.create_timestamp()) + price_candidates = { + timestamp: price + for timestamp, price in prices.items() + if ( + # is current timeslot or futur + int(timestamp) + price_timeslot_seconds > now and + # ends before plan target time + not int(timestamp) >= now + remaining_time + ) + } + log.debug("%s Preis-Kandidaten in %s Sekunden zwischen %s Uhr und %s Uhr von %s Uhr bis %s Uhr", + len(price_candidates), + duration, + datetime.fromtimestamp(now), + datetime.fromtimestamp(now + remaining_time), + datetime.fromtimestamp(int(min(price_candidates))), + datetime.fromtimestamp(int(max(price_candidates))+price_timeslot_seconds)) + 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 as e: + log.exception("Fehler im Optional-Modul: %s", e) return [] def et_get_prices(self): try: if self.et_module: - thread_handler(Thread(target=self.et_module.update, args=(), name="electricity tariff")) + thread_handler(Thread(target=self.et_module.update, args=(), + name="electricity tariff in optional")) else: # Wenn kein Modul konfiguriert ist, Fehlerstatus zurücksetzen. if self.data.et.get.fault_state != 0 or self.data.et.get.fault_str != NO_ERROR: Pub().pub("openWB/set/optional/et/get/fault_state", 0) Pub().pub("openWB/set/optional/et/get/fault_str", NO_ERROR) - except Exception: - log.exception("Fehler im Optional-Modul") + except Exception as e: + log.exception("Fehler im Optional-Modul: %s", e) def ocpp_transfer_meter_values(self): try: if self.data.ocpp.active: thread_handler(Thread(target=self._transfer_meter_values, args=(), name="OCPP Client")) - except Exception: - log.exception("Fehler im OCPP-Optional-Modul") + except Exception as e: + log.exception("Fehler im OCPP-Optional-Modul: %s", e) def _transfer_meter_values(self): for cp in data.data.cp_data.values(): diff --git a/packages/control/optional_test.py b/packages/control/optional_test.py index cc517ef7ae..4efb677612 100644 --- a/packages/control/optional_test.py +++ b/packages/control/optional_test.py @@ -1,31 +1,439 @@ from unittest.mock import Mock from control.optional import Optional +from helpermodules import timecheck +import pytest +ONE_HOUR_SECONDS = 3600 +IGNORED = 0.0001 +CHEAP = 0.0002 +EXPENSIVE = 0.3000 -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 quarter hour + "1698227100": CHEAP, + # second hour + "1698228000": EXPENSIVE, + "1698228900": EXPENSIVE, + "1698229800": CHEAP, + "1698230700": EXPENSIVE, + # third hour + "1698231600": CHEAP, + "1698232500": CHEAP, + "1698233400": CHEAP, + "1698234300": EXPENSIVE, + # fourth hour + "1698235200": CHEAP, + "1698236100": EXPENSIVE, + "1698237000": EXPENSIVE, + "1698237900": EXPENSIVE, + # fifth hour + "1698238800": CHEAP, + "1698239700": CHEAP, # 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": CHEAP, # current quarter hour + # second hour + "1698228000": EXPENSIVE, + "1698228900": EXPENSIVE, + "1698229800": CHEAP, + "1698230700": EXPENSIVE, + # third hour + "1698231600": CHEAP, + "1698232500": CHEAP, + "1698233400": CHEAP, + "1698234300": EXPENSIVE, + # fourth hour + "1698235200": CHEAP, + "1698236100": EXPENSIVE, + "1698237000": EXPENSIVE, + "1698237900": EXPENSIVE, + # fifth hour + "1698238800": CHEAP, + "1698239700": CHEAP, + "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": CHEAP, # current quarert hour + # second hour + "1698228000": EXPENSIVE, + "1698228900": EXPENSIVE, + "1698229800": CHEAP, + "1698230700": EXPENSIVE, + # third hour + "1698231600": CHEAP, + "1698232500": CHEAP, + "1698233400": CHEAP, + "1698234300": EXPENSIVE, + # fourth hour + "1698235200": CHEAP, + "1698236100": EXPENSIVE, + "1698237000": EXPENSIVE, + "1698237900": EXPENSIVE, + # fifth hour + "1698238800": CHEAP, + "1698239700": CHEAP, + "1698240600": EXPENSIVE, + "1698241500": EXPENSIVE, # last before plan target + # sixth hour + "1698242400": IGNORED, + "1698243300": IGNORED, + }, + [1698227100, 1698229800, 1698231600, 1698232500, + 1698233400, 1698235200, 1698238800, 1698239700, 1698241500], + 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, # current quarter hour + "1698227100": CHEAP, + # second hour + "1698228000": EXPENSIVE, + "1698228900": EXPENSIVE, + "1698229800": CHEAP, + "1698230700": EXPENSIVE, + # third hour + "1698231600": CHEAP, + "1698232500": CHEAP, + "1698233400": CHEAP, + "1698234300": EXPENSIVE, + # fourth hour + "1698235200": CHEAP, + "1698236100": EXPENSIVE, + "1698237000": EXPENSIVE, + "1698237900": EXPENSIVE, + # fifth hour + "1698238800": EXPENSIVE, + "1698239700": EXPENSIVE, + "1698240600": EXPENSIVE, # last before plan target + "1698241500": EXPENSIVE, + # sixth hour + "1698242400": IGNORED, + "1698243300": IGNORED, + }, + [1698227100, 1698229800, 1698231600, 1698232500, 1698233400, 1698235200, 1698239700, 1698240600], + id="order in time sequence equal prices" + ), + pytest.param( + "quarter_hour", + 1698226600, + 2 * ONE_HOUR_SECONDS, + 4 * ONE_HOUR_SECONDS, + { + # first hour + "1698224400": IGNORED, + "1698225300": IGNORED, + "1698226200": EXPENSIVE, # current quarter hour + "1698227100": .07, + # second hour + "1698228000": EXPENSIVE, + "1698228900": .08, + "1698229800": .05, + "1698230700": .04, + # third hour + "1698231600": .03, + "1698232500": .02, + "1698233400": .01, + "1698234300": EXPENSIVE, + # fourth hour + "1698235200": .04, + "1698236100": EXPENSIVE, + "1698237000": EXPENSIVE, + "1698237900": EXPENSIVE, + # fifth hour + "1698238800": EXPENSIVE, + "1698239700": EXPENSIVE, # last before plan target + "1698240600": EXPENSIVE, + "1698241500": IGNORED, + }, + [1698227100, 1698228900, 1698229800, 1698230700, 1698231600, 1698232500, 1698233400, 1698235200], + id="order in time sequence reverse" + ), + ], +) +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 + + +@pytest.mark.parametrize( + "provider_available, current_price, max_price, expected", + [ + pytest.param(True, 0.10, 0.15, True, id="price_below_max"), + pytest.param(True, 0.15, 0.15, True, id="price_equal_max"), + pytest.param(True, 0.20, 0.15, False, id="price_above_max"), + pytest.param(False, None, 0.15, True, id="provider_not_available"), + ] +) +def test_et_charging_allowed(monkeypatch, provider_available, current_price, max_price, expected): + opt = Optional() + monkeypatch.setattr(opt, "et_provider_available", Mock(return_value=provider_available)) + if provider_available: + monkeypatch.setattr(opt, "et_get_current_price", Mock(return_value=current_price)) + result = opt.et_is_charging_allowed_price_threshold(max_price) + assert result == expected + + +def test_et_charging_allowed_exception(monkeypatch): + opt = Optional() + monkeypatch.setattr(opt, "et_provider_available", Mock(return_value=True)) + monkeypatch.setattr(opt, "et_get_current_price", Mock(side_effect=Exception)) + result = opt.et_is_charging_allowed_price_threshold(0.15) + assert result is False + + +@pytest.mark.parametrize( + "now_ts, provider_available, price_list, selected_hours , expected", + [ + pytest.param( + 1698224400, False, {}, [], + False, id="no charge if provider not available" + ), + pytest.param( + 1698224400, True, { + # first hour + "1698224400": IGNORED, + "1698225300": IGNORED, + "1698226200": EXPENSIVE, + "1698227100": CHEAP, # current quarter hour + # second hour + "1698228000": EXPENSIVE, + "1698228900": EXPENSIVE, + "1698229800": CHEAP, + "1698230700": EXPENSIVE, + # third hour + "1698231600": CHEAP, + "1698232500": CHEAP, + "1698233400": CHEAP, + "1698234300": EXPENSIVE, + # fourth hour + "1698235200": CHEAP, + "1698236100": EXPENSIVE, + "1698237000": EXPENSIVE, + "1698237900": EXPENSIVE, + # fifth hour + "1698238800": EXPENSIVE, + "1698239700": EXPENSIVE, + "1698240600": EXPENSIVE, # last before plan target + "1698241500": IGNORED, }, + [1698227100, 1698231600, 1698232500, 1698233400, 1698235200], + False, id="no charge if provider available but before cheapest slot" + ), + pytest.param( + 1698224400, True, { + # first hour + "1698224400": IGNORED, + "1698225300": IGNORED, + "1698226200": EXPENSIVE, + "1698227100": CHEAP, # current quarter hour + # second hour + "1698228000": EXPENSIVE, + "1698228900": EXPENSIVE, + "1698229800": CHEAP, + "1698230700": EXPENSIVE, + # third hour + "1698231600": CHEAP, + "1698232500": CHEAP, + "1698233400": CHEAP, + "1698234300": EXPENSIVE, + # fourth hour + "1698235200": CHEAP, + "1698236100": EXPENSIVE, + "1698237000": EXPENSIVE, + "1698237900": EXPENSIVE, + # fifth hour + "1698238800": EXPENSIVE, + "1698239700": EXPENSIVE, + "1698240600": EXPENSIVE, # last before plan target + "1698241500": IGNORED, }, [], + False, id="no charge if provider no charge times list" + ), + pytest.param( + 1698224400, True, { + # second hour + "1698228000": EXPENSIVE, + "1698228900": EXPENSIVE, + "1698229800": CHEAP, + "1698230700": EXPENSIVE, + # third hour + "1698231600": CHEAP, + "1698232500": CHEAP, + "1698233400": CHEAP, + "1698234300": EXPENSIVE, + # fourth hour + "1698235200": CHEAP, + "1698236100": EXPENSIVE, + "1698237000": EXPENSIVE, + "1698237900": EXPENSIVE, + # fifth hour + "1698238800": EXPENSIVE, + "1698239700": EXPENSIVE, + "1698240600": EXPENSIVE, # last before plan target + "1698241500": IGNORED, }, + [1698227100, 1698231600, 1698232500, 1698233400, 1698235200], + False, id="no charge if current time in expensive hour" + ), + pytest.param( + 1698227100, True, { + # first hour + "1698227100": CHEAP, # current quarter hour + # second hour + "1698228000": EXPENSIVE, + "1698228900": EXPENSIVE, + "1698229800": CHEAP, + "1698230700": EXPENSIVE, + # third hour + "1698231600": CHEAP, + "1698232500": CHEAP, + "1698233400": CHEAP, + "1698234300": EXPENSIVE, + # fourth hour + "1698235200": CHEAP, + "1698236100": EXPENSIVE, + "1698237000": EXPENSIVE, + "1698237900": EXPENSIVE, + # fifth hour + "1698238800": EXPENSIVE, + "1698239700": EXPENSIVE, + "1698240600": EXPENSIVE, # last before plan target + "1698241500": IGNORED, }, + [1698227100, 1698231600, 1698232500, 1698233400, 1698235200], + True, id="charge if provider available and matching time slot start" + ), + pytest.param( + 1698227100, True, { + # first hour + "1698227100": IGNORED, # current quarter hour + # second hour + "1698228000": IGNORED, + "1698228900": IGNORED, + "1698229800": IGNORED, + "1698230700": IGNORED, + # third hour + "1698231600": IGNORED, + "1698232500": IGNORED, + "1698233400": IGNORED, + "1698234300": IGNORED, + # fourth hour + "1698235200": IGNORED, + "1698236100": IGNORED, + "1698237000": IGNORED, + "1698237900": IGNORED, + # fifth hour + "1698238800": IGNORED, + "1698239700": IGNORED, + "1698240600": IGNORED, # last before plan target + "1698241500": IGNORED, }, + [1698227100, 1698231600, 1698232500, 1698233400, 1698235200], + True, id="charge if provider available and matching time slot start" + ), + ] +) +def test_et_charging_available(now_ts, provider_available, price_list, selected_hours, expected, monkeypatch): + monkeypatch.setattr( + timecheck, + "create_timestamp", + Mock(return_value=now_ts) + ) + opt = Optional() + opt.data.et.get.prices = price_list + monkeypatch.setattr(opt, "et_provider_available", Mock(return_value=provider_available)) + result = opt.et_is_charging_allowed_hours_list(selected_hours) + assert result == expected + + +def test_et_charging_available_exception(monkeypatch): + opt = Optional() + monkeypatch.setattr(opt, "et_provider_available", Mock(return_value=True)) + opt.data.et.get.prices = {} # empty prices list raises exception + result = opt.et_is_charging_allowed_hours_list([]) + assert result is False 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/main.py b/packages/main.py index 1945cf890d..d5ebdf0409 100755 --- a/packages/main.py +++ b/packages/main.py @@ -190,6 +190,70 @@ def handler5MinAlgorithm(self): except Exception: log.exception("Fehler im Main-Modul") + @__with_handler_lock(error_threshold=60) + def handler5Min(self): + """ Handler, der alle 5 Minuten aufgerufen wird und die Heartbeats der Threads überprüft und die Aufgaben + ausführt, die nur alle 5 Minuten ausgeführt werden müssen. + """ + try: + log.debug("5 Minuten Handler ausführen.") + if not sub.heartbeat: + log.error("Heartbeat für Subdata nicht zurückgesetzt.") + sub.disconnect() + thread_handler(Thread(target=sub.sub_topics, args=(), name="Subdata")) + else: + sub.heartbeat = False + + if not set.heartbeat: + log.error("Heartbeat für Setdata nicht zurückgesetzt.") + set.disconnect() + thread_handler(Thread(target=set.set_data, args=(), name="Setdata")) + else: + set.heartbeat = False + + if sub.internal_chargepoint_data["global_data"].configured: + if not general_internal_chargepoint_handler.internal_chargepoint_handler.heartbeat: + log.error("Heartbeat für Internen Ladepunkt nicht zurückgesetzt.") + general_internal_chargepoint_handler.event_stop.set() + general_internal_chargepoint_handler.event_start.set() + else: + general_internal_chargepoint_handler.internal_chargepoint_handler.heartbeat = False + with ChangedValuesContext(loadvars_.event_module_update_completed): + sub.system_data["system"].update_ip_address() + + # In-Memory Log-Handler zurücksetzen + logger.clear_in_memory_log_handler("main") + + log.info("# ***Start*** ") + # log.debug(run_command.run_shell_command("top -b -n 1 | head -n 20")) + # log.debug(f'Drosselung: {run_command.run_shell_command("if which vcgencmd >/dev/null; then vcgencmd get_throttled; else echo not found; fi")}') + Pub().pub("openWB/set/system/time", timecheck.create_timestamp()) + if not self.__acquire_lock("handler10Sec", error_threshold=30): + return + try: + logger.write_logs_to_file("main") + finally: + self.__release_lock("handler10Sec") + except Exception: + log.exception("Fehler im Main-Modul") + + @__with_handler_lock(error_threshold=60) + def handler5MinAlgorithm(self): + """ Handler, der alle 5 Minuten aufgerufen wird und die Heartbeats der Threads überprüft und die Aufgaben + ausführt, die nur alle 5 Minuten ausgeführt werden müssen. + """ + try: + with ChangedValuesContext(loadvars_.event_module_update_completed): + totals = save_log(LogType.DAILY) + update_daily_yields(totals) + update_pv_monthly_yearly_yields() + data.data.general_data.grid_protection() + data.data.optional_data.ocpp_transfer_meter_values() + data.data.counter_all_data.validate_hierarchy() + data.data.optional_data.et_get_prices() + except Exception: + log.exception("Fehler im Main-Modul") + @__with_handler_lock(error_threshold=60) def handler5Min(self): """ Handler, der alle 5 Minuten aufgerufen wird und die Heartbeats der Threads überprüft und die Aufgaben @@ -250,7 +314,9 @@ def handler_hour(self): """ Handler, der jede Stunde aufgerufen wird und die Aufgaben ausführt, die nur jede Stunde ausgeführt werden müssen. """ try: - data.data.optional_data.et_get_prices() + with ChangedValuesContext(loadvars_.event_module_update_completed): + for cp in data.data.cp_data.values(): + calculate_charged_energy_by_source(cp) logger.clear_in_memory_log_handler(None) except Exception: log.exception("Fehler im Main-Modul") 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..19abccca24 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,24 @@ T_TARIFF_CONFIG = TypeVar("T_TARIFF_CONFIG") +TARIFF_UPDATE_HOUR = 14 # latest expected time for daily tariff update +ONE_HOUR_SECONDS: int = 3600 +log = logging.getLogger(__name__) +''' +next_query_time and internal_tariff_state are defined outside of class ConfigurableElectricityTariff because +for an unknown reason defining them as a class variable does not keep their values. +''' +next_query_time: datetime = datetime.fromtimestamp(1) +internal_tariff_state: TariffState = None class ConfigurableElectricityTariff(Generic[T_TARIFF_CONFIG]): def __init__(self, config: T_TARIFF_CONFIG, component_initializer: Callable[[], float]) -> None: + global internal_tariff_state, next_query_time + next_query_time = datetime.now() + internal_tariff_state = 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 +38,118 @@ def __init__(self, with SingleComponentUpdateContext(self.fault_state): self._component_updater = component_initializer(config) - def update(self): + def update(self) -> None: 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())) - for timestamp in list(tariff_state.prices.keys()): - if timestamp < current_hour: - self.fault_state.warning( - 'Die Preisliste startet nicht mit der aktuellen Stunde. Abgelaufene Einträge wurden entfernt.') - tariff_state.prices.pop(timestamp) + tariff_state, timeslot_length_seconds = self.__update_et_provider_data(internal_tariff_state) + self.__store_and_publish_updated_data(tariff_state) + self.__log_and_publish_progress(timeslot_length_seconds) + + def __update_et_provider_data(self, tariff_state: TariffState) -> tuple[TariffState, int]: + tariff_state = self.__query_et_provider_data_once_per_day(internal_tariff_state) + timeslot_length_seconds = self.__calculate_price_timeslot_length(tariff_state) + tariff_state = self._remove_outdated_prices(tariff_state, timeslot_length_seconds) + return tariff_state, timeslot_length_seconds + + def __query_et_provider_data_once_per_day(self, tariff_state: TariffState) -> TariffState: + if datetime.now() > next_query_time: + return self.__query_et_provider_data(tariff_state=tariff_state) + else: + return tariff_state + + def __query_et_provider_data(self, tariff_state: TariffState) -> TariffState: + def is_tomorrow(last_timestamp: str) -> bool: + return (self.__day_of(date=datetime.now()) < self.__day_of(datetime.fromtimestamp(int(last_timestamp))) + or self.__day_of(date=datetime.now()).hour < TARIFF_UPDATE_HOUR) + global next_query_time + log.info(f'Wartezeit {next_query_time.strftime("%Y%m%d-%H:%M:%S")}' + ' abgelaufen, Strompreise werden abgefragt' + ) + try: + new_tariff_state = self._component_updater() + if 0 < len(new_tariff_state.prices): + if is_tomorrow(self.__get_last_entry_time_stamp(new_tariff_state)): + next_query_time = self.__calulate_next_query_time(new_tariff_state) + log.info('Nächster Abruf der Strompreise' + f' {next_query_time.strftime("%Y%m%d-%H:%M:%S")}.') + else: + log.info('Keine Daten für morgen erhalten, weiterer Versuch in 5 Minuten') + return new_tariff_state + else: + log.warning('Leere Preisliste erhalten, weiterer Versuch in 5 Minuten.') + return tariff_state + except Exception as e: + log.warning(f'Fehler beim Abruf der Strompreise: {e}, nächster Versuch in 5 Minuten.') + self.fault_state.warning( + f'Fehler beim Abruf der Strompreise: {e}, nächster Versuch in 5 Minuten.') + return tariff_state + + def __day_of(self, date: datetime) -> datetime: + return date.replace(hour=0, minute=0, second=0, microsecond=0) + + def __next_query_message(self) -> str: + tomorrow = ( + '' + if self.__day_of(datetime.now()) == self.__day_of(next_query_time) + else 'morgen ' + ) + return ( + f'{tomorrow}{next_query_time.strftime("%H:%M")}' + if datetime.now() < next_query_time + else "im nächsten Regelzyklus" + ) + + def __log_and_publish_progress(self, timeslot_length_seconds): + def publish_info(message_extension: str) -> None: + self.fault_state.no_error( + f'Die Preisliste hat {message_extension}{len(internal_tariff_state.prices)} Einträge. ' + f'Nächster Abruf der Strompreise {self.__next_query_message()}.') + expected_time_slots = int(24 * ONE_HOUR_SECONDS / timeslot_length_seconds) + publish_info(f'nicht {expected_time_slots}, sondern ' + if len(internal_tariff_state.prices) < expected_time_slots + else '' + ) + + def __store_and_publish_updated_data(self, tariff_state: TariffState) -> None: + global internal_tariff_state + internal_tariff_state = tariff_state + self.store.set(tariff_state) + self.store.update() + + def __calulate_next_query_time(self, tariff_state: TariffState) -> datetime: + return datetime.fromtimestamp(int(max(tariff_state.prices))).replace( + hour=TARIFF_UPDATE_HOUR, 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 trying early and randomizing query time + minutes=random.randint(1, 7) * -5 + ) + + def __calculate_price_timeslot_length(self, tariff_state: TariffState) -> int: + if (tariff_state is None or + tariff_state.prices is None or + len(tariff_state.prices) < 2): + self.fault_state.error("not enough price entries to calculate timeslot length") + return 1 + else: + first_timestamps = list(tariff_state.prices.keys())[:2] + return int(first_timestamps[1]) - int(first_timestamps[0]) + + def __get_last_entry_time_stamp(self, tariff_state: TariffState) -> str: + last_known_timestamp = "0" + if tariff_state is not None: + last_known_timestamp = max(tariff_state.prices) + return last_known_timestamp + + def _remove_outdated_prices(self, tariff_state: TariffState, timeslot_length_seconds: int) -> TariffState: + if tariff_state.prices is None: + self.fault_state.error("no prices to show") + else: + now = timecheck.create_timestamp() + for timestamp in list(tariff_state.prices.keys()): + if int(timestamp) < now - (timeslot_length_seconds - 1): # keep current time slot + tariff_state.prices.pop(timestamp) + log.debug( + 'Die Preisliste startet nicht mit der aktuellen Stunde. ' + f'Eintrag {timestamp} wurden entfernt. rest: {tariff_state.prices}') return tariff_state diff --git a/packages/modules/common/configurable_tariff_test.py b/packages/modules/common/configurable_tariff_test.py index c2b0f7e3c8..c3463249aa 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 datetime import datetime, timedelta +from unittest.mock import Mock, patch +from helpermodules import timecheck import pytest from modules.common.component_state import TariffState @@ -7,28 +8,214 @@ from modules.electricity_tariffs.awattar.config import AwattarTariff +class DummyConfig: + name = "TestTariff" + + +class DummyTariffState: + def __init__(self, prices): + self.prices = prices + + +def dummy_component_initializer(config): + def updater(): + now = int(datetime.now().timestamp()) + return DummyTariffState({str(now + 3600): 1.0, str(now + 7200): 2.0}) + + return updater + + +@pytest.mark.parametrize( + "tariff_state_factory, last_known_timestamp, expected_log_method, expected_log_call", + [ + pytest.param( + lambda: DummyTariffState( + { + str(int(datetime.now().timestamp()) + 3600): 1.0, + str(int(datetime.now().timestamp()) + 7200): 2.0, + } + ), + lambda: "0", + "info", + True, + id="success_new_data_info_log", + ), + pytest.param( + lambda: DummyTariffState( + {str(int(datetime.now().timestamp()) + 7200): 1.0} + ), + lambda: str(int(datetime.now().timestamp()) + 7200), + "info", + True, + id="no_new_data_info_log", + ), + pytest.param( + lambda: (_ for _ in ()).throw(Exception("Test error")), + lambda: "0", + "warning", + True, + id="exception_warning_log", + ), + pytest.param( + lambda: DummyTariffState({}), + lambda: "0", + "warning", + True, + id="empty response_warning_log", + ), + ], +) +def test_query_et_provider_data_once_per_day_param( + monkeypatch, + tariff_state_factory, + last_known_timestamp, + expected_log_method, + expected_log_call, +): + config = DummyConfig() + # For exception case, use a special component_initializer + if expected_log_method == "warning": + + def failing_component_initializer(config): + def updater(): + raise Exception("Test error") + + return updater + + tariff = ConfigurableElectricityTariff(config, failing_component_initializer) + else: + + def component_initializer(config): + return lambda: tariff_state_factory() + + tariff = ConfigurableElectricityTariff(config, component_initializer) + + monkeypatch.setattr("modules.common.configurable_tariff.next_query_time", + (datetime.now() - timedelta(days=1))) + tariff._ConfigurableElectricityTariff__tariff_state = DummyTariffState( + {str(int(datetime.now().timestamp())): 1.0} + ) + monkeypatch.setattr( + tariff, + "_ConfigurableElectricityTariff__get_last_entry_time_stamp", + last_known_timestamp, + ) + monkeypatch.setattr( + tariff, "_ConfigurableElectricityTariff__calulate_next_query_time", lambda: None + ) + with patch("modules.common.configurable_tariff.log") as mock_log: + tariff._ConfigurableElectricityTariff__query_et_provider_data_once_per_day(TariffState()) + assert getattr(mock_log, expected_log_method).called == expected_log_call + + @pytest.mark.parametrize( - "tariff_state, expected", + "now, tariff_state, expected", [ - pytest.param(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, - "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( + 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( + 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 + + +def test_accept_no_prices_at_start(monkeypatch): + # setup + tariff = ConfigurableElectricityTariff( + AwattarTariff(), + Mock( + return_value=TariffState( + prices={"5": 10.72e-05, "6": 10.72e-05, "7": 10.72e-05, "8": 10.72e-05} + ) + ), + ) + + # Montag 16.05.2022, 8:40:52 "05/16/2022, 08:40:52" Unix: 1652683252 + monkeypatch.setattr(timecheck, "create_timestamp", Mock(return_value=5)) + + # test - do not fail + tariff._remove_outdated_prices(TariffState(), 1) diff --git a/packages/modules/common/store/_tariff.py b/packages/modules/common/store/_tariff.py index a6509a91c2..8a899be7fb 100644 --- a/packages/modules/common/store/_tariff.py +++ b/packages/modules/common/store/_tariff.py @@ -3,6 +3,10 @@ from modules.common.store import ValueStore from modules.common.store._api import LoggingValueStore from modules.common.store._broker import pub_to_broker +import logging + + +log = logging.getLogger(__name__) class TariffValueStoreBroker(ValueStore[TariffState]): @@ -14,7 +18,9 @@ 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) diff --git a/packages/modules/electricity_tariffs/tibber/tariff.py b/packages/modules/electricity_tariffs/tibber/tariff.py index 1bcb32283d..16981b736b 100644 --- a/packages/modules/electricity_tariffs/tibber/tariff.py +++ b/packages/modules/electricity_tariffs/tibber/tariff.py @@ -1,47 +1,63 @@ #!/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 json # Demo-Token: 5K4MVS-OjfWhK_4yrjOlFe1F6kJXPVf7eQYggo8ebAE # Demo Home-ID: 96a14971-525a-4420-aae9-e5aedaa129ff +AS_EURO_PER_Wh = 1000 + -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() 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} + 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