Skip to content

Commit fd8e066

Browse files
authored
electricity tariff quarter hourly prices: remove global variables (#2897)
* draft * pytest * remove obsolet price request (is done in subdata) * flake8 * fix * improve
1 parent 748b9ad commit fd8e066

File tree

6 files changed

+100
-195
lines changed

6 files changed

+100
-195
lines changed

packages/control/optional.py

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22
"""
33
import logging
44
from math import ceil
5+
import random
56
from threading import Thread
6-
from typing import List, Optional
7-
from datetime import datetime
7+
from typing import List, Optional as TypingOptional
8+
from datetime import datetime, timedelta
89

910
from control import data
1011
from control.ocpp import OcppMixin
@@ -19,27 +20,28 @@
1920

2021
log = logging.getLogger(__name__)
2122
AS_EURO_PER_KWH = 1000.0 # Umrechnung von €/Wh in €/kWh
23+
TARIFF_UPDATE_HOUR = 14 # latest expected time for daily tariff update
2224

2325

2426
class Optional(OcppMixin):
2527
def __init__(self):
2628
try:
2729
self.data = OptionalData()
2830
# guarded et_module stored in a private attribute
29-
self._et_module: Optional[ConfigurableElectricityTariff] = None
31+
self._et_module: TypingOptional[ConfigurableElectricityTariff] = None
3032
self.monitoring_module: ConfigurableMonitoring = None
3133
self.data.dc_charging = hardware_configuration.get_hardware_configuration_setting("dc_charging")
3234
Pub().pub("openWB/optional/dc_charging", self.data.dc_charging)
3335
except Exception:
3436
log.exception("Fehler im Optional-Modul")
3537

3638
@property
37-
def et_module(self) -> Optional[ConfigurableElectricityTariff]:
39+
def et_module(self) -> TypingOptional[ConfigurableElectricityTariff]:
3840
"""Getter for the electricity tariff module (may be None)."""
3941
return self._et_module
4042

4143
@et_module.setter
42-
def et_module(self, value: Optional[ConfigurableElectricityTariff]):
44+
def et_module(self, value: TypingOptional[ConfigurableElectricityTariff]):
4345
"""Setter with basic type-guarding and logging.
4446
4547
Accepts either None or a ConfigurableElectricityTariff instance. Logs when set/cleared.
@@ -61,8 +63,8 @@ def monitoring_start(self):
6163
self.monitoring_module.start_monitoring()
6264

6365
def monitoring_stop(self):
64-
if self.mon_module is not None:
65-
self.mon_module.stop_monitoring()
66+
if self.monitoring_module is not None:
67+
self.monitoring_module.stop_monitoring()
6668

6769
def et_provider_available(self) -> bool:
6870
return self.et_module is not None
@@ -201,9 +203,11 @@ def et_get_loading_hours(self, duration: float, remaining_time: float) -> List[i
201203

202204
def et_get_prices(self):
203205
try:
204-
if self.et_module:
206+
if self.et_module and self.et_price_update_required():
205207
thread_handler(Thread(target=self.et_module.update, args=(),
206208
name="electricity tariff in optional"))
209+
self.data.et.get.next_query_time = None
210+
Pub().pub("openWB/set/optional/et/get/next_query_time", None)
207211
else:
208212
# Wenn kein Modul konfiguriert ist, Fehlerstatus zurücksetzen.
209213
if self.data.et.get.fault_state != 0 or self.data.et.get.fault_str != NO_ERROR:
@@ -212,6 +216,44 @@ def et_get_prices(self):
212216
except Exception as e:
213217
log.exception("Fehler im Optional-Modul: %s", e)
214218

219+
def et_price_update_required(self) -> bool:
220+
def is_tomorrow(last_timestamp: str) -> bool:
221+
return (day_of(date=datetime.now()) < day_of(datetime.fromtimestamp(int(last_timestamp)))
222+
or day_of(date=datetime.now()).hour < TARIFF_UPDATE_HOUR)
223+
224+
def day_of(date: datetime) -> datetime:
225+
return date.replace(hour=0, minute=0, second=0, microsecond=0)
226+
227+
def get_last_entry_time_stamp() -> str:
228+
last_known_timestamp = "0"
229+
if self.data.et.get.prices is not None:
230+
last_known_timestamp = max(self.data.et.get.prices)
231+
return last_known_timestamp
232+
if len(self.data.et.get.prices) == 0:
233+
return True
234+
if self.data.et.get.next_query_time is None:
235+
next_query_time = datetime.fromtimestamp(int(max(self.data.et.get.prices))).replace(
236+
hour=TARIFF_UPDATE_HOUR, minute=0, second=0
237+
) + timedelta(
238+
# aktually ET providers issue next day prices up to half an hour earlier then 14:00
239+
# reduce serverload on their site by trying early and randomizing query time
240+
minutes=random.randint(1, 7) * -5
241+
)
242+
self.data.et.get.next_query_time = next_query_time.timestamp()
243+
Pub().pub("openWB/set/optional/et/get/next_query_time", self.data.et.get.next_query_time)
244+
if is_tomorrow(get_last_entry_time_stamp()):
245+
if timecheck.create_timestamp() > self.data.et.get.next_query_time:
246+
log.info(
247+
f'Wartezeit {datetime.fromtimestamp(self.data.et.get.next_query_time).strftime("%Y%m%d-%H:%M:%S")}'
248+
' abgelaufen, Strompreise werden abgefragt')
249+
return True
250+
else:
251+
log.info(
252+
'Nächster Abruf der Strompreise '
253+
f'{datetime.fromtimestamp(self.data.et.get.next_query_time).strftime("%Y%m%d-%H:%M:%S")}.')
254+
return False
255+
return False
256+
215257
def ocpp_transfer_meter_values(self):
216258
try:
217259
if self.data.ocpp.active:

packages/control/optional_data.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
class EtGet:
1111
fault_state: int = 0
1212
fault_str: str = NO_ERROR
13+
next_query_time: Optional[float] = None
1314
prices: Dict = field(default_factory=empty_dict_factory)
1415

1516

packages/control/optional_test.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,3 +437,43 @@ def test_et_charging_available_exception(monkeypatch):
437437
opt.data.et.get.prices = {} # empty prices list raises exception
438438
result = opt.et_is_charging_allowed_hours_list([])
439439
assert result is False
440+
441+
442+
@pytest.mark.parametrize(
443+
"prices, next_query_time, current_timestamp, expected",
444+
[
445+
pytest.param(
446+
{}, None, 1698224400, True,
447+
id="update_required_when_no_prices"
448+
),
449+
pytest.param(
450+
{"1698224400": 0.1, "1698228000": 0.2}, None, 1698224400, False,
451+
id="no_update_required_when_prices_available_and_recent"
452+
),
453+
pytest.param(
454+
{"1698224400": 0.1, "1698228000": 0.2}, 1698310800, 1698224400, False,
455+
id="no_update_required_when_next_query_time_not_reached"
456+
),
457+
pytest.param(
458+
{"1698224400": 0.1, "1698228000": 0.2}, 1698224000, 1698310800, True,
459+
id="update_required_when_next_query_time_passed"
460+
),
461+
pytest.param(
462+
{"1609459200": 0.1, "1609462800": 0.2}, None, 1698224400, True,
463+
id="update_required_when_prices_from_yesterday"
464+
),
465+
]
466+
)
467+
def test_et_price_update_required(monkeypatch, prices, next_query_time, current_timestamp, expected):
468+
# setup
469+
opt = Optional()
470+
opt.data.et.get.prices = prices
471+
opt.data.et.get.next_query_time = next_query_time
472+
473+
monkeypatch.setattr(timecheck, "create_timestamp", Mock(return_value=current_timestamp))
474+
475+
# execution
476+
result = opt.et_price_update_required()
477+
478+
# evaluation
479+
assert result == expected

packages/helpermodules/setdata.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -849,7 +849,7 @@ def process_optional_topic(self, msg: mqtt.MQTTMessage):
849849
try:
850850
if "openWB/set/optional/et/get/prices" in msg.topic:
851851
self._validate_value(msg, "json")
852-
elif "openWB/set/optional/et/get/price" in msg.topic:
852+
elif "openWB/set/optional/et/get/next_query_time" in msg.topic:
853853
self._validate_value(msg, float)
854854
elif "openWB/set/optional/et/get/fault_state" in msg.topic:
855855
self._validate_value(msg, int, [(0, 2)])

packages/modules/common/configurable_tariff.py

Lines changed: 7 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
from typing import TypeVar, Generic, Callable
2-
from datetime import datetime, timedelta
32
from helpermodules import timecheck
4-
import random
53
import logging
64
from modules.common import store
75
from modules.common.component_context import SingleComponentUpdateContext
@@ -14,21 +12,12 @@
1412
TARIFF_UPDATE_HOUR = 14 # latest expected time for daily tariff update
1513
ONE_HOUR_SECONDS: int = 3600
1614
log = logging.getLogger(__name__)
17-
'''
18-
next_query_time and internal_tariff_state are defined outside of class ConfigurableElectricityTariff because
19-
for an unknown reason defining them as a class variable does not keep their values.
20-
'''
21-
next_query_time: datetime = datetime.fromtimestamp(1)
22-
internal_tariff_state: TariffState = None
2315

2416

2517
class ConfigurableElectricityTariff(Generic[T_TARIFF_CONFIG]):
2618
def __init__(self,
2719
config: T_TARIFF_CONFIG,
2820
component_initializer: Callable[[], float]) -> None:
29-
global internal_tariff_state, next_query_time
30-
next_query_time = datetime.now()
31-
internal_tariff_state = None
3221
self.config = config
3322
self.store = store.get_electricity_tariff_value_store()
3423
self.fault_state = FaultState(ComponentInfo(None, self.config.name, ComponentType.ELECTRICITY_TARIFF.value))
@@ -41,90 +30,30 @@ def __init__(self,
4130
def update(self) -> None:
4231
if hasattr(self, "_component_updater"):
4332
with SingleComponentUpdateContext(self.fault_state):
44-
tariff_state, timeslot_length_seconds = self.__update_et_provider_data(internal_tariff_state)
33+
tariff_state, timeslot_length_seconds = self.__update_et_provider_data()
4534
self.__store_and_publish_updated_data(tariff_state)
46-
self.__log_and_publish_progress(timeslot_length_seconds)
35+
self.__log_and_publish_progress(timeslot_length_seconds, tariff_state)
4736

48-
def __update_et_provider_data(self, tariff_state: TariffState) -> tuple[TariffState, int]:
49-
tariff_state = self.__query_et_provider_data_once_per_day(internal_tariff_state)
37+
def __update_et_provider_data(self) -> tuple[TariffState, int]:
38+
tariff_state = self._component_updater()
5039
timeslot_length_seconds = self.__calculate_price_timeslot_length(tariff_state)
5140
tariff_state = self._remove_outdated_prices(tariff_state, timeslot_length_seconds)
5241
return tariff_state, timeslot_length_seconds
5342

54-
def __query_et_provider_data_once_per_day(self, tariff_state: TariffState) -> TariffState:
55-
if datetime.now() > next_query_time:
56-
return self.__query_et_provider_data(tariff_state=tariff_state)
57-
else:
58-
return tariff_state
59-
60-
def __query_et_provider_data(self, tariff_state: TariffState) -> TariffState:
61-
def is_tomorrow(last_timestamp: str) -> bool:
62-
return (self.__day_of(date=datetime.now()) < self.__day_of(datetime.fromtimestamp(int(last_timestamp)))
63-
or self.__day_of(date=datetime.now()).hour < TARIFF_UPDATE_HOUR)
64-
global next_query_time
65-
log.info(f'Wartezeit {next_query_time.strftime("%Y%m%d-%H:%M:%S")}'
66-
' abgelaufen, Strompreise werden abgefragt'
67-
)
68-
try:
69-
new_tariff_state = self._component_updater()
70-
if 0 < len(new_tariff_state.prices):
71-
if is_tomorrow(self.__get_last_entry_time_stamp(new_tariff_state)):
72-
next_query_time = self.__calulate_next_query_time(new_tariff_state)
73-
log.info('Nächster Abruf der Strompreise'
74-
f' {next_query_time.strftime("%Y%m%d-%H:%M:%S")}.')
75-
else:
76-
log.info('Keine Daten für morgen erhalten, weiterer Versuch in 5 Minuten')
77-
return new_tariff_state
78-
else:
79-
log.warning('Leere Preisliste erhalten, weiterer Versuch in 5 Minuten.')
80-
return tariff_state
81-
except Exception as e:
82-
log.warning(f'Fehler beim Abruf der Strompreise: {e}, nächster Versuch in 5 Minuten.')
83-
self.fault_state.warning(
84-
f'Fehler beim Abruf der Strompreise: {e}, nächster Versuch in 5 Minuten.')
85-
return tariff_state
86-
87-
def __day_of(self, date: datetime) -> datetime:
88-
return date.replace(hour=0, minute=0, second=0, microsecond=0)
89-
90-
def __next_query_message(self) -> str:
91-
tomorrow = (
92-
''
93-
if self.__day_of(datetime.now()) == self.__day_of(next_query_time)
94-
else 'morgen '
95-
)
96-
return (
97-
f'{tomorrow}{next_query_time.strftime("%H:%M")}'
98-
if datetime.now() < next_query_time
99-
else "im nächsten Regelzyklus"
100-
)
101-
102-
def __log_and_publish_progress(self, timeslot_length_seconds):
43+
def __log_and_publish_progress(self, timeslot_length_seconds, tariff_state):
10344
def publish_info(message_extension: str) -> None:
10445
self.fault_state.no_error(
105-
f'Die Preisliste hat {message_extension}{len(internal_tariff_state.prices)} Einträge. '
106-
f'Nächster Abruf der Strompreise {self.__next_query_message()}.')
46+
f'Die Preisliste hat {message_extension}{len(tariff_state.prices)} Einträge. ')
10747
expected_time_slots = int(24 * ONE_HOUR_SECONDS / timeslot_length_seconds)
10848
publish_info(f'nicht {expected_time_slots}, sondern '
109-
if len(internal_tariff_state.prices) < expected_time_slots
49+
if len(tariff_state.prices) < expected_time_slots
11050
else ''
11151
)
11252

11353
def __store_and_publish_updated_data(self, tariff_state: TariffState) -> None:
114-
global internal_tariff_state
115-
internal_tariff_state = tariff_state
11654
self.store.set(tariff_state)
11755
self.store.update()
11856

119-
def __calulate_next_query_time(self, tariff_state: TariffState) -> datetime:
120-
return datetime.fromtimestamp(int(max(tariff_state.prices))).replace(
121-
hour=TARIFF_UPDATE_HOUR, minute=0, second=0
122-
) + timedelta(
123-
# aktually ET providers issue next day prices up to half an hour earlier then 14:00
124-
# reduce serverload on their site by trying early and randomizing query time
125-
minutes=random.randint(1, 7) * -5
126-
)
127-
12857
def __calculate_price_timeslot_length(self, tariff_state: TariffState) -> int:
12958
if (tariff_state is None or
13059
tariff_state.prices is None or
@@ -135,12 +64,6 @@ def __calculate_price_timeslot_length(self, tariff_state: TariffState) -> int:
13564
first_timestamps = list(tariff_state.prices.keys())[:2]
13665
return int(first_timestamps[1]) - int(first_timestamps[0])
13766

138-
def __get_last_entry_time_stamp(self, tariff_state: TariffState) -> str:
139-
last_known_timestamp = "0"
140-
if tariff_state is not None:
141-
last_known_timestamp = max(tariff_state.prices)
142-
return last_known_timestamp
143-
14467
def _remove_outdated_prices(self, tariff_state: TariffState, timeslot_length_seconds: int) -> TariffState:
14568
if tariff_state.prices is None:
14669
self.fault_state.error("no prices to show")

0 commit comments

Comments
 (0)