From 3aca8228eea605e9ac47cf6eea485c0233415057 Mon Sep 17 00:00:00 2001 From: Thomas Papendieck <14850347+tpd-opitz@users.noreply.github.com> Date: Tue, 30 Sep 2025 18:57:30 +0200 Subject: [PATCH 01/19] request quarter hourly prices - Co-authored-by: benderl --- packages/helpermodules/timecheck.py | 7 ++++ packages/helpermodules/timecheck_test.py | 32 +++++++++++++++++++ .../electricity_tariffs/tibber/tariff.py | 23 +++++++++++-- .../electricity_tariffs/tibber/tariff_test.py | 9 ++++++ 4 files changed, 69 insertions(+), 2 deletions(-) diff --git a/packages/helpermodules/timecheck.py b/packages/helpermodules/timecheck.py index 8b42a97d8e..a133ca1436 100644 --- a/packages/helpermodules/timecheck.py +++ b/packages/helpermodules/timecheck.py @@ -1,5 +1,6 @@ """prüft, ob Zeitfenster aktuell sind """ +import math import logging import datetime from typing import List, Optional, Tuple, TypeVar, Union @@ -236,6 +237,12 @@ def create_unix_timestamp_current_full_hour() -> int: return int(datetime.datetime.strptime(full_hour, "%m/%d/%Y, %H").timestamp()) +def create_unix_timestamp_current_quarter_hour() -> int: + def round_to_quarter_hour(current_time: float, quarter_hour: int = 900) -> float: + return math.floor(current_time / quarter_hour) * quarter_hour + return int(round_to_quarter_hour(create_timestamp())) + + def get_relative_date_string(date_string: str, day_offset: int = 0, month_offset: int = 0, year_offset: int = 0) -> str: print_format = "%Y%m%d" if len(date_string) > 6 else "%Y%m" my_date = datetime.datetime.strptime(date_string, print_format) diff --git a/packages/helpermodules/timecheck_test.py b/packages/helpermodules/timecheck_test.py index d45f0b8227..8dea6d1ead 100644 --- a/packages/helpermodules/timecheck_test.py +++ b/packages/helpermodules/timecheck_test.py @@ -1,4 +1,5 @@ import datetime +import logging from typing import List, Optional, Union from unittest.mock import MagicMock, Mock import pytest @@ -7,6 +8,8 @@ from helpermodules.abstract_plans import (AutolockPlan, FrequencyDate, FrequencyPeriod, ScheduledChargingPlan, TimeChargingPlan) +log = logging.getLogger(__name__) + class Params: def __init__(self, name: str, @@ -140,3 +143,32 @@ def test_convert_timestamp_delta_to_time_string(timestamp, expected): # evaluation assert time_string == expected + + +@pytest.mark.parametrize("timestamp, expected", + [ + pytest.param("2025-10-01 9:00", "2025-10-01 9:00", id="9:00"), + pytest.param("2025-10-01 9:01", "2025-10-01 9:00", id="9:01"), + pytest.param("2025-10-01 9:10", "2025-10-01 9:00", id="9:10"), + pytest.param("2025-10-01 9:14", "2025-10-01 9:00", id="9:14"), + pytest.param("2025-10-01 9:15", "2025-10-01 9:15", id="9:15"), + pytest.param("2025-10-01 9:41", "2025-10-01 9:30", id="9:41"), + pytest.param("2025-10-01 9:46", "2025-10-01 9:45", id="9:46") + ] + ) +def test_create_unix_timestamp_current_quarter_hour(timestamp, expected, monkeypatch): + # setup + datetime_mock = MagicMock(wraps=datetime.datetime) + current_time = datetime.datetime.strptime(timestamp, "%Y-%m-%d %H:%M") + datetime_mock.today.return_value = current_time + monkeypatch.setattr(datetime, "datetime", datetime_mock) + + # execution + qh= timecheck.create_unix_timestamp_current_quarter_hour() + log.debug(f"timestamp: {current_time} , from mock: {datetime.datetime.today().timestamp()}" + f" result: {qh}") + + current_quarter_hour = datetime.datetime.fromtimestamp(qh).strftime("%Y-%m-%d %H:%M") + + # evaluation + assert current_quarter_hour == expected diff --git a/packages/modules/electricity_tariffs/tibber/tariff.py b/packages/modules/electricity_tariffs/tibber/tariff.py index 1bcb32283d..33f8b2ae4d 100644 --- a/packages/modules/electricity_tariffs/tibber/tariff.py +++ b/packages/modules/electricity_tariffs/tibber/tariff.py @@ -2,6 +2,7 @@ from datetime import datetime from typing import Dict from helpermodules import timecheck +import json from modules.common.abstract_device import DeviceDescriptor from modules.common.component_state import TariffState @@ -21,8 +22,26 @@ def _get_sorted_price_data(response_json: Dict, key: str): def fetch_prices(config: TibberTariffConfiguration) -> Dict[int, float]: headers = {'Authorization': 'Bearer ' + config.token, 'Content-Type': 'application/json'} - data = '{ "query": "{viewer {home(id:\\"' + config.home_id + \ - '\\") {currentSubscription {priceInfo {today {total startsAt} tomorrow {total startsAt}}}}}}" }' + query = """ + query PriceInfo($homeId: ID!) { + viewer { + home(id: $homeId) { + currentSubscription { + priceInfo(resolution: QUARTER_HOURLY) { + today { total startsAt } + tomorrow { total startsAt } + } + } + } + } + } + """ + + payload = { + "query": query, + "variables": {"homeId": config.home_id}, + } + data = json.dumps(payload) response = req.get_http_session().post('https://api.tibber.com/v1-beta/gql', headers=headers, data=data, timeout=6) response_json = response.json() if response_json.get("errors") is None: diff --git a/packages/modules/electricity_tariffs/tibber/tariff_test.py b/packages/modules/electricity_tariffs/tibber/tariff_test.py index e8b9a66ae2..34e72c5d10 100644 --- a/packages/modules/electricity_tariffs/tibber/tariff_test.py +++ b/packages/modules/electricity_tariffs/tibber/tariff_test.py @@ -27,6 +27,9 @@ def test_fetch_prices(monkeypatch, requests_mock): {"priceInfo": {"today": [{"total": 0.8724, "startsAt": "2023-10-27T00:00:00.000+02:00"}, {"total": 0.8551, "startsAt": "2023-10-27T01:00:00.000+02:00"}, + {"total": 0.8552, "startsAt": "2023-10-27T01:15:00.000+02:00"}, + {"total": 0.8553, "startsAt": "2023-10-27T01:30:00.000+02:00"}, + {"total": 0.8554, "startsAt": "2023-10-27T01:45:00.000+02:00"}, {"total": 0.8389, "startsAt": "2023-10-27T02:00:00.000+02:00"}, {"total": 0.8189, "startsAt": "2023-10-27T03:00:00.000+02:00"}, {"total": 0.8544, "startsAt": "2023-10-27T04:00:00.000+02:00"}, @@ -35,6 +38,9 @@ def test_fetch_prices(monkeypatch, requests_mock): {"total": 1.6862, "startsAt": "2023-10-27T07:00:00.000+02:00"}, {"total": 2.3264, "startsAt": "2023-10-27T08:00:00.000+02:00"}, {"total": 2.3024, "startsAt": "2023-10-27T09:00:00.000+02:00"}, + {"total": 2.3124, "startsAt": "2023-10-27T09:15:00.000+02:00"}, + {"total": 2.3224, "startsAt": "2023-10-27T09:30:00.000+02:00"}, + {"total": 2.3324, "startsAt": "2023-10-27T09:45:00.000+02:00"}, {"total": 1.7204, "startsAt": "2023-10-27T10:00:00.000+02:00"}, {"total": 1.7213, "startsAt": "2023-10-27T11:00:00.000+02:00"}, {"total": 1.8697, "startsAt": "2023-10-27T12:00:00.000+02:00"}, @@ -56,6 +62,9 @@ def test_fetch_prices(monkeypatch, requests_mock): "1698382800": 0.0016862, "1698386400": 0.0023264, "1698390000": 0.0023024, + "1698390900": 0.0023123999999999996, + "1698391800": 0.0023224, + "1698392700": 0.0023323999999999997, "1698393600": 0.0017204, "1698397200": 0.0017213, "1698400800": 0.0018697, From 21c6c283d3742f2337fb4750b3b24e453d67ee01 Mon Sep 17 00:00:00 2001 From: Thomas Papendieck <14850347+tpd-opitz@users.noreply.github.com> Date: Wed, 1 Oct 2025 23:05:41 +0200 Subject: [PATCH 02/19] Update timecheck_test.py --- packages/helpermodules/timecheck.py | 6 +++--- packages/helpermodules/timecheck_test.py | 16 ++++++++-------- packages/modules/common/store/_tariff.py | 5 ++++- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/packages/helpermodules/timecheck.py b/packages/helpermodules/timecheck.py index a133ca1436..adb5d0517a 100644 --- a/packages/helpermodules/timecheck.py +++ b/packages/helpermodules/timecheck.py @@ -1,6 +1,5 @@ """prüft, ob Zeitfenster aktuell sind """ -import math import logging import datetime from typing import List, Optional, Tuple, TypeVar, Union @@ -239,8 +238,9 @@ def create_unix_timestamp_current_full_hour() -> int: def create_unix_timestamp_current_quarter_hour() -> int: def round_to_quarter_hour(current_time: float, quarter_hour: int = 900) -> float: - return math.floor(current_time / quarter_hour) * quarter_hour - return int(round_to_quarter_hour(create_timestamp())) + log.debug(f"current time: {current_time} => modified: {current_time - (current_time % quarter_hour)}") + return current_time - (current_time % quarter_hour) + return int(round_to_quarter_hour(datetime.datetime.today().timestamp())) def get_relative_date_string(date_string: str, day_offset: int = 0, month_offset: int = 0, year_offset: int = 0) -> str: diff --git a/packages/helpermodules/timecheck_test.py b/packages/helpermodules/timecheck_test.py index 8dea6d1ead..a1e60fe794 100644 --- a/packages/helpermodules/timecheck_test.py +++ b/packages/helpermodules/timecheck_test.py @@ -147,13 +147,13 @@ def test_convert_timestamp_delta_to_time_string(timestamp, expected): @pytest.mark.parametrize("timestamp, expected", [ - pytest.param("2025-10-01 9:00", "2025-10-01 9:00", id="9:00"), - pytest.param("2025-10-01 9:01", "2025-10-01 9:00", id="9:01"), - pytest.param("2025-10-01 9:10", "2025-10-01 9:00", id="9:10"), - pytest.param("2025-10-01 9:14", "2025-10-01 9:00", id="9:14"), - pytest.param("2025-10-01 9:15", "2025-10-01 9:15", id="9:15"), - pytest.param("2025-10-01 9:41", "2025-10-01 9:30", id="9:41"), - pytest.param("2025-10-01 9:46", "2025-10-01 9:45", id="9:46") + pytest.param("2025-10-01 09:00", "2025-10-01 09:00", id="9:00"), + pytest.param("2025-10-01 09:01", "2025-10-01 09:00", id="9:01"), + pytest.param("2025-10-01 09:10", "2025-10-01 09:00", id="9:10"), + pytest.param("2025-10-01 09:14", "2025-10-01 09:00", id="9:14"), + pytest.param("2025-10-01 09:15", "2025-10-01 09:15", id="9:15"), + pytest.param("2025-10-01 09:41", "2025-10-01 09:30", id="9:41"), + pytest.param("2025-10-01 09:46", "2025-10-01 09:45", id="9:46") ] ) def test_create_unix_timestamp_current_quarter_hour(timestamp, expected, monkeypatch): @@ -164,7 +164,7 @@ def test_create_unix_timestamp_current_quarter_hour(timestamp, expected, monkeyp monkeypatch.setattr(datetime, "datetime", datetime_mock) # execution - qh= timecheck.create_unix_timestamp_current_quarter_hour() + qh = timecheck.create_unix_timestamp_current_quarter_hour() log.debug(f"timestamp: {current_time} , from mock: {datetime.datetime.today().timestamp()}" f" result: {qh}") diff --git a/packages/modules/common/store/_tariff.py b/packages/modules/common/store/_tariff.py index a6509a91c2..da3698ae3f 100644 --- a/packages/modules/common/store/_tariff.py +++ b/packages/modules/common/store/_tariff.py @@ -12,12 +12,15 @@ def __init__(self): def set(self, state: TariffState) -> None: self.state = state - def update(self): + def __update(self): try: pub_to_broker("openWB/set/optional/et/get/prices", self.state.prices) except Exception as e: raise FaultState.from_exception(e) + def update(self): + self.__update(self) + def get_electricity_tariff_value_store() -> ValueStore[TariffState]: return LoggingValueStore(TariffValueStoreBroker()) From e6d488995d4b7203142a01970e875e7f1af6b3a1 Mon Sep 17 00:00:00 2001 From: Thomas Papendieck <14850347+tpd-opitz@users.noreply.github.com> Date: Sat, 4 Oct 2025 22:34:56 +0200 Subject: [PATCH 03/19] expect shorter price time slots in charge time calculation --- packages/conftest.py | 17 +++++- packages/control/optional.py | 58 ++++++++++++++----- packages/control/optional_test.py | 9 ++- packages/helpermodules/timecheck.py | 4 ++ packages/helpermodules/timecheck_test.py | 1 + packages/modules/common/component_state.py | 4 +- .../modules/common/configurable_tariff.py | 28 ++++++--- .../electricity_tariffs/tibber/tariff.py | 29 ++++++---- .../electricity_tariffs/tibber/tariff_test.py | 6 +- 9 files changed, 113 insertions(+), 43 deletions(-) diff --git a/packages/conftest.py b/packages/conftest.py index 959c842090..93ab32c407 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,14 +34,20 @@ 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) 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) + if "no_mock_full_hour" not in request.keywords: + full_hour_timestamp = Mock(return_value=int(datetime.datetime(2022, 5, 16, 8, 0, 0).timestamp())) + monkeypatch.setattr(timecheck, "create_unix_timestamp_current_full_hour", full_hour_timestamp) + if "no_mock_quarter_hour" not in request.keywords: + quarter_hour_timesatmp = Mock(return_value=int(datetime.datetime(2022, 5, 16, 8, 30, 0).timestamp())) + monkeypatch.setattr(timecheck, "create_unix_timestamp_current_quarter_hour", quarter_hour_timesatmp) @pytest.fixture(autouse=True) diff --git a/packages/control/optional.py b/packages/control/optional.py index 96c1e66c57..9518ad8d4f 100644 --- a/packages/control/optional.py +++ b/packages/control/optional.py @@ -1,6 +1,7 @@ """Optionale Module """ import logging +import datetime from math import ceil from threading import Thread from typing import List @@ -11,10 +12,13 @@ from helpermodules import hardware_configuration from helpermodules.constants import NO_ERROR from helpermodules.pub import Pub -from helpermodules.timecheck import create_unix_timestamp_current_full_hour from helpermodules.utils import thread_handler from modules.common.configurable_tariff import ConfigurableElectricityTariff from modules.common.configurable_monitoring import ConfigurableMonitoring +from helpermodules.timecheck import ( + create_unix_timestamp_current_quarter_hour, + create_unix_timestamp_current_full_hour +) log = logging.getLogger(__name__) @@ -64,37 +68,61 @@ def et_charging_allowed(self, max_price: float): log.exception("Fehler im Optional-Modul") return False - def et_get_current_price(self): + def et_get_current_price(self) -> float: if self.et_provider_available(): - return self.data.et.get.prices[str(int(create_unix_timestamp_current_full_hour()))] + prices = self.data.et.get.prices + timestamp, first = next(iter(prices.items())) + log.debug(f"first in prices list: {first} from " + + f"{datetime.datetime.fromtimestamp(int(timestamp)).strftime('%Y-%m-%d %H:%M')}") + return first else: raise Exception("Kein Anbieter für strompreisbasiertes Laden konfiguriert.") + def __calculate_price_timeslot_length(self, prices: dict) -> int: + first_timestamps = list(prices.keys())[:2] + return int(first_timestamps[1]) - int(first_timestamps[0]) + def et_get_loading_hours(self, duration: float, remaining_time: float) -> List[int]: """ Parameter --------- duration: float benötigte Ladezeit - + remaining_time: float + Restzeit bis Termin (von wo an gerechnet???) Return ------ - list: Key des Dictionary (Unix-Sekunden der günstigen Stunden) + list: Key des Dictionary (Unix-Sekunden der günstigen Zeit-Slots) """ if self.et_provider_available() is False: raise Exception("Kein Anbieter für strompreisbasiertes Laden konfiguriert.") try: prices = self.data.et.get.prices - prices_in_scheduled_time = {} - i = 0 - for timestamp, price in prices.items(): - if i < ceil((duration+remaining_time)/3600): - prices_in_scheduled_time.update({timestamp: price}) - i += 1 - else: - break - ordered = sorted(prices_in_scheduled_time.items(), key=lambda x: x[1]) - return [int(i[0]) for i in ordered][:ceil(duration/3600)] + price_timeslot_seconds = self.__calculate_price_timeslot_length(prices) + now = ( + create_unix_timestamp_current_full_hour() + if 3600 == price_timeslot_seconds + else create_unix_timestamp_current_quarter_hour() + ) + + log.debug(f"current full hour: " + f"{int(now)} {datetime.datetime.fromtimestamp(int(now)).strftime('%Y-%m-%d %H:%M')} " + f"Plan target Date: {int(now) + remaining_time} " + f"{datetime.datetime.fromtimestamp(int(now) + remaining_time).strftime('%Y-%m-%d %H:%M')}") + + prices = { + timestamp: price + for timestamp, price in prices.items() + if ( # is current timeslot or futur + int(timestamp) >= int(now) and + # ends before plan target time + int(timestamp) + price_timeslot_seconds <= int(now) + remaining_time + ) + } + log.debug(f"shrinked prices list to {len(prices)} time lots before " + + f"{datetime.datetime.fromtimestamp(int(now) + remaining_time).strftime('%Y-%m-%d %H:%M')}") + ordered_by_price = sorted(prices.items(), key=lambda x: x[1]) + return sorted([int(i[0]) for i in ordered_by_price][:ceil(duration/price_timeslot_seconds)]) except Exception: log.exception("Fehler im Optional-Modul") return [] diff --git a/packages/control/optional_test.py b/packages/control/optional_test.py index cc517ef7ae..ee924a7429 100644 --- a/packages/control/optional_test.py +++ b/packages/control/optional_test.py @@ -1,16 +1,23 @@ from unittest.mock import Mock from control.optional import Optional +import pytest +ONE_HOUR_SECONDS = 3600 + +@pytest.mark.no_mock_full_hour def test_et_get_loading_hours(monkeypatch): # setup opt = Optional() opt.data.et.get.prices = PRICE_LIST mock_et_provider_available = Mock(return_value=True) monkeypatch.setattr(opt, "et_provider_available", mock_et_provider_available) + monkeypatch.setattr("control.optional.create_unix_timestamp_current_full_hour", + Mock(return_value=1698228000)) # execution - loading_hours = opt.et_get_loading_hours(3600, 7200) + loading_hours = opt.et_get_loading_hours(duration=ONE_HOUR_SECONDS, + remaining_time=3 * ONE_HOUR_SECONDS) # evaluation assert loading_hours == [1698231600] diff --git a/packages/helpermodules/timecheck.py b/packages/helpermodules/timecheck.py index adb5d0517a..5bfc47c2ea 100644 --- a/packages/helpermodules/timecheck.py +++ b/packages/helpermodules/timecheck.py @@ -343,3 +343,7 @@ def convert_timestamp_delta_to_time_string(timestamp: int, delta: int) -> str: return f"{minute_diff} Min." elif seconds_diff > 0: return f"{seconds_diff} Sek." + + +def convert_to_timestamp(timestring: str) -> int: + return int(datetime.datetime.fromisoformat(timestring).timestamp()) diff --git a/packages/helpermodules/timecheck_test.py b/packages/helpermodules/timecheck_test.py index a1e60fe794..2523b12c9b 100644 --- a/packages/helpermodules/timecheck_test.py +++ b/packages/helpermodules/timecheck_test.py @@ -145,6 +145,7 @@ def test_convert_timestamp_delta_to_time_string(timestamp, expected): assert time_string == expected +@pytest.mark.no_mock_quarter_hour @pytest.mark.parametrize("timestamp, expected", [ pytest.param("2025-10-01 09:00", "2025-10-01 09:00", id="9:00"), diff --git a/packages/modules/common/component_state.py b/packages/modules/common/component_state.py index 7eb3ff5990..620418000c 100644 --- a/packages/modules/common/component_state.py +++ b/packages/modules/common/component_state.py @@ -232,8 +232,10 @@ def __init__(self, @auto_str class TariffState: def __init__(self, - prices: Optional[Dict[int, float]] = None) -> None: + prices: Optional[Dict[str, float]] = None, + prices_per_hour: int = 24) -> None: self.prices = prices + self.prices_per_hour = prices_per_hour @auto_str diff --git a/packages/modules/common/configurable_tariff.py b/packages/modules/common/configurable_tariff.py index afb933eacd..f42b65bc1c 100644 --- a/packages/modules/common/configurable_tariff.py +++ b/packages/modules/common/configurable_tariff.py @@ -1,6 +1,8 @@ from typing import TypeVar, Generic, Callable -from helpermodules.timecheck import create_unix_timestamp_current_full_hour - +from helpermodules.timecheck import ( + create_unix_timestamp_current_quarter_hour, + create_unix_timestamp_current_full_hour +) from modules.common import store from modules.common.component_context import SingleComponentUpdateContext from modules.common.component_state import TariffState @@ -31,16 +33,26 @@ def update(self): tariff_state = self._remove_outdated_prices(self._component_updater()) self.store.set(tariff_state) self.store.update() - if len(tariff_state.prices) < 24: + expected_time_slots = 24 * tariff_state.prices_per_hour + if len(tariff_state.prices) < expected_time_slots: self.fault_state.no_error( - f'Die Preisliste hat nicht 24, sondern {len(tariff_state.prices)} Einträge. ' + f'Die Preisliste hat nicht {expected_time_slots}, ' + f'sondern {len(tariff_state.prices)} Einträge. ' 'Die Strompreise werden vom Anbieter erst um 14:00 für den Folgetag aktualisiert.') - def _remove_outdated_prices(self, tariff_state: TariffState) -> TariffState: - current_hour = str(int(create_unix_timestamp_current_full_hour())) + def _remove_outdated_prices(self, tariff_state: TariffState, ONE_HOUR_SECONDS: int = 3600) -> TariffState: + first_timestamps = list(tariff_state.prices.keys())[:2] + timeslot_length_seconds = int(first_timestamps[1]) - int(first_timestamps[0]) + is_hourely_prices = ONE_HOUR_SECONDS == timeslot_length_seconds + current_hour = ( + create_unix_timestamp_current_full_hour() + if is_hourely_prices + else create_unix_timestamp_current_quarter_hour() + ) for timestamp in list(tariff_state.prices.keys()): - if timestamp < current_hour: + if int(timestamp) < int(current_hour): self.fault_state.warning( - 'Die Preisliste startet nicht mit der aktuellen Stunde. Abgelaufene Einträge wurden entfernt.') + 'Die Preisliste startet nicht mit der aktuellen Stunde. ' + 'Abgelaufene Einträge wurden entfernt.') tariff_state.prices.pop(timestamp) return tariff_state diff --git a/packages/modules/electricity_tariffs/tibber/tariff.py b/packages/modules/electricity_tariffs/tibber/tariff.py index 33f8b2ae4d..9dd12f7a64 100644 --- a/packages/modules/electricity_tariffs/tibber/tariff.py +++ b/packages/modules/electricity_tariffs/tibber/tariff.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 -from datetime import datetime -from typing import Dict +from typing import Dict, Callable from helpermodules import timecheck import json @@ -9,18 +8,23 @@ from modules.common import req from modules.electricity_tariffs.tibber.config import TibberTariffConfiguration from modules.electricity_tariffs.tibber.config import TibberTariff +import logging +import datetime # Demo-Token: 5K4MVS-OjfWhK_4yrjOlFe1F6kJXPVf7eQYggo8ebAE # Demo Home-ID: 96a14971-525a-4420-aae9-e5aedaa129ff +AS_EURO_PER_Wh = 1000 +log = logging.getLogger(__name__) + def _get_sorted_price_data(response_json: Dict, key: str): return sorted(response_json['data']['viewer']['home']['currentSubscription'] ['priceInfo'][key], key=lambda k: (k['startsAt'], k['total'])) -def fetch_prices(config: TibberTariffConfiguration) -> Dict[int, float]: +def fetch_prices(config: TibberTariffConfiguration) -> Dict[str, float]: headers = {'Authorization': 'Bearer ' + config.token, 'Content-Type': 'application/json'} query = """ query PriceInfo($homeId: ID!) { @@ -48,21 +52,22 @@ def fetch_prices(config: TibberTariffConfiguration) -> Dict[int, float]: today_prices = _get_sorted_price_data(response_json, 'today') tomorrow_prices = _get_sorted_price_data(response_json, 'tomorrow') sorted_market_prices = today_prices + tomorrow_prices - prices: Dict[int, float] = {} - current_hour = timecheck.create_unix_timestamp_current_full_hour() - for price_data in sorted_market_prices: - start_time_epoch = datetime.fromisoformat(price_data['startsAt']).timestamp() - if current_hour <= start_time_epoch: - prices.update({str(int(start_time_epoch)): price_data['total'] / 1000}) + current_hour = timecheck.create_unix_timestamp_current_quarter_hour() + log.debug(f"current full hour: {int(current_hour)} " + f"{datetime.datetime.fromtimestamp(int(current_hour)).strftime('%Y-%m-%d %H:%M')} ") + return { + str(timecheck.convert_to_timestamp(timeslot['startsAt'])): float(timeslot['total']) / AS_EURO_PER_Wh + for timeslot in sorted_market_prices + if timecheck.convert_to_timestamp(timeslot['startsAt']) >= int(current_hour) # is current timeslot or futur + } else: error = response_json['errors'][0]['message'] raise Exception(error) - return prices -def create_electricity_tariff(config: TibberTariff): +def create_electricity_tariff(config: TibberTariff) -> Callable[[], TariffState]: def updater(): - return TariffState(prices=fetch_prices(config.configuration)) + return TariffState(prices=fetch_prices(config.configuration), prices_per_hour=4) return updater diff --git a/packages/modules/electricity_tariffs/tibber/tariff_test.py b/packages/modules/electricity_tariffs/tibber/tariff_test.py index 34e72c5d10..bf5a5d732b 100644 --- a/packages/modules/electricity_tariffs/tibber/tariff_test.py +++ b/packages/modules/electricity_tariffs/tibber/tariff_test.py @@ -2,15 +2,15 @@ from helpermodules import timecheck from modules.electricity_tariffs.tibber.config import TibberTariffConfiguration from modules.electricity_tariffs.tibber.tariff import fetch_prices +import pytest +@pytest.mark.no_mock_quarter_hour def test_fetch_prices(monkeypatch, requests_mock): # setup config = TibberTariffConfiguration(token="5K4MVS-OjfWhK_4yrjOlFe1F6kJXPVf7eQYggo8ebAE", home_id="96a14971-525a-4420-aae9-e5aedaa129ff") - mock_create_unix_timestamp_current_full_hour = Mock(return_value=1698382800) - monkeypatch.setattr(timecheck, "create_unix_timestamp_current_full_hour", - mock_create_unix_timestamp_current_full_hour) + monkeypatch.setattr(timecheck, "create_unix_timestamp_current_quarter_hour", Mock(return_value=1698382800)) requests_mock.post('https://api.tibber.com/v1-beta/gql', json=SAMPLE_DATA) # execution From 46f7daf27f6e727ada870c85cea368f8d55932ed Mon Sep 17 00:00:00 2001 From: Thomas Papendieck <14850347+tpd-opitz@users.noreply.github.com> Date: Mon, 6 Oct 2025 23:59:20 +0200 Subject: [PATCH 04/19] unittests for 15 minuts charging time slots selection mock quarter hour timestamp --- packages/control/optional_test.py | 137 +++++++++++++++++++++++++----- 1 file changed, 115 insertions(+), 22 deletions(-) diff --git a/packages/control/optional_test.py b/packages/control/optional_test.py index ee924a7429..063531ff04 100644 --- a/packages/control/optional_test.py +++ b/packages/control/optional_test.py @@ -3,36 +3,129 @@ import pytest ONE_HOUR_SECONDS = 3600 +IGNORED = 0.0001 +CHEEP = 0.0002 +EXPENSIVE = 0.0003 @pytest.mark.no_mock_full_hour -def test_et_get_loading_hours(monkeypatch): +@pytest.mark.parametrize( + "granularity, now_ts, duration, remaining_time, price_list, expected_loading_hours", + [ + pytest.param( + "full_hour", + 1698228000, + ONE_HOUR_SECONDS, + 3 * ONE_HOUR_SECONDS, + { + "1698224400": 0.00012499, + "1698228000": 0.00011737999999999999, # matching now + "1698231600": 0.00011562000000000001, + "1698235200": 0.00012447, # last before plan target + "1698238800": 0.00013813, + "1698242400": 0.00014751, + "1698246000": 0.00015372999999999998, + "1698249600": 0.00015462, + "1698253200": 0.00015771, + "1698256800": 0.00013708, + "1698260400": 0.00012355, + "1698264000": 0.00012006, + "1698267600": 0.00011279999999999999, + }, + [1698231600], + id="select single time slot of one hour length" + ), + pytest.param( + "quarter_hour", + 1698226200, + 2 * ONE_HOUR_SECONDS, + 4 * ONE_HOUR_SECONDS, + { + # first hour + "1698224400": IGNORED, + "1698225300": IGNORED, + "1698226200": EXPENSIVE, # current quarert hour + "1698227100": CHEEP, + # second hour + "1698228000": EXPENSIVE, + "1698228900": EXPENSIVE, + "1698229800": CHEEP, + "1698230700": EXPENSIVE, + # third hour + "1698231600": CHEEP, + "1698232500": CHEEP, + "1698233400": CHEEP, + "1698234300": EXPENSIVE, + # fourth hour + "1698235200": CHEEP, + "1698236100": EXPENSIVE, + "1698237000": EXPENSIVE, + "1698237900": EXPENSIVE, + # fifth hour + "1698238800": CHEEP, + "1698239700": CHEEP, # last before plan target + "1698240600": IGNORED, + "1698241500": IGNORED, + }, + [1698227100, 1698229800, 1698231600, 1698232500, 1698233400, 1698235200, 1698238800, 1698239700], + id="select 8 time slots of 15 minutes lenght, include last before plan target" + ), + pytest.param( + "quarter_hour", + 1698227100, + 2 * ONE_HOUR_SECONDS, + 4 * ONE_HOUR_SECONDS, + { + # first hour + "1698224400": IGNORED, + "1698225300": IGNORED, + "1698226200": EXPENSIVE, + "1698227100": CHEEP, # current quarert hour + # second hour + "1698228000": EXPENSIVE, + "1698228900": EXPENSIVE, + "1698229800": CHEEP, + "1698230700": EXPENSIVE, + # third hour + "1698231600": CHEEP, + "1698232500": CHEEP, + "1698233400": CHEEP, + "1698234300": EXPENSIVE, + # fourth hour + "1698235200": CHEEP, + "1698236100": EXPENSIVE, + "1698237000": EXPENSIVE, + "1698237900": EXPENSIVE, + # fifth hour + "1698238800": CHEEP, + "1698239700": CHEEP, + "1698240600": EXPENSIVE, # last before plan target + "1698241500": IGNORED, + }, + [1698227100, 1698229800, 1698231600, 1698232500, 1698233400, 1698235200, 1698238800, 1698239700], + id="select 8 time slots of 15 minutes lenght, include current quarter hour" + ), + ], +) +def test_et_get_loading_hours(granularity, + now_ts, + duration, + remaining_time, + price_list, + expected_loading_hours, + monkeypatch): # setup opt = Optional() - opt.data.et.get.prices = PRICE_LIST + opt.data.et.get.prices = price_list mock_et_provider_available = Mock(return_value=True) monkeypatch.setattr(opt, "et_provider_available", mock_et_provider_available) - monkeypatch.setattr("control.optional.create_unix_timestamp_current_full_hour", - Mock(return_value=1698228000)) + monkeypatch.setattr( + f"control.optional.create_unix_timestamp_current_{granularity}", + Mock(return_value=now_ts) + ) # execution - loading_hours = opt.et_get_loading_hours(duration=ONE_HOUR_SECONDS, - remaining_time=3 * ONE_HOUR_SECONDS) + loading_hours = opt.et_get_loading_hours(duration=duration, remaining_time=remaining_time) # evaluation - assert loading_hours == [1698231600] - - -PRICE_LIST = {"1698224400": 0.00012499, - "1698228000": 0.00011737999999999999, - "1698231600": 0.00011562000000000001, - "1698235200": 0.00012447, - "1698238800": 0.00013813, - "1698242400": 0.00014751, - "1698246000": 0.00015372999999999998, - "1698249600": 0.00015462, - "1698253200": 0.00015771, - "1698256800": 0.00013708, - "1698260400": 0.00012355, - "1698264000": 0.00012006, - "1698267600": 0.00011279999999999999} + assert loading_hours == expected_loading_hours From 8e9a90b6b63985ab9246f6318a779d57784fc4f0 Mon Sep 17 00:00:00 2001 From: Thomas aus Welkers <14850347+tpd-opitz@users.noreply.github.com> Date: Thu, 9 Oct 2025 01:29:09 +0200 Subject: [PATCH 05/19] enable shorter update cycle of ET price data --- packages/conftest.py | 8 +- packages/control/optional.py | 66 ++++++---- packages/control/optional_test.py | 75 ++++++++++- packages/helpermodules/timecheck.py | 15 +-- packages/helpermodules/timecheck_test.py | 30 ----- packages/main.py | 6 +- packages/modules/common/component_state.py | 5 +- .../modules/common/configurable_tariff.py | 122 +++++++++++++---- .../common/configurable_tariff_test.py | 57 +++++++- packages/modules/common/store/_tariff.py | 13 +- .../electricity_tariffs/tibber/tariff.py | 32 ++--- .../electricity_tariffs/tibber/tariff_test.py | 124 ++++++++++-------- 12 files changed, 363 insertions(+), 190 deletions(-) diff --git a/packages/conftest.py b/packages/conftest.py index 93ab32c407..a9b00baedf 100644 --- a/packages/conftest.py +++ b/packages/conftest.py @@ -42,12 +42,8 @@ def mock_today(monkeypatch, request) -> None: monkeypatch.setattr(datetime, "datetime", datetime_mock) now_timestamp = Mock(return_value=1652683252) monkeypatch.setattr(timecheck, "create_timestamp", now_timestamp) - if "no_mock_full_hour" not in request.keywords: - full_hour_timestamp = Mock(return_value=int(datetime.datetime(2022, 5, 16, 8, 0, 0).timestamp())) - monkeypatch.setattr(timecheck, "create_unix_timestamp_current_full_hour", full_hour_timestamp) - if "no_mock_quarter_hour" not in request.keywords: - quarter_hour_timesatmp = Mock(return_value=int(datetime.datetime(2022, 5, 16, 8, 30, 0).timestamp())) - monkeypatch.setattr(timecheck, "create_unix_timestamp_current_quarter_hour", quarter_hour_timesatmp) + full_hour_timestamp = Mock(return_value=int(datetime.datetime(2022, 5, 16, 8, 0, 0).timestamp())) + monkeypatch.setattr(timecheck, "create_unix_timestamp_current_full_hour", full_hour_timestamp) @pytest.fixture(autouse=True) diff --git a/packages/control/optional.py b/packages/control/optional.py index 9518ad8d4f..78f02ca335 100644 --- a/packages/control/optional.py +++ b/packages/control/optional.py @@ -1,7 +1,6 @@ """Optionale Module """ import logging -import datetime from math import ceil from threading import Thread from typing import List @@ -12,13 +11,10 @@ from helpermodules import hardware_configuration from helpermodules.constants import NO_ERROR from helpermodules.pub import Pub +from helpermodules import timecheck from helpermodules.utils import thread_handler from modules.common.configurable_tariff import ConfigurableElectricityTariff from modules.common.configurable_monitoring import ConfigurableMonitoring -from helpermodules.timecheck import ( - create_unix_timestamp_current_quarter_hour, - create_unix_timestamp_current_full_hour -) log = logging.getLogger(__name__) @@ -68,16 +64,31 @@ def et_charging_allowed(self, max_price: float): log.exception("Fehler im Optional-Modul") return False - def et_get_current_price(self) -> float: + def __get_first_entry(self, prices: dict[str, float]) -> tuple[str, float]: if self.et_provider_available(): prices = self.data.et.get.prices timestamp, first = next(iter(prices.items())) - log.debug(f"first in prices list: {first} from " + - f"{datetime.datetime.fromtimestamp(int(timestamp)).strftime('%Y-%m-%d %H:%M')}") - return first + price_timeslot_seconds = self.__calculate_price_timeslot_length(prices) + now = int(timecheck.create_timestamp()) + prices = { + price[0]: price[1] + for price in prices.items() + if int(price[0]) > now - (price_timeslot_seconds - 1) + } + self.data.et.get.prices = prices + timestamp, first = next(iter(prices.items())) + return timestamp, first else: raise Exception("Kein Anbieter für strompreisbasiertes Laden konfiguriert.") + def __get_current_timeslot_start(self, prices: dict[str, float]) -> float: + timestamp, first = self.__get_first_entry(prices) + return timestamp + + def et_get_current_price(self, prices: dict[str, float]) -> float: + timestamp, first = self.__get_first_entry(prices) + return first + def __calculate_price_timeslot_length(self, prices: dict) -> int: first_timestamps = list(prices.keys())[:2] return int(first_timestamps[1]) - int(first_timestamps[0]) @@ -99,30 +110,29 @@ def et_get_loading_hours(self, duration: float, remaining_time: float) -> List[i try: prices = self.data.et.get.prices price_timeslot_seconds = self.__calculate_price_timeslot_length(prices) - now = ( - create_unix_timestamp_current_full_hour() - if 3600 == price_timeslot_seconds - else create_unix_timestamp_current_quarter_hour() - ) - - log.debug(f"current full hour: " - f"{int(now)} {datetime.datetime.fromtimestamp(int(now)).strftime('%Y-%m-%d %H:%M')} " - f"Plan target Date: {int(now) + remaining_time} " - f"{datetime.datetime.fromtimestamp(int(now) + remaining_time).strftime('%Y-%m-%d %H:%M')}") - - prices = { + first_timeslot_start = self.__get_current_timeslot_start(prices) + price_candidates = { timestamp: price for timestamp, price in prices.items() - if ( # is current timeslot or futur - int(timestamp) >= int(now) and + if ( + # is current timeslot or futur + int(timestamp) >= int(first_timeslot_start) and # ends before plan target time - int(timestamp) + price_timeslot_seconds <= int(now) + remaining_time + int(timestamp) + price_timeslot_seconds <= int(first_timeslot_start) + remaining_time ) } - log.debug(f"shrinked prices list to {len(prices)} time lots before " + - f"{datetime.datetime.fromtimestamp(int(now) + remaining_time).strftime('%Y-%m-%d %H:%M')}") - ordered_by_price = sorted(prices.items(), key=lambda x: x[1]) - return sorted([int(i[0]) for i in ordered_by_price][:ceil(duration/price_timeslot_seconds)]) + now = int(timecheck.create_timestamp()) + ordered_by_date_reverse = reversed(sorted(price_candidates.items(), key=lambda x: x[0])) + ordered_by_price = sorted(ordered_by_date_reverse, key=lambda x: x[1]) + selected_time_slots = {int(i[0]): float(i[1]) + for i in ordered_by_price[:1 + ceil(duration/price_timeslot_seconds)]} + selected_lenght = price_timeslot_seconds * ( + len(selected_time_slots)-1) - (int(now) - min(selected_time_slots)) + return sorted(selected_time_slots.keys() + if not (min(selected_time_slots) > now or duration <= selected_lenght) + else [timestamp[0] for timestamp in iter(selected_time_slots.items())][:-1] + ) + # if sum() sorted([int(i[0]) for i in ordered_by_price][:ceil(duration/price_timeslot_seconds)]) except Exception: log.exception("Fehler im Optional-Modul") return [] diff --git a/packages/control/optional_test.py b/packages/control/optional_test.py index 063531ff04..3784b99b2d 100644 --- a/packages/control/optional_test.py +++ b/packages/control/optional_test.py @@ -1,5 +1,6 @@ from unittest.mock import Mock from control.optional import Optional +from helpermodules import timecheck import pytest ONE_HOUR_SECONDS = 3600 @@ -105,6 +106,77 @@ [1698227100, 1698229800, 1698231600, 1698232500, 1698233400, 1698235200, 1698238800, 1698239700], id="select 8 time slots of 15 minutes lenght, include current quarter hour" ), + pytest.param( + "quarter_hour", + 1698227900, + 2 * ONE_HOUR_SECONDS, + 4 * ONE_HOUR_SECONDS, + { + # first hour + "1698224400": IGNORED, + "1698225300": IGNORED, + "1698226200": EXPENSIVE, + "1698227100": CHEEP, # current quarert hour + # second hour + "1698228000": EXPENSIVE, + "1698228900": EXPENSIVE, + "1698229800": CHEEP, + "1698230700": EXPENSIVE, + # third hour + "1698231600": CHEEP, + "1698232500": CHEEP, + "1698233400": CHEEP, + "1698234300": EXPENSIVE, + # fourth hour + "1698235200": CHEEP, + "1698236100": EXPENSIVE, + "1698237000": EXPENSIVE, + "1698237900": EXPENSIVE, + # fifth hour + "1698238800": CHEEP, + "1698239700": CHEEP, + "1698240600": EXPENSIVE, # last before plan target + "1698241500": IGNORED, + }, + [1698227100, 1698229800, 1698231600, 1698232500, + 1698233400, 1698235200, 1698238800, 1698239700, 1698240600], + id="select additional if time elapsed in current slot makes selection too short" + ), + pytest.param( + "quarter_hour", + 1698226600, + 2 * ONE_HOUR_SECONDS, + 4 * ONE_HOUR_SECONDS, + { + # first hour + "1698224400": IGNORED, + "1698225300": IGNORED, + "1698226200": EXPENSIVE, + "1698227100": CHEEP, # current quarert hour + # second hour + "1698228000": EXPENSIVE, + "1698228900": EXPENSIVE, + "1698229800": CHEEP, + "1698230700": EXPENSIVE, + # third hour + "1698231600": CHEEP, + "1698232500": CHEEP, + "1698233400": CHEEP, + "1698234300": EXPENSIVE, + # fourth hour + "1698235200": CHEEP, + "1698236100": EXPENSIVE, + "1698237000": EXPENSIVE, + "1698237900": EXPENSIVE, + # fifth hour + "1698238800": EXPENSIVE, + "1698239700": EXPENSIVE, + "1698240600": EXPENSIVE, # last before plan target + "1698241500": IGNORED, + }, + [1698227100, 1698229800, 1698231600, 1698232500, 1698233400, 1698235200, 1698238800, 1698239700], + id="select latest if most expensive candidates have same price" + ), ], ) def test_et_get_loading_hours(granularity, @@ -120,7 +192,8 @@ def test_et_get_loading_hours(granularity, mock_et_provider_available = Mock(return_value=True) monkeypatch.setattr(opt, "et_provider_available", mock_et_provider_available) monkeypatch.setattr( - f"control.optional.create_unix_timestamp_current_{granularity}", + timecheck, + "create_timestamp", Mock(return_value=now_ts) ) diff --git a/packages/helpermodules/timecheck.py b/packages/helpermodules/timecheck.py index 5bfc47c2ea..1d9437313b 100644 --- a/packages/helpermodules/timecheck.py +++ b/packages/helpermodules/timecheck.py @@ -236,13 +236,6 @@ def create_unix_timestamp_current_full_hour() -> int: return int(datetime.datetime.strptime(full_hour, "%m/%d/%Y, %H").timestamp()) -def create_unix_timestamp_current_quarter_hour() -> int: - def round_to_quarter_hour(current_time: float, quarter_hour: int = 900) -> float: - log.debug(f"current time: {current_time} => modified: {current_time - (current_time % quarter_hour)}") - return current_time - (current_time % quarter_hour) - return int(round_to_quarter_hour(datetime.datetime.today().timestamp())) - - def get_relative_date_string(date_string: str, day_offset: int = 0, month_offset: int = 0, year_offset: int = 0) -> str: print_format = "%Y%m%d" if len(date_string) > 6 else "%Y%m" my_date = datetime.datetime.strptime(date_string, print_format) @@ -307,14 +300,14 @@ def duration_sum(first: str, second: str) -> str: return "00:00" -def __get_timedelta_obj(time: str) -> datetime.timedelta: +def __get_timedelta_obj(time_str: str) -> datetime.timedelta: """ erstellt aus einem String ein timedelta-Objekt. Parameter --------- - time: str + time_str: str Zeitstrings HH:MM ggf DD:HH:MM """ - time_charged = time.split(":") + time_charged = time_str.split(":") if len(time_charged) == 2: delta = datetime.timedelta(hours=int(time_charged[0]), minutes=int(time_charged[1])) @@ -323,7 +316,7 @@ def __get_timedelta_obj(time: str) -> datetime.timedelta: hours=int(time_charged[1]), minutes=int(time_charged[2])) else: - raise Exception("Unknown charge duration: "+time) + raise Exception(f"Unknown charge duration: {time_str}") return delta diff --git a/packages/helpermodules/timecheck_test.py b/packages/helpermodules/timecheck_test.py index 2523b12c9b..8f559705aa 100644 --- a/packages/helpermodules/timecheck_test.py +++ b/packages/helpermodules/timecheck_test.py @@ -143,33 +143,3 @@ def test_convert_timestamp_delta_to_time_string(timestamp, expected): # evaluation assert time_string == expected - - -@pytest.mark.no_mock_quarter_hour -@pytest.mark.parametrize("timestamp, expected", - [ - pytest.param("2025-10-01 09:00", "2025-10-01 09:00", id="9:00"), - pytest.param("2025-10-01 09:01", "2025-10-01 09:00", id="9:01"), - pytest.param("2025-10-01 09:10", "2025-10-01 09:00", id="9:10"), - pytest.param("2025-10-01 09:14", "2025-10-01 09:00", id="9:14"), - pytest.param("2025-10-01 09:15", "2025-10-01 09:15", id="9:15"), - pytest.param("2025-10-01 09:41", "2025-10-01 09:30", id="9:41"), - pytest.param("2025-10-01 09:46", "2025-10-01 09:45", id="9:46") - ] - ) -def test_create_unix_timestamp_current_quarter_hour(timestamp, expected, monkeypatch): - # setup - datetime_mock = MagicMock(wraps=datetime.datetime) - current_time = datetime.datetime.strptime(timestamp, "%Y-%m-%d %H:%M") - datetime_mock.today.return_value = current_time - monkeypatch.setattr(datetime, "datetime", datetime_mock) - - # execution - qh = timecheck.create_unix_timestamp_current_quarter_hour() - log.debug(f"timestamp: {current_time} , from mock: {datetime.datetime.today().timestamp()}" - f" result: {qh}") - - current_quarter_hour = datetime.datetime.fromtimestamp(qh).strftime("%Y-%m-%d %H:%M") - - # evaluation - assert current_quarter_hour == expected diff --git a/packages/main.py b/packages/main.py index 1945cf890d..40a6de5063 100755 --- a/packages/main.py +++ b/packages/main.py @@ -220,6 +220,8 @@ def handler5Min(self): general_internal_chargepoint_handler.internal_chargepoint_handler.heartbeat = False with ChangedValuesContext(loadvars_.event_module_update_completed): sub.system_data["system"].update_ip_address() + + data.data.optional_data.et_get_prices() except Exception: log.exception("Fehler im Main-Modul") @@ -250,7 +252,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 620418000c..0da5d3ddda 100644 --- a/packages/modules/common/component_state.py +++ b/packages/modules/common/component_state.py @@ -232,10 +232,9 @@ def __init__(self, @auto_str class TariffState: def __init__(self, - prices: Optional[Dict[str, float]] = None, - prices_per_hour: int = 24) -> None: + prices: Optional[Dict[str, float]] = None + ) -> None: self.prices = prices - self.prices_per_hour = prices_per_hour @auto_str diff --git a/packages/modules/common/configurable_tariff.py b/packages/modules/common/configurable_tariff.py index f42b65bc1c..5039361ead 100644 --- a/packages/modules/common/configurable_tariff.py +++ b/packages/modules/common/configurable_tariff.py @@ -1,8 +1,8 @@ from typing import TypeVar, Generic, Callable -from helpermodules.timecheck import ( - create_unix_timestamp_current_quarter_hour, - create_unix_timestamp_current_full_hour -) +from datetime import datetime, timedelta +from helpermodules import timecheck +import random +import logging from modules.common import store from modules.common.component_context import SingleComponentUpdateContext from modules.common.component_state import TariffState @@ -11,12 +11,16 @@ T_TARIFF_CONFIG = TypeVar("T_TARIFF_CONFIG") +ONE_HOUR_SECONDS: int = 3600 +log = logging.getLogger(__name__) class ConfigurableElectricityTariff(Generic[T_TARIFF_CONFIG]): def __init__(self, config: T_TARIFF_CONFIG, component_initializer: Callable[[], float]) -> None: + self.__next_query_time = datetime.fromtimestamp(1) + self.__tariff_state: TariffState = None self.config = config self.store = store.get_electricity_tariff_value_store() self.fault_state = FaultState(ComponentInfo(None, self.config.name, ComponentType.ELECTRICITY_TARIFF.value)) @@ -28,31 +32,91 @@ def __init__(self, def update(self): if hasattr(self, "_component_updater"): - # Wenn beim Initialisieren etwas schief gelaufen ist, ursprüngliche Fehlermeldung beibehalten with SingleComponentUpdateContext(self.fault_state): - tariff_state = self._remove_outdated_prices(self._component_updater()) - self.store.set(tariff_state) - self.store.update() - expected_time_slots = 24 * tariff_state.prices_per_hour - if len(tariff_state.prices) < expected_time_slots: - self.fault_state.no_error( - f'Die Preisliste hat nicht {expected_time_slots}, ' - f'sondern {len(tariff_state.prices)} Einträge. ' - 'Die Strompreise werden vom Anbieter erst um 14:00 für den Folgetag aktualisiert.') - - def _remove_outdated_prices(self, tariff_state: TariffState, ONE_HOUR_SECONDS: int = 3600) -> TariffState: - first_timestamps = list(tariff_state.prices.keys())[:2] - timeslot_length_seconds = int(first_timestamps[1]) - int(first_timestamps[0]) - is_hourely_prices = ONE_HOUR_SECONDS == timeslot_length_seconds - current_hour = ( - create_unix_timestamp_current_full_hour() - if is_hourely_prices - else create_unix_timestamp_current_quarter_hour() - ) - for timestamp in list(tariff_state.prices.keys()): - if int(timestamp) < int(current_hour): + self.__query_et_provider_data_once_per_day() + timeslot_length_seconds = self.__calculate_price_timeslot_length() + self.__tariff_state = self._remove_outdated_prices(self.__tariff_state, timeslot_length_seconds) + self.__store_and_publish_updated_data() + self.__log_and_publish_progress(timeslot_length_seconds) + + def __query_et_provider_data_once_per_day(self): + if datetime.now() > self.__next_query_time: + log.info(f'Wartezeit {self.__next_query_time.strftime("%Y%m%d-%H:%M:%S")}' + ' abgelaufen, Strompreise werden abgefragt') + try: + last_known_timestamp = self.__get_last_entry_time_stamp() + self.__tariff_state = self._component_updater() + if last_known_timestamp < (max(self.__tariff_state.prices)): + self.__calulate_next_query_time() + else: + log.info('Keine Daten für morgen erhalten, weiterer Versuch in 5 Minuten') + except Exception as e: self.fault_state.warning( - 'Die Preisliste startet nicht mit der aktuellen Stunde. ' - 'Abgelaufene Einträge wurden entfernt.') - tariff_state.prices.pop(timestamp) + f'Fehler beim Abruf der Strompreise: {e}, nächster Versuch in 5 Minuten.' + ) + log.info(f'Nächster Abruf der Strompreise frühestens {self.__next_query_time.strftime("%Y%m%d-%H:%M:%S")}.') + + 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(self.__next_query_time) + else 'morgen ' + ) + return ( + f'frühestens {tomorrow}{self.__next_query_time.strftime("%H:%M")}' + if datetime.now() < self.__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(self.__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(self.__tariff_state.prices) < expected_time_slots + else '' + ) + + def __store_and_publish_updated_data(self): + self.store.set(self.__tariff_state) + self.store.update() + + 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 trying early and randomizing query time + minutes=random.randint(-30, -10), + seconds=random.randint(0, 59) + ) + if datetime.now() > self.__next_query_time: + self.__next_query_time += timedelta(days=1) + + 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 __get_last_entry_time_stamp(self) -> str: + last_known_timestamp = "0" + if self.__tariff_state is not None: + last_known_timestamp = max(self.__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..eddb53fe7b 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,73 @@ @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 + + +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 da3698ae3f..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]): @@ -12,15 +16,14 @@ def __init__(self): def set(self, state: TariffState) -> None: self.state = state - def __update(self): + def update(self): try: - pub_to_broker("openWB/set/optional/et/get/prices", self.state.prices) + prices = self.state.prices + pub_to_broker("openWB/set/optional/et/get/prices", prices) + log.debug(f"published prices list to MQTT having {len(prices)} entries") except Exception as e: raise FaultState.from_exception(e) - def update(self): - self.__update(self) - def 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 9dd12f7a64..973ea876cc 100644 --- a/packages/modules/electricity_tariffs/tibber/tariff.py +++ b/packages/modules/electricity_tariffs/tibber/tariff.py @@ -1,15 +1,13 @@ #!/usr/bin/env python3 -from typing import Dict, Callable +from typing import Callable from helpermodules import timecheck -import json - from modules.common.abstract_device import DeviceDescriptor from modules.common.component_state import TariffState from modules.common import req from modules.electricity_tariffs.tibber.config import TibberTariffConfiguration from modules.electricity_tariffs.tibber.config import TibberTariff import logging -import datetime +import json # Demo-Token: 5K4MVS-OjfWhK_4yrjOlFe1F6kJXPVf7eQYggo8ebAE @@ -19,12 +17,15 @@ log = logging.getLogger(__name__) -def _get_sorted_price_data(response_json: Dict, key: str): - return sorted(response_json['data']['viewer']['home']['currentSubscription'] - ['priceInfo'][key], key=lambda k: (k['startsAt'], k['total'])) +def _get_sorted_price_data(response_json: dict, day: str) -> dict[str, float]: + prices = sorted(response_json['data']['viewer']['home']['currentSubscription'] + ['priceInfo'][day], key=lambda k: (k['startsAt'], k['total'])) + return {str(timecheck.convert_to_timestamp(price['startsAt'])): # timestamp + float(price['total']) / AS_EURO_PER_Wh # prices + for price in prices} -def fetch_prices(config: TibberTariffConfiguration) -> Dict[str, float]: +def fetch_prices(config: TibberTariffConfiguration) -> dict[str, float]: headers = {'Authorization': 'Bearer ' + config.token, 'Content-Type': 'application/json'} query = """ query PriceInfo($homeId: ID!) { @@ -48,18 +49,13 @@ def fetch_prices(config: TibberTariffConfiguration) -> Dict[str, float]: data = json.dumps(payload) response = req.get_http_session().post('https://api.tibber.com/v1-beta/gql', headers=headers, data=data, timeout=6) response_json = response.json() + log.debug(json.dumps(response_json)) if response_json.get("errors") is None: today_prices = _get_sorted_price_data(response_json, 'today') tomorrow_prices = _get_sorted_price_data(response_json, 'tomorrow') - sorted_market_prices = today_prices + tomorrow_prices - current_hour = timecheck.create_unix_timestamp_current_quarter_hour() - log.debug(f"current full hour: {int(current_hour)} " - f"{datetime.datetime.fromtimestamp(int(current_hour)).strftime('%Y-%m-%d %H:%M')} ") - return { - str(timecheck.convert_to_timestamp(timeslot['startsAt'])): float(timeslot['total']) / AS_EURO_PER_Wh - for timeslot in sorted_market_prices - if timecheck.convert_to_timestamp(timeslot['startsAt']) >= int(current_hour) # is current timeslot or futur - } + sorted_market_prices = {**today_prices, **tomorrow_prices} + log.debug(f"tibber response: {sorted_market_prices}") + return sorted_market_prices else: error = response_json['errors'][0]['message'] raise Exception(error) @@ -67,7 +63,7 @@ def fetch_prices(config: TibberTariffConfiguration) -> Dict[str, float]: def create_electricity_tariff(config: TibberTariff) -> Callable[[], TariffState]: def updater(): - return TariffState(prices=fetch_prices(config.configuration), prices_per_hour=4) + return TariffState(prices=fetch_prices(config.configuration)) return updater diff --git a/packages/modules/electricity_tariffs/tibber/tariff_test.py b/packages/modules/electricity_tariffs/tibber/tariff_test.py index bf5a5d732b..8e9fa6983f 100644 --- a/packages/modules/electricity_tariffs/tibber/tariff_test.py +++ b/packages/modules/electricity_tariffs/tibber/tariff_test.py @@ -1,64 +1,59 @@ from unittest.mock import Mock +from datetime import datetime from helpermodules import timecheck from modules.electricity_tariffs.tibber.config import TibberTariffConfiguration from modules.electricity_tariffs.tibber.tariff import fetch_prices import pytest -@pytest.mark.no_mock_quarter_hour -def test_fetch_prices(monkeypatch, requests_mock): - # setup - config = TibberTariffConfiguration(token="5K4MVS-OjfWhK_4yrjOlFe1F6kJXPVf7eQYggo8ebAE", - home_id="96a14971-525a-4420-aae9-e5aedaa129ff") - monkeypatch.setattr(timecheck, "create_unix_timestamp_current_quarter_hour", Mock(return_value=1698382800)) - requests_mock.post('https://api.tibber.com/v1-beta/gql', json=SAMPLE_DATA) - - # execution - prices = fetch_prices(config) - - # evaluation - assert prices == EXPECTED_PRICES - - -SAMPLE_DATA = {"data": - {"viewer": - {"home": - {"currentSubscription": - {"priceInfo": - {"today": [{"total": 0.8724, "startsAt": "2023-10-27T00:00:00.000+02:00"}, - {"total": 0.8551, "startsAt": "2023-10-27T01:00:00.000+02:00"}, - {"total": 0.8552, "startsAt": "2023-10-27T01:15:00.000+02:00"}, - {"total": 0.8553, "startsAt": "2023-10-27T01:30:00.000+02:00"}, - {"total": 0.8554, "startsAt": "2023-10-27T01:45:00.000+02:00"}, - {"total": 0.8389, "startsAt": "2023-10-27T02:00:00.000+02:00"}, - {"total": 0.8189, "startsAt": "2023-10-27T03:00:00.000+02:00"}, - {"total": 0.8544, "startsAt": "2023-10-27T04:00:00.000+02:00"}, - {"total": 0.8473, "startsAt": "2023-10-27T05:00:00.000+02:00"}, - {"total": 1.1724, "startsAt": "2023-10-27T06:00:00.000+02:00"}, - {"total": 1.6862, "startsAt": "2023-10-27T07:00:00.000+02:00"}, - {"total": 2.3264, "startsAt": "2023-10-27T08:00:00.000+02:00"}, - {"total": 2.3024, "startsAt": "2023-10-27T09:00:00.000+02:00"}, - {"total": 2.3124, "startsAt": "2023-10-27T09:15:00.000+02:00"}, - {"total": 2.3224, "startsAt": "2023-10-27T09:30:00.000+02:00"}, - {"total": 2.3324, "startsAt": "2023-10-27T09:45:00.000+02:00"}, - {"total": 1.7204, "startsAt": "2023-10-27T10:00:00.000+02:00"}, - {"total": 1.7213, "startsAt": "2023-10-27T11:00:00.000+02:00"}, - {"total": 1.8697, "startsAt": "2023-10-27T12:00:00.000+02:00"}, - {"total": 1.69, "startsAt": "2023-10-27T13:00:00.000+02:00"}, - {"total": 1.5725, "startsAt": "2023-10-27T14:00:00.000+02:00"}, - {"total": 1.2997, "startsAt": "2023-10-27T15:00:00.000+02:00"}, - {"total": 1.4289, "startsAt": "2023-10-27T16:00:00.000+02:00"}, - {"total": 1.9176, "startsAt": "2023-10-27T17:00:00.000+02:00"}, - {"total": 2.1175, "startsAt": "2023-10-27T18:00:00.000+02:00"}, - {"total": 1.8902, "startsAt": "2023-10-27T19:00:00.000+02:00"}, - {"total": 1.2104, "startsAt": "2023-10-27T20:00:00.000+02:00"}, - {"total": 1.1332, "startsAt": "2023-10-27T21:00:00.000+02:00"}, - {"total": 0.8015, "startsAt": "2023-10-27T22:00:00.000+02:00"}, - {"total": 0.7698, "startsAt": "2023-10-27T23:00:00.000+02:00"} - ], - "tomorrow": []}}}}}} +SAMPLE_DATA_TODAY = { + "data": { + "viewer": { + "home": {"currentSubscription": { + "priceInfo": {"today": [ + {"total": 0.8724, "startsAt": "2023-10-27T00:00:00.000+02:00"}, + {"total": 0.8551, "startsAt": "2023-10-27T01:00:00.000+02:00"}, + {"total": 0.8552, "startsAt": "2023-10-27T01:15:00.000+02:00"}, + {"total": 0.8553, "startsAt": "2023-10-27T01:30:00.000+02:00"}, + {"total": 0.8554, "startsAt": "2023-10-27T01:45:00.000+02:00"}, + {"total": 0.8389, "startsAt": "2023-10-27T02:00:00.000+02:00"}, + {"total": 0.8189, "startsAt": "2023-10-27T03:00:00.000+02:00"}, + {"total": 0.8544, "startsAt": "2023-10-27T04:00:00.000+02:00"}, + {"total": 0.8473, "startsAt": "2023-10-27T05:00:00.000+02:00"}, + {"total": 1.1724, "startsAt": "2023-10-27T06:00:00.000+02:00"}, + {"total": 1.6862, "startsAt": "2023-10-27T07:00:00.000+02:00"}, + {"total": 2.3264, "startsAt": "2023-10-27T08:00:00.000+02:00"}, + {"total": 2.3024, "startsAt": "2023-10-27T09:00:00.000+02:00"}, + {"total": 2.3124, "startsAt": "2023-10-27T09:15:00.000+02:00"}, + {"total": 2.3224, "startsAt": "2023-10-27T09:30:00.000+02:00"}, + {"total": 2.3324, "startsAt": "2023-10-27T09:45:00.000+02:00"}, + {"total": 1.7204, "startsAt": "2023-10-27T10:00:00.000+02:00"}, + {"total": 1.7213, "startsAt": "2023-10-27T11:00:00.000+02:00"}, + {"total": 1.8697, "startsAt": "2023-10-27T12:00:00.000+02:00"}, + {"total": 1.69, "startsAt": "2023-10-27T13:00:00.000+02:00"}, + {"total": 1.5725, "startsAt": "2023-10-27T14:00:00.000+02:00"}, + {"total": 1.2997, "startsAt": "2023-10-27T15:00:00.000+02:00"}, + {"total": 1.4289, "startsAt": "2023-10-27T16:00:00.000+02:00"}, + {"total": 1.9176, "startsAt": "2023-10-27T17:00:00.000+02:00"}, + {"total": 2.1175, "startsAt": "2023-10-27T18:00:00.000+02:00"}, + {"total": 1.8902, "startsAt": "2023-10-27T19:00:00.000+02:00"}, + {"total": 1.2104, "startsAt": "2023-10-27T20:00:00.000+02:00"}, + {"total": 1.1332, "startsAt": "2023-10-27T21:00:00.000+02:00"}, + {"total": 0.8015, "startsAt": "2023-10-27T22:00:00.000+02:00"}, + {"total": 0.7698, "startsAt": "2023-10-27T23:00:00.000+02:00"} + ], "tomorrow": []}}}}}} -EXPECTED_PRICES = { +EXPECTED_PRICES_TODAY = { + '1698357600': 0.0008724, + '1698361200': 0.0008551, + '1698362100': 0.0008552, + '1698363000': 0.0008552999999999999, + '1698363900': 0.0008554000000000001, + '1698364800': 0.0008389, + '1698368400': 0.0008189, + '1698372000': 0.0008544000000000001, + '1698375600': 0.0008473, + '1698379200': 0.0011724, "1698382800": 0.0016862, "1698386400": 0.0023264, "1698390000": 0.0023024, @@ -80,3 +75,26 @@ def test_fetch_prices(monkeypatch, requests_mock): "1698436800": 0.0008015, "1698440400": 0.0007698000000000001, } + + +@pytest.mark.parametrize( + "now, tibber_response, expected", + [ + pytest.param(datetime.fromisoformat('2023-10-27T07:00:00.000+02:00'), + SAMPLE_DATA_TODAY, + EXPECTED_PRICES_TODAY, + id="return all prices, outdated will be filtered in ConfigurableElectricityTariff"), + ] +) +def test_fetch_prices(now, tibber_response, expected, monkeypatch, requests_mock): + # setup + config = TibberTariffConfiguration(token="5K4MVS-OjfWhK_4yrjOlFe1F6kJXPVf7eQYggo8ebAE", + home_id="96a14971-525a-4420-aae9-e5aedaa129ff") + monkeypatch.setattr(timecheck, "create_timestamp", Mock(return_value=int(now.timestamp()))) + requests_mock.post('https://api.tibber.com/v1-beta/gql', json=tibber_response) + + # execution + prices = fetch_prices(config) + + # evaluation + assert prices == expected From 7486511d64cabe77d5e49e98ce65a636c9bb59b0 Mon Sep 17 00:00:00 2001 From: Thomas aus Welkers <14850347+tpd-opitz@users.noreply.github.com> Date: Thu, 16 Oct 2025 11:07:18 +0200 Subject: [PATCH 06/19] fix missing parameter in et_charging_allowed() --- packages/control/optional.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/control/optional.py b/packages/control/optional.py index 78f02ca335..bfd447aa3a 100644 --- a/packages/control/optional.py +++ b/packages/control/optional.py @@ -51,7 +51,7 @@ def et_charging_allowed(self, max_price: float): """ try: if self.et_provider_available(): - if self.et_get_current_price() <= max_price: + if self.et_get_current_price(prices=self.data.et.get.prices) <= max_price: return True else: return False From ac22545aeece14deac1a7dd9bef4ffacd720e471 Mon Sep 17 00:00:00 2001 From: Thomas aus Welkers <14850347+tpd-opitz@users.noreply.github.com> Date: Thu, 16 Oct 2025 15:03:14 +0200 Subject: [PATCH 07/19] do not overwrite known prices if list from ET provider is empty --- packages/control/optional.py | 5 +- packages/control/optional_test.py | 39 +++ packages/main.py | 62 +++++ .../modules/common/configurable_tariff.py | 19 +- .../common/configurable_tariff_test.py | 234 ++++++++++++++---- 5 files changed, 300 insertions(+), 59 deletions(-) diff --git a/packages/control/optional.py b/packages/control/optional.py index bfd447aa3a..8daf4b9e81 100644 --- a/packages/control/optional.py +++ b/packages/control/optional.py @@ -51,10 +51,7 @@ def et_charging_allowed(self, max_price: float): """ try: if self.et_provider_available(): - if self.et_get_current_price(prices=self.data.et.get.prices) <= max_price: - return True - else: - return False + return self.et_get_current_price(prices=self.data.et.get.prices) <= max_price else: return True except KeyError: diff --git a/packages/control/optional_test.py b/packages/control/optional_test.py index 3784b99b2d..75f921c5ca 100644 --- a/packages/control/optional_test.py +++ b/packages/control/optional_test.py @@ -202,3 +202,42 @@ def test_et_get_loading_hours(granularity, # evaluation 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_charging_allowed(max_price) + assert result == expected + + +def test_et_charging_allowed_keyerror(monkeypatch): + opt = Optional() + monkeypatch.setattr(opt, "et_provider_available", Mock(return_value=True)) + monkeypatch.setattr(opt, "et_get_current_price", Mock(side_effect=KeyError)) + called = {} + + def fake_et_get_prices(): + called["called"] = True + monkeypatch.setattr(opt, "et_get_prices", fake_et_get_prices) + opt.et_charging_allowed(0.15) + assert called.get("called") is True + + +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_charging_allowed(0.15) + assert result is False diff --git a/packages/main.py b/packages/main.py index 40a6de5063..088da0d9a0 100755 --- a/packages/main.py +++ b/packages/main.py @@ -221,6 +221,68 @@ def handler5Min(self): 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() + 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() data.data.optional_data.et_get_prices() except Exception: log.exception("Fehler im Main-Modul") diff --git a/packages/modules/common/configurable_tariff.py b/packages/modules/common/configurable_tariff.py index 5039361ead..245661882b 100644 --- a/packages/modules/common/configurable_tariff.py +++ b/packages/modules/common/configurable_tariff.py @@ -40,17 +40,20 @@ def update(self): self.__log_and_publish_progress(timeslot_length_seconds) def __query_et_provider_data_once_per_day(self): + def is_tomorrow(last_timestamp: str) -> bool: + return self.__day_of(date=datetime.now()) < self.__day_of(datetime.fromtimestamp(int(last_timestamp))) if datetime.now() > self.__next_query_time: log.info(f'Wartezeit {self.__next_query_time.strftime("%Y%m%d-%H:%M:%S")}' ' abgelaufen, Strompreise werden abgefragt') try: - last_known_timestamp = self.__get_last_entry_time_stamp() - self.__tariff_state = self._component_updater() - if last_known_timestamp < (max(self.__tariff_state.prices)): + new_tariff_state = self._component_updater() + if (0 < len(new_tariff_state.prices) and is_tomorrow(max(new_tariff_state.prices))): + self.__tariff_state = new_tariff_state self.__calulate_next_query_time() else: log.info('Keine Daten für morgen erhalten, weiterer Versuch in 5 Minuten') 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.' ) @@ -66,16 +69,16 @@ def __next_query_message(self) -> str: else 'morgen ' ) return ( - f'frühestens {tomorrow}{self.__next_query_time.strftime("%H:%M")}' - if datetime.now() < self.__next_query_time - else "im nächsten Regelzyklus" + f'frühestens {tomorrow}{self.__next_query_time.strftime("%H:%M")}' + if datetime.now() < self.__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(self.__tariff_state.prices)} Einträge. ' - f'Nächster Abruf der Strompreise {self.__next_query_message()}.') + f'Die Preisliste hat {message_extension}{len(self.__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(self.__tariff_state.prices) < expected_time_slots diff --git a/packages/modules/common/configurable_tariff_test.py b/packages/modules/common/configurable_tariff_test.py index eddb53fe7b..ab72ea0a3d 100644 --- a/packages/modules/common/configurable_tariff_test.py +++ b/packages/modules/common/configurable_tariff_test.py @@ -1,5 +1,5 @@ - -from unittest.mock import Mock +from datetime import datetime, timedelta +from unittest.mock import Mock, patch from helpermodules import timecheck import pytest @@ -8,55 +8,196 @@ 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) + tariff._ConfigurableElectricityTariff__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() + assert getattr(mock_log, expected_log_method).called == expected_log_call + + @pytest.mark.parametrize( "now, tariff_state, expected", [ - 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"), + 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(now: int, 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)) + monkeypatch.setattr(timecheck, "create_timestamp", Mock(return_value=now)) # test - result = tariff._remove_outdated_prices(tariff_state, time_slot_seconds[1]-time_slot_seconds[0]) + result = tariff._remove_outdated_prices( + tariff_state, time_slot_seconds[1] - time_slot_seconds[0] + ) # assert assert result.prices == expected.prices @@ -66,16 +207,15 @@ 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}))) + 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)) + monkeypatch.setattr(timecheck, "create_timestamp", Mock(return_value=5)) # test - do not fail tariff._remove_outdated_prices(TariffState(), 1) From 06199c7228b37ef780da493e2b406c7890dd837d Mon Sep 17 00:00:00 2001 From: Thomas aus Welkers <14850347+tpd-opitz@users.noreply.github.com> Date: Tue, 21 Oct 2025 10:19:38 +0200 Subject: [PATCH 08/19] enhance logging --- packages/control/optional.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/control/optional.py b/packages/control/optional.py index 8daf4b9e81..b848f5fef3 100644 --- a/packages/control/optional.py +++ b/packages/control/optional.py @@ -17,6 +17,7 @@ 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): @@ -41,7 +42,7 @@ 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_charging_allowed(self, max_price: float) -> bool: """ prüft, ob der aktuelle Strompreis niedriger oder gleich der festgelegten Preisgrenze ist. Return @@ -51,7 +52,11 @@ def et_charging_allowed(self, max_price: float): """ try: if self.et_provider_available(): - return self.et_get_current_price(prices=self.data.et.get.prices) <= max_price + 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: From a54dffd8b98e0f108f5a89de1292cd1065c668c9 Mon Sep 17 00:00:00 2001 From: Thomas aus Welkers <14850347+tpd-opitz@users.noreply.github.com> Date: Tue, 21 Oct 2025 14:06:12 +0200 Subject: [PATCH 09/19] requested changes --- packages/conftest.py | 2 +- packages/control/optional_test.py | 64 +++++++++---------- packages/helpermodules/timecheck_test.py | 3 - .../electricity_tariffs/tibber/tariff.py | 4 -- 4 files changed, 33 insertions(+), 40 deletions(-) diff --git a/packages/conftest.py b/packages/conftest.py index a9b00baedf..28c5f9ea26 100644 --- a/packages/conftest.py +++ b/packages/conftest.py @@ -34,7 +34,7 @@ def mock_open_file(monkeypatch) -> None: @pytest.fixture(autouse=True) -def mock_today(monkeypatch, request) -> None: +def mock_today(monkeypatch) -> 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) diff --git a/packages/control/optional_test.py b/packages/control/optional_test.py index 75f921c5ca..ab2563e356 100644 --- a/packages/control/optional_test.py +++ b/packages/control/optional_test.py @@ -5,7 +5,7 @@ ONE_HOUR_SECONDS = 3600 IGNORED = 0.0001 -CHEEP = 0.0002 +CHEAP = 0.0002 EXPENSIVE = 0.0003 @@ -45,26 +45,26 @@ # first hour "1698224400": IGNORED, "1698225300": IGNORED, - "1698226200": EXPENSIVE, # current quarert hour - "1698227100": CHEEP, + "1698226200": EXPENSIVE, # current quarter hour + "1698227100": CHEAP, # second hour "1698228000": EXPENSIVE, "1698228900": EXPENSIVE, - "1698229800": CHEEP, + "1698229800": CHEAP, "1698230700": EXPENSIVE, # third hour - "1698231600": CHEEP, - "1698232500": CHEEP, - "1698233400": CHEEP, + "1698231600": CHEAP, + "1698232500": CHEAP, + "1698233400": CHEAP, "1698234300": EXPENSIVE, # fourth hour - "1698235200": CHEEP, + "1698235200": CHEAP, "1698236100": EXPENSIVE, "1698237000": EXPENSIVE, "1698237900": EXPENSIVE, # fifth hour - "1698238800": CHEEP, - "1698239700": CHEEP, # last before plan target + "1698238800": CHEAP, + "1698239700": CHEAP, # last before plan target "1698240600": IGNORED, "1698241500": IGNORED, }, @@ -81,25 +81,25 @@ "1698224400": IGNORED, "1698225300": IGNORED, "1698226200": EXPENSIVE, - "1698227100": CHEEP, # current quarert hour + "1698227100": CHEAP, # current quarter hour # second hour "1698228000": EXPENSIVE, "1698228900": EXPENSIVE, - "1698229800": CHEEP, + "1698229800": CHEAP, "1698230700": EXPENSIVE, # third hour - "1698231600": CHEEP, - "1698232500": CHEEP, - "1698233400": CHEEP, + "1698231600": CHEAP, + "1698232500": CHEAP, + "1698233400": CHEAP, "1698234300": EXPENSIVE, # fourth hour - "1698235200": CHEEP, + "1698235200": CHEAP, "1698236100": EXPENSIVE, "1698237000": EXPENSIVE, "1698237900": EXPENSIVE, # fifth hour - "1698238800": CHEEP, - "1698239700": CHEEP, + "1698238800": CHEAP, + "1698239700": CHEAP, "1698240600": EXPENSIVE, # last before plan target "1698241500": IGNORED, }, @@ -116,25 +116,25 @@ "1698224400": IGNORED, "1698225300": IGNORED, "1698226200": EXPENSIVE, - "1698227100": CHEEP, # current quarert hour + "1698227100": CHEAP, # current quarert hour # second hour "1698228000": EXPENSIVE, "1698228900": EXPENSIVE, - "1698229800": CHEEP, + "1698229800": CHEAP, "1698230700": EXPENSIVE, # third hour - "1698231600": CHEEP, - "1698232500": CHEEP, - "1698233400": CHEEP, + "1698231600": CHEAP, + "1698232500": CHEAP, + "1698233400": CHEAP, "1698234300": EXPENSIVE, # fourth hour - "1698235200": CHEEP, + "1698235200": CHEAP, "1698236100": EXPENSIVE, "1698237000": EXPENSIVE, "1698237900": EXPENSIVE, # fifth hour - "1698238800": CHEEP, - "1698239700": CHEEP, + "1698238800": CHEAP, + "1698239700": CHEAP, "1698240600": EXPENSIVE, # last before plan target "1698241500": IGNORED, }, @@ -152,19 +152,19 @@ "1698224400": IGNORED, "1698225300": IGNORED, "1698226200": EXPENSIVE, - "1698227100": CHEEP, # current quarert hour + "1698227100": CHEAP, # current quarter hour # second hour "1698228000": EXPENSIVE, "1698228900": EXPENSIVE, - "1698229800": CHEEP, + "1698229800": CHEAP, "1698230700": EXPENSIVE, # third hour - "1698231600": CHEEP, - "1698232500": CHEEP, - "1698233400": CHEEP, + "1698231600": CHEAP, + "1698232500": CHEAP, + "1698233400": CHEAP, "1698234300": EXPENSIVE, # fourth hour - "1698235200": CHEEP, + "1698235200": CHEAP, "1698236100": EXPENSIVE, "1698237000": EXPENSIVE, "1698237900": EXPENSIVE, diff --git a/packages/helpermodules/timecheck_test.py b/packages/helpermodules/timecheck_test.py index 8f559705aa..d45f0b8227 100644 --- a/packages/helpermodules/timecheck_test.py +++ b/packages/helpermodules/timecheck_test.py @@ -1,5 +1,4 @@ import datetime -import logging from typing import List, Optional, Union from unittest.mock import MagicMock, Mock import pytest @@ -8,8 +7,6 @@ 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/electricity_tariffs/tibber/tariff.py b/packages/modules/electricity_tariffs/tibber/tariff.py index 973ea876cc..16981b736b 100644 --- a/packages/modules/electricity_tariffs/tibber/tariff.py +++ b/packages/modules/electricity_tariffs/tibber/tariff.py @@ -6,7 +6,6 @@ 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 @@ -14,7 +13,6 @@ # 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, day: str) -> dict[str, float]: @@ -49,12 +47,10 @@ def fetch_prices(config: TibberTariffConfiguration) -> dict[str, float]: data = json.dumps(payload) response = req.get_http_session().post('https://api.tibber.com/v1-beta/gql', headers=headers, data=data, timeout=6) response_json = response.json() - log.debug(json.dumps(response_json)) if response_json.get("errors") is None: today_prices = _get_sorted_price_data(response_json, 'today') tomorrow_prices = _get_sorted_price_data(response_json, 'tomorrow') sorted_market_prices = {**today_prices, **tomorrow_prices} - log.debug(f"tibber response: {sorted_market_prices}") return sorted_market_prices else: error = response_json['errors'][0]['message'] From cd68a4eefa39c6a63d448d8d624e24b61d08e971 Mon Sep 17 00:00:00 2001 From: Thomas aus Welkers <14850347+tpd-opitz@users.noreply.github.com> Date: Tue, 21 Oct 2025 20:25:28 +0000 Subject: [PATCH 10/19] improve schedule charging messages --- packages/control/chargelog/chargelog_test.py | 2 +- packages/control/ev/charge_template.py | 61 +++++--- packages/control/ev/charge_template_test.py | 96 ++++++++++--- packages/control/optional.py | 31 +++- packages/control/optional_test.py | 141 +++++++++++++++++++ 5 files changed, 290 insertions(+), 41 deletions(-) diff --git a/packages/control/chargelog/chargelog_test.py b/packages/control/chargelog/chargelog_test.py index 774bfb260a..c2be4da1bc 100644 --- a/packages/control/chargelog/chargelog_test.py +++ b/packages/control/chargelog/chargelog_test.py @@ -145,4 +145,4 @@ def test_calc_charge_cost_reference_middle_day_change(mock_data, monkeypatch): assert cp.data.set.log.charged_energy_by_source == { 'grid': 1243, 'pv': 386, 'bat': 671, 'cp': 0.0} - assert round(cp.data.set.log.costs, 5) == 0.5 + assert round(cp.data.set.log.costs, 5) == 0.5 \ No newline at end of file diff --git a/packages/control/ev/charge_template.py b/packages/control/ev/charge_template.py index e4cc2929fe..924dd2fee4 100644 --- a/packages/control/ev/charge_template.py +++ b/packages/control/ev/charge_template.py @@ -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,44 @@ 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: + return ("Geladen wird "+("jetzt und " + if data.data.optional_data.et_charging_is_allowed(hour_list) + else '') + + "zu folgenden Uhrzeiten: " + + ", ".join([tomorrow(hour) + + datetime.datetime.fromtimestamp(hour).strftime('%-H:%M') + for hour in (sorted(hour_list) + if not data.data.optional_data.et_charging_is_allowed(hour_list) + else sorted(hour_list)[1:])]) + + ".") + + 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_charging_is_allowed(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..f21de8a9fe 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_charging_is_allowed", 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 b848f5fef3..7e56b3c367 100644 --- a/packages/control/optional.py +++ b/packages/control/optional.py @@ -42,6 +42,29 @@ def monitoring_stop(self): def et_provider_available(self) -> bool: return self.et_module is not None + def et_charging_is_allowed(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_charging_allowed(self, max_price: float) -> bool: """ prüft, ob der aktuelle Strompreis niedriger oder gleich der festgelegten Preisgrenze ist. @@ -83,9 +106,9 @@ def __get_first_entry(self, prices: dict[str, float]) -> tuple[str, float]: else: raise Exception("Kein Anbieter für strompreisbasiertes Laden konfiguriert.") - def __get_current_timeslot_start(self, prices: dict[str, float]) -> float: + def __get_current_timeslot_start(self, prices: dict[str, float]) -> int: timestamp, first = self.__get_first_entry(prices) - return timestamp + return int(timestamp) def et_get_current_price(self, prices: dict[str, float]) -> float: timestamp, first = self.__get_first_entry(prices) @@ -121,7 +144,7 @@ def et_get_loading_hours(self, duration: float, remaining_time: float) -> List[i 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])) @@ -129,7 +152,7 @@ def et_get_loading_hours(self, duration: float, remaining_time: float) -> List[i 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)) + 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] diff --git a/packages/control/optional_test.py b/packages/control/optional_test.py index ab2563e356..cdddff7a99 100644 --- a/packages/control/optional_test.py +++ b/packages/control/optional_test.py @@ -241,3 +241,144 @@ def test_et_charging_allowed_exception(monkeypatch): monkeypatch.setattr(opt, "et_get_current_price", Mock(side_effect=Exception)) result = opt.et_charging_allowed(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" + ), + ] +) +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_charging_is_allowed(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_charging_is_allowed([]) + assert result is False From 5172eba0414cd0c97beb9d85820b42fe911e4573 Mon Sep 17 00:00:00 2001 From: Thomas aus Welkers <14850347+tpd-opitz@users.noreply.github.com> Date: Tue, 21 Oct 2025 22:34:31 +0000 Subject: [PATCH 11/19] avoid resetting price list --- packages/control/optional.py | 43 +++++++++++++++++-------------- packages/control/optional_test.py | 13 ---------- 2 files changed, 23 insertions(+), 33 deletions(-) diff --git a/packages/control/optional.py b/packages/control/optional.py index 7e56b3c367..14698eebc1 100644 --- a/packages/control/optional.py +++ b/packages/control/optional.py @@ -82,27 +82,30 @@ def et_charging_allowed(self, max_price: float) -> bool: 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 __get_first_entry(self, prices: dict[str, float]) -> tuple[str, float]: if self.et_provider_available(): prices = self.data.et.get.prices - timestamp, first = next(iter(prices.items())) - 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 + 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.") @@ -171,15 +174,15 @@ def et_get_prices(self): 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 cdddff7a99..a01d2a2125 100644 --- a/packages/control/optional_test.py +++ b/packages/control/optional_test.py @@ -222,19 +222,6 @@ def test_et_charging_allowed(monkeypatch, provider_available, current_price, max assert result == expected -def test_et_charging_allowed_keyerror(monkeypatch): - opt = Optional() - monkeypatch.setattr(opt, "et_provider_available", Mock(return_value=True)) - monkeypatch.setattr(opt, "et_get_current_price", Mock(side_effect=KeyError)) - called = {} - - def fake_et_get_prices(): - called["called"] = True - monkeypatch.setattr(opt, "et_get_prices", fake_et_get_prices) - opt.et_charging_allowed(0.15) - assert called.get("called") is True - - def test_et_charging_allowed_exception(monkeypatch): opt = Optional() monkeypatch.setattr(opt, "et_provider_available", Mock(return_value=True)) From b5aa34974243cc0080854143abbad7f280ada424 Mon Sep 17 00:00:00 2001 From: Thomas aus Welkers <14850347+tpd-opitz@users.noreply.github.com> Date: Wed, 22 Oct 2025 22:03:05 +0000 Subject: [PATCH 12/19] fix price slot selection --- packages/control/ev/charge_template.py | 2 +- packages/control/optional.py | 25 +++++--- packages/control/optional_test.py | 84 +++++++++++++++++++++++--- 3 files changed, 94 insertions(+), 17 deletions(-) diff --git a/packages/control/ev/charge_template.py b/packages/control/ev/charge_template.py index 924dd2fee4..46c01f59d4 100644 --- a/packages/control/ev/charge_template.py +++ b/packages/control/ev/charge_template.py @@ -611,7 +611,7 @@ def get_hours_message() -> str: datetime.datetime.fromtimestamp(hour).strftime('%-H:%M') for hour in (sorted(hour_list) if not data.data.optional_data.et_charging_is_allowed(hour_list) - else sorted(hour_list)[1:])]) + else (sorted(hour_list)[1:] if len(hour_list) > 1 else []))]) + ".") def end_of_today_timestamp() -> int: diff --git a/packages/control/optional.py b/packages/control/optional.py index 14698eebc1..34c464a167 100644 --- a/packages/control/optional.py +++ b/packages/control/optional.py @@ -4,6 +4,7 @@ from math import ceil from threading import Thread from typing import List +from datetime import datetime from control import data from control.ocpp import OcppMixin @@ -138,31 +139,39 @@ def et_get_loading_hours(self, duration: float, remaining_time: float) -> List[i try: prices = self.data.et.get.prices price_timeslot_seconds = self.__calculate_price_timeslot_length(prices) - first_timeslot_start = self.__get_current_timeslot_start(prices) + now = int(timecheck.create_timestamp()) price_candidates = { timestamp: price for timestamp, price in prices.items() if ( # is current timeslot or futur - int(timestamp) >= int(first_timeslot_start) and + int(timestamp) + price_timeslot_seconds > now and # ends before plan target time - int(timestamp) + price_timeslot_seconds <= int(first_timeslot_start) + remaining_time + not int(timestamp) >= now + remaining_time ) } - now = int(timecheck.create_timestamp()) + log.debug("%s Preis-Kandidaten %s für %s Sekunden zwischen %s und %s von %s bis %s", + 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)) + 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") + except Exception as e: + log.exception("Fehler im Optional-Modul: %s", e) return [] def et_get_prices(self): diff --git a/packages/control/optional_test.py b/packages/control/optional_test.py index a01d2a2125..50060b3010 100644 --- a/packages/control/optional_test.py +++ b/packages/control/optional_test.py @@ -6,7 +6,7 @@ ONE_HOUR_SECONDS = 3600 IGNORED = 0.0001 CHEAP = 0.0002 -EXPENSIVE = 0.0003 +EXPENSIVE = 0.3000 @pytest.mark.no_mock_full_hour @@ -135,11 +135,14 @@ # fifth hour "1698238800": CHEAP, "1698239700": CHEAP, - "1698240600": EXPENSIVE, # last before plan target - "1698241500": IGNORED, + "1698240600": EXPENSIVE, + "1698241500": EXPENSIVE, # last before plan target + # sixth hour + "1698242400": IGNORED, + "1698243300": IGNORED, }, [1698227100, 1698229800, 1698231600, 1698232500, - 1698233400, 1698235200, 1698238800, 1698239700, 1698240600], + 1698233400, 1698235200, 1698238800, 1698239700, 1698241500], id="select additional if time elapsed in current slot makes selection too short" ), pytest.param( @@ -151,8 +154,8 @@ # first hour "1698224400": IGNORED, "1698225300": IGNORED, - "1698226200": EXPENSIVE, - "1698227100": CHEAP, # current quarter hour + "1698226200": EXPENSIVE, # current quarter hour + "1698227100": CHEAP, # second hour "1698228000": EXPENSIVE, "1698228900": EXPENSIVE, @@ -172,10 +175,48 @@ "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, 1698229800, 1698231600, 1698232500, 1698233400, 1698235200, 1698238800, 1698239700], - id="select latest if most expensive candidates have same price" + [1698227100, 1698228900, 1698229800, 1698230700, 1698231600, 1698232500, 1698233400, 1698235200], + id="order in time sequence reverse" ), ], ) @@ -348,6 +389,33 @@ def test_et_charging_allowed_exception(monkeypatch): [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): From 8b53deacbfdf407a83475498e229dfbb297e38da Mon Sep 17 00:00:00 2001 From: Thomas aus Welkers <14850347+tpd-opitz@users.noreply.github.com> Date: Fri, 24 Oct 2025 01:52:35 +0200 Subject: [PATCH 13/19] fix next fetch date --- packages/control/optional.py | 4 ++-- .../modules/common/configurable_tariff.py | 24 ++++++++++--------- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/packages/control/optional.py b/packages/control/optional.py index 34c464a167..321497f1b2 100644 --- a/packages/control/optional.py +++ b/packages/control/optional.py @@ -150,7 +150,7 @@ def et_get_loading_hours(self, duration: float, remaining_time: float) -> List[i not int(timestamp) >= now + remaining_time ) } - log.debug("%s Preis-Kandidaten %s für %s Sekunden zwischen %s und %s von %s bis %s", + 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), @@ -162,7 +162,7 @@ def et_get_loading_hours(self, duration: float, remaining_time: float) -> List[i 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) - + price_timeslot_seconds * (len(selected_time_slots)-1) - (int(now) - min(selected_time_slots)) ) return sorted(selected_time_slots.keys() diff --git a/packages/modules/common/configurable_tariff.py b/packages/modules/common/configurable_tariff.py index 245661882b..1f4d5c1fe5 100644 --- a/packages/modules/common/configurable_tariff.py +++ b/packages/modules/common/configurable_tariff.py @@ -11,6 +11,7 @@ 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__) @@ -41,23 +42,26 @@ def update(self): def __query_et_provider_data_once_per_day(self): def is_tomorrow(last_timestamp: str) -> bool: - return self.__day_of(date=datetime.now()) < self.__day_of(datetime.fromtimestamp(int(last_timestamp))) + 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) if datetime.now() > self.__next_query_time: log.info(f'Wartezeit {self.__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) and is_tomorrow(max(new_tariff_state.prices))): + if 0 < len(new_tariff_state.prices): self.__tariff_state = new_tariff_state - self.__calulate_next_query_time() - else: - log.info('Keine Daten für morgen erhalten, weiterer Versuch in 5 Minuten') + if is_tomorrow(self.__get_last_entry_time_stamp()): + self.__calulate_next_query_time() + log.info('Nächster Abruf der Strompreise frühestens' + f' {self.__next_query_time.strftime("%Y%m%d-%H:%M:%S")}.') + else: + log.info('Keine Daten für morgen erhalten, weiterer Versuch in 5 Minuten') 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.' ) - log.info(f'Nächster Abruf der Strompreise frühestens {self.__next_query_time.strftime("%Y%m%d-%H:%M:%S")}.') def __day_of(self, date: datetime) -> datetime: return date.replace(hour=0, minute=0, second=0, microsecond=0) @@ -90,16 +94,14 @@ def __store_and_publish_updated_data(self): self.store.update() def __calulate_next_query_time(self) -> None: - self.__next_query_time = datetime.now().replace( - hour=14, minute=0, second=0 + self.__next_query_time = datetime.fromtimestamp(int(max(self.__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 + # aktually ET providers issue next day prices up to half an hour earlier then TARIFF_UPDATE_HOURE:00 # reduce serverload on their site by trying early and randomizing query time minutes=random.randint(-30, -10), seconds=random.randint(0, 59) ) - if datetime.now() > self.__next_query_time: - self.__next_query_time += timedelta(days=1) def __calculate_price_timeslot_length(self) -> int: first_timestamps = list(self.__tariff_state.prices.keys())[:2] From 09aaa8f08622cf76fc3394c16b9b33ce0f00f1c5 Mon Sep 17 00:00:00 2001 From: Thomas aus Welkers <14850347+tpd-opitz@users.noreply.github.com> Date: Tue, 28 Oct 2025 10:04:29 +0100 Subject: [PATCH 14/19] update prices on construction of Optional, track (re)creation of Et/EtGet --- packages/control/optional.py | 72 +++++++++++++++++++++++++++++++----- 1 file changed, 63 insertions(+), 9 deletions(-) diff --git a/packages/control/optional.py b/packages/control/optional.py index 321497f1b2..d8aadb9061 100644 --- a/packages/control/optional.py +++ b/packages/control/optional.py @@ -3,8 +3,10 @@ import logging from math import ceil from threading import Thread -from typing import List +from typing import List, Optional from datetime import datetime +import traceback +import uuid from control import data from control.ocpp import OcppMixin @@ -24,14 +26,62 @@ class Optional(OcppMixin): def __init__(self): try: + # unique instance id for tracing/logging + self.instance_id = str(uuid.uuid4()) 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) + + # Log instance creation with non-None fields + active_fields = { + "data": bool(self.data), + "et_module": bool(self.et_module), + "monitoring_module": bool(self.monitoring_module), + "dc_charging": bool(self.data.dc_charging), + } + # log creation of Et factory for debugging, include limited stack trace + stack = traceback.format_list(traceback.extract_stack(limit=15)[:-1]) + # remove internal newlines in each stack entry so each entry is a single line + sanitized = [s.replace("\n", "").strip() for s in stack] + log.info("Optional instance created: %s - Active fields: %s, Call stack:\n %s", + self.instance_id, + {k: v for k, v in active_fields.items() if v}, + "\n ".join(sanitized)) 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 creation of Et factory for debugging, include limited stack trace + stack = traceback.format_list(traceback.extract_stack(limit=15)[:-1]) + # remove internal newlines in each stack entry so each entry is a single line + sanitized = [s.replace("\n", "").strip() for s in stack] + log.debug("Replacing existing et_module on Optional %s not allowed!\nCall stack:\n %s", + self.instance_id, "\n ".join(sanitized)) + 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: %s", getattr( + self, "instance_id", "unknown"), value.config.name) + self.et_get_prices() + else: + log.info("et_module cleared on Optional %s", getattr(self, "instance_id", "unknown")) + def monitoring_start(self): if self.monitoring_module is not None: self.monitoring_module.start_monitoring() @@ -63,7 +113,7 @@ def et_charging_is_allowed(self, selected_hours: list[int]) -> bool: log.info("Prüfe strompreisbasiertes Laden: Nicht konfiguriert") return False except Exception as e: - log.exception(f"Fehler im Optional-Modul: {e}") + log.exception(f"Fehler im Optional-Modul {self.instance_id}: {e}") return False def et_charging_allowed(self, max_price: float) -> bool: @@ -77,24 +127,27 @@ def et_charging_allowed(self, max_price: float) -> bool: try: if self.et_provider_available(): 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", + log.info("Prüfe strompreisbasiertes Laden mit Preisgrenze %.5f €/kWh, aktueller Preis: %.5f €/kWh" + + " Optinal instance: %s", max_price * AS_EURO_PER_KWH, - current_price*AS_EURO_PER_KWH) + current_price * AS_EURO_PER_KWH, + self.instance_id) return current_price <= max_price else: return True except KeyError as e: - log.exception("Fehler beim strompreisbasierten Laden: %s", e) + log.exception("Fehler beim strompreisbasierten Laden: %s, Optinal instance %s", e, self.instance_id) return False except Exception as e: - log.exception("Fehler im Optional-Modul: %s", e) + log.exception("Fehler im Optional-Modul: %s, Optinal instance %s", e, self.instance_id) return False def __get_first_entry(self, prices: dict[str, float]) -> tuple[str, float]: if self.et_provider_available(): prices = self.data.et.get.prices if prices is None or len(prices) == 0: - raise Exception("Keine Preisdaten für strompreisbasiertes Laden vorhanden.") + raise Exception("Keine Preisdaten für strompreisbasiertes Laden vorhanden, Optinal instance %s.", + self.instance_id) else: timestamp, first = next(iter(prices.items())) price_timeslot_seconds = self.__calculate_price_timeslot_length(prices) @@ -177,7 +230,8 @@ def et_get_loading_hours(self, duration: float, remaining_time: float) -> List[i 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=f"electricity tariff in optional {self.instance_id}")) 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: From 3918d077464f916937aee6b21d5b0a0a7a4ceaa5 Mon Sep 17 00:00:00 2001 From: Thomas aus Welkers <14850347+tpd-opitz@users.noreply.github.com> Date: Tue, 28 Oct 2025 15:44:13 +0100 Subject: [PATCH 15/19] enhance logging for Tarif update target time --- packages/control/chargelog/chargelog_test.py | 2 +- packages/control/optional.py | 45 ++++--------------- .../modules/common/configurable_tariff.py | 36 +++++++++------ .../common/configurable_tariff_test.py | 6 +-- 4 files changed, 36 insertions(+), 53 deletions(-) diff --git a/packages/control/chargelog/chargelog_test.py b/packages/control/chargelog/chargelog_test.py index c2be4da1bc..774bfb260a 100644 --- a/packages/control/chargelog/chargelog_test.py +++ b/packages/control/chargelog/chargelog_test.py @@ -145,4 +145,4 @@ def test_calc_charge_cost_reference_middle_day_change(mock_data, monkeypatch): assert cp.data.set.log.charged_energy_by_source == { 'grid': 1243, 'pv': 386, 'bat': 671, 'cp': 0.0} - assert round(cp.data.set.log.costs, 5) == 0.5 \ No newline at end of file + assert round(cp.data.set.log.costs, 5) == 0.5 diff --git a/packages/control/optional.py b/packages/control/optional.py index d8aadb9061..b538682c86 100644 --- a/packages/control/optional.py +++ b/packages/control/optional.py @@ -5,8 +5,6 @@ from threading import Thread from typing import List, Optional from datetime import datetime -import traceback -import uuid from control import data from control.ocpp import OcppMixin @@ -26,30 +24,12 @@ class Optional(OcppMixin): def __init__(self): try: - # unique instance id for tracing/logging - self.instance_id = str(uuid.uuid4()) self.data = OptionalData() # 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) - - # Log instance creation with non-None fields - active_fields = { - "data": bool(self.data), - "et_module": bool(self.et_module), - "monitoring_module": bool(self.monitoring_module), - "dc_charging": bool(self.data.dc_charging), - } - # log creation of Et factory for debugging, include limited stack trace - stack = traceback.format_list(traceback.extract_stack(limit=15)[:-1]) - # remove internal newlines in each stack entry so each entry is a single line - sanitized = [s.replace("\n", "").strip() for s in stack] - log.info("Optional instance created: %s - Active fields: %s, Call stack:\n %s", - self.instance_id, - {k: v for k, v in active_fields.items() if v}, - "\n ".join(sanitized)) except Exception: log.exception("Fehler im Optional-Modul") @@ -65,12 +45,7 @@ def et_module(self, value: Optional[ConfigurableElectricityTariff]): 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 creation of Et factory for debugging, include limited stack trace - stack = traceback.format_list(traceback.extract_stack(limit=15)[:-1]) - # remove internal newlines in each stack entry so each entry is a single line - sanitized = [s.replace("\n", "").strip() for s in stack] - log.debug("Replacing existing et_module on Optional %s not allowed!\nCall stack:\n %s", - self.instance_id, "\n ".join(sanitized)) + 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") @@ -113,7 +88,7 @@ def et_charging_is_allowed(self, selected_hours: list[int]) -> bool: log.info("Prüfe strompreisbasiertes Laden: Nicht konfiguriert") return False except Exception as e: - log.exception(f"Fehler im Optional-Modul {self.instance_id}: {e}") + log.exception(f"Fehler im Optional-Modul: {e}") return False def et_charging_allowed(self, max_price: float) -> bool: @@ -127,27 +102,25 @@ def et_charging_allowed(self, max_price: float) -> bool: try: if self.et_provider_available(): 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" + - " Optinal instance: %s", + 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, - self.instance_id) + current_price * AS_EURO_PER_KWH + ) return current_price <= max_price else: return True except KeyError as e: - log.exception("Fehler beim strompreisbasierten Laden: %s, Optinal instance %s", e, self.instance_id) + log.exception("Fehler beim strompreisbasierten Laden: %s", e) return False except Exception as e: - log.exception("Fehler im Optional-Modul: %s, Optinal instance %s", e, self.instance_id) + log.exception("Fehler im Optional-Modul: %s", e) return False def __get_first_entry(self, prices: dict[str, float]) -> tuple[str, float]: if self.et_provider_available(): prices = self.data.et.get.prices if prices is None or len(prices) == 0: - raise Exception("Keine Preisdaten für strompreisbasiertes Laden vorhanden, Optinal instance %s.", - self.instance_id) + 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) @@ -231,7 +204,7 @@ def et_get_prices(self): try: if self.et_module: thread_handler(Thread(target=self.et_module.update, args=(), - name=f"electricity tariff in optional {self.instance_id}")) + 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: diff --git a/packages/modules/common/configurable_tariff.py b/packages/modules/common/configurable_tariff.py index 1f4d5c1fe5..6ff2395b6f 100644 --- a/packages/modules/common/configurable_tariff.py +++ b/packages/modules/common/configurable_tariff.py @@ -14,13 +14,18 @@ TARIFF_UPDATE_HOUR = 14 # latest expected time for daily tariff update ONE_HOUR_SECONDS: int = 3600 log = logging.getLogger(__name__) +''' +next_query_time is defined outside of class ConfigurableElectricityTariff because +for an unknown reason defining it as a class variable does not keep its value. +''' +next_query_time: datetime = datetime.fromtimestamp(1) 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) + # unique instance id for tracing/logging self.__tariff_state: TariffState = None self.config = config self.store = store.get_electricity_tariff_value_store() @@ -44,21 +49,26 @@ def __query_et_provider_data_once_per_day(self): 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) - if datetime.now() > self.__next_query_time: - log.info(f'Wartezeit {self.__next_query_time.strftime("%Y%m%d-%H:%M:%S")}' - ' abgelaufen, Strompreise werden abgefragt') + global next_query_time + if datetime.now() > 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): self.__tariff_state = new_tariff_state if is_tomorrow(self.__get_last_entry_time_stamp()): - self.__calulate_next_query_time() + next_query_time = self.__calulate_next_query_time() log.info('Nächster Abruf der Strompreise frühestens' - f' {self.__next_query_time.strftime("%Y%m%d-%H:%M:%S")}.') + 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') + log.info('Keine Daten für morgen erhalten, weiterer Versuch in 5 Minuten' + ) except Exception as e: - log.warning(f'Fehler beim Abruf der Strompreise: {e}, nächster Versuch in 5 Minuten.') + 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.' ) @@ -69,12 +79,12 @@ def __day_of(self, date: datetime) -> datetime: def __next_query_message(self) -> str: tomorrow = ( '' - if self.__day_of(datetime.now()) == self.__day_of(self.__next_query_time) + if self.__day_of(datetime.now()) == self.__day_of(next_query_time) else 'morgen ' ) return ( - f'frühestens {tomorrow}{self.__next_query_time.strftime("%H:%M")}' - if datetime.now() < self.__next_query_time + f'frühestens {tomorrow}{next_query_time.strftime("%H:%M")}' + if datetime.now() < next_query_time else "im nächsten Regelzyklus" ) @@ -93,8 +103,8 @@ def __store_and_publish_updated_data(self): self.store.set(self.__tariff_state) self.store.update() - def __calulate_next_query_time(self) -> None: - self.__next_query_time = datetime.fromtimestamp(int(max(self.__tariff_state.prices))).replace( + def __calulate_next_query_time(self) -> datetime: + return datetime.fromtimestamp(int(max(self.__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 TARIFF_UPDATE_HOURE:00 diff --git a/packages/modules/common/configurable_tariff_test.py b/packages/modules/common/configurable_tariff_test.py index ab72ea0a3d..7a50dc5592 100644 --- a/packages/modules/common/configurable_tariff_test.py +++ b/packages/modules/common/configurable_tariff_test.py @@ -89,9 +89,9 @@ def component_initializer(config): return lambda: tariff_state_factory() tariff = ConfigurableElectricityTariff(config, component_initializer) - tariff._ConfigurableElectricityTariff__next_query_time = datetime.now() - timedelta( - days=1 - ) + + 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} ) From 5d61668eff997116e04f27ec730148a52778df9c Mon Sep 17 00:00:00 2001 From: Thomas aus Welkers <14850347+tpd-opitz@users.noreply.github.com> Date: Fri, 31 Oct 2025 09:40:13 +0100 Subject: [PATCH 16/19] fix persistence of TariffState --- .../modules/common/configurable_tariff.py | 114 ++++++++++-------- .../common/configurable_tariff_test.py | 2 +- 2 files changed, 67 insertions(+), 49 deletions(-) diff --git a/packages/modules/common/configurable_tariff.py b/packages/modules/common/configurable_tariff.py index 6ff2395b6f..19abccca24 100644 --- a/packages/modules/common/configurable_tariff.py +++ b/packages/modules/common/configurable_tariff.py @@ -15,18 +15,20 @@ ONE_HOUR_SECONDS: int = 3600 log = logging.getLogger(__name__) ''' -next_query_time is defined outside of class ConfigurableElectricityTariff because -for an unknown reason defining it as a class variable does not keep its value. +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: - # unique instance id for tracing/logging - self.__tariff_state: TariffState = 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)) @@ -36,42 +38,51 @@ 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"): with SingleComponentUpdateContext(self.fault_state): - self.__query_et_provider_data_once_per_day() - timeslot_length_seconds = self.__calculate_price_timeslot_length() - self.__tariff_state = self._remove_outdated_prices(self.__tariff_state, timeslot_length_seconds) - self.__store_and_publish_updated_data() + 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 __query_et_provider_data_once_per_day(self): + 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 - if datetime.now() > 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): - self.__tariff_state = new_tariff_state - if is_tomorrow(self.__get_last_entry_time_stamp()): - next_query_time = self.__calulate_next_query_time() - log.info('Nächster Abruf der Strompreise frühestens' - 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' - ) - 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.' - ) + 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) @@ -83,7 +94,7 @@ def __next_query_message(self) -> str: else 'morgen ' ) return ( - f'frühestens {tomorrow}{next_query_time.strftime("%H:%M")}' + f'{tomorrow}{next_query_time.strftime("%H:%M")}' if datetime.now() < next_query_time else "im nächsten Regelzyklus" ) @@ -91,36 +102,43 @@ def __next_query_message(self) -> str: 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(self.__tariff_state.prices)} Einträge. ' + 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(self.__tariff_state.prices) < expected_time_slots + if len(internal_tariff_state.prices) < expected_time_slots else '' ) - def __store_and_publish_updated_data(self): - self.store.set(self.__tariff_state) + 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) -> datetime: - return datetime.fromtimestamp(int(max(self.__tariff_state.prices))).replace( + 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 TARIFF_UPDATE_HOURE:00 + # 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(-30, -10), - seconds=random.randint(0, 59) + minutes=random.randint(1, 7) * -5 ) - 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 __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) -> str: + def __get_last_entry_time_stamp(self, tariff_state: TariffState) -> str: last_known_timestamp = "0" - if self.__tariff_state is not None: - last_known_timestamp = max(self.__tariff_state.prices) + 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: diff --git a/packages/modules/common/configurable_tariff_test.py b/packages/modules/common/configurable_tariff_test.py index 7a50dc5592..c3463249aa 100644 --- a/packages/modules/common/configurable_tariff_test.py +++ b/packages/modules/common/configurable_tariff_test.py @@ -104,7 +104,7 @@ def component_initializer(config): 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() + tariff._ConfigurableElectricityTariff__query_et_provider_data_once_per_day(TariffState()) assert getattr(mock_log, expected_log_method).called == expected_log_call From 35e44b4cd880c7e3d8ce2d0955fba1ecf94c0f9b Mon Sep 17 00:00:00 2001 From: Thomas aus Welkers <14850347+tpd-opitz@users.noreply.github.com> Date: Mon, 3 Nov 2025 16:03:04 +0000 Subject: [PATCH 17/19] move et price update call in main.py --- packages/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/main.py b/packages/main.py index 088da0d9a0..d5ebdf0409 100755 --- a/packages/main.py +++ b/packages/main.py @@ -250,6 +250,7 @@ def handler5MinAlgorithm(self): 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") @@ -283,7 +284,6 @@ def handler5Min(self): general_internal_chargepoint_handler.internal_chargepoint_handler.heartbeat = False with ChangedValuesContext(loadvars_.event_module_update_completed): sub.system_data["system"].update_ip_address() - data.data.optional_data.et_get_prices() except Exception: log.exception("Fehler im Main-Modul") From baba30108f220d02606b3a1908fcc416d80640c2 Mon Sep 17 00:00:00 2001 From: Thomas aus Welkers <14850347+tpd-opitz@users.noreply.github.com> Date: Mon, 3 Nov 2025 16:15:27 +0000 Subject: [PATCH 18/19] refactoring: rename optional.et_is_charging_allowed_* --- packages/control/ev/charge_template.py | 10 ++++++---- packages/control/ev/charge_template_test.py | 2 +- packages/control/optional.py | 4 ++-- packages/control/optional_test.py | 8 ++++---- 4 files changed, 13 insertions(+), 11 deletions(-) diff --git a/packages/control/ev/charge_template.py b/packages/control/ev/charge_template.py index 46c01f59d4..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 @@ -603,14 +603,16 @@ def scheduled_charging_calc_current(self, # 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 data.data.optional_data.et_charging_is_allowed(hour_list) + 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 data.data.optional_data.et_charging_is_allowed(hour_list) + if not is_loading_hour(hour_list) else (sorted(hour_list)[1:] if len(hour_list) > 1 else []))]) + ".") @@ -624,7 +626,7 @@ def tomorrow(timestamp: int) -> str: selected_plan.duration, selected_plan.remaining_time) log.debug(f"Günstige Ladezeiten: {hour_list}") - if data.data.optional_data.et_charging_is_allowed(hour_list): + 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" diff --git a/packages/control/ev/charge_template_test.py b/packages/control/ev/charge_template_test.py index f21de8a9fe..6cd0fb7eb2 100644 --- a/packages/control/ev/charge_template_test.py +++ b/packages/control/ev/charge_template_test.py @@ -380,7 +380,7 @@ def test_scheduled_charging_calc_current_electricity_tariff( 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=is_loading_hour) - monkeypatch.setattr(data.data.optional_data, "et_charging_is_allowed", mock_is_list_valid) + monkeypatch.setattr(data.data.optional_data, "et_is_charging_allowed_hours_list", mock_is_list_valid) # execution ret = ct.scheduled_charging_calc_current( diff --git a/packages/control/optional.py b/packages/control/optional.py index b538682c86..3e8e346e77 100644 --- a/packages/control/optional.py +++ b/packages/control/optional.py @@ -68,7 +68,7 @@ def monitoring_stop(self): def et_provider_available(self) -> bool: return self.et_module is not None - def et_charging_is_allowed(self, selected_hours: list[int]) -> bool: + 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 @@ -91,7 +91,7 @@ def et_charging_is_allowed(self, selected_hours: list[int]) -> bool: log.exception(f"Fehler im Optional-Modul: {e}") return False - def et_charging_allowed(self, max_price: float) -> bool: + 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 diff --git a/packages/control/optional_test.py b/packages/control/optional_test.py index 50060b3010..4efb677612 100644 --- a/packages/control/optional_test.py +++ b/packages/control/optional_test.py @@ -259,7 +259,7 @@ def test_et_charging_allowed(monkeypatch, provider_available, current_price, max 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_charging_allowed(max_price) + result = opt.et_is_charging_allowed_price_threshold(max_price) assert result == expected @@ -267,7 +267,7 @@ 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_charging_allowed(0.15) + result = opt.et_is_charging_allowed_price_threshold(0.15) assert result is False @@ -427,7 +427,7 @@ def test_et_charging_available(now_ts, provider_available, price_list, selected_ opt = Optional() opt.data.et.get.prices = price_list monkeypatch.setattr(opt, "et_provider_available", Mock(return_value=provider_available)) - result = opt.et_charging_is_allowed(selected_hours) + result = opt.et_is_charging_allowed_hours_list(selected_hours) assert result == expected @@ -435,5 +435,5 @@ 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_charging_is_allowed([]) + result = opt.et_is_charging_allowed_hours_list([]) assert result is False From d9e3006a947d2cf9a64255b5738bb3b5054ba7bb Mon Sep 17 00:00:00 2001 From: Thomas aus Welkers <14850347+tpd-opitz@users.noreply.github.com> Date: Mon, 3 Nov 2025 16:19:31 +0000 Subject: [PATCH 19/19] completely remove instance_id --- packages/control/optional.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/control/optional.py b/packages/control/optional.py index 3e8e346e77..8f17a1137e 100644 --- a/packages/control/optional.py +++ b/packages/control/optional.py @@ -51,11 +51,10 @@ def et_module(self, value: Optional[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: %s", getattr( - self, "instance_id", "unknown"), value.config.name) + log.info("et_module set on Optional: %s", value.config.name) self.et_get_prices() else: - log.info("et_module cleared on Optional %s", getattr(self, "instance_id", "unknown")) + log.info("et_module cleared in Optional") def monitoring_start(self): if self.monitoring_module is not None: