1
1
"""Component for EPEX Spot support."""
2
2
import logging
3
- from datetime import timedelta
3
+ from datetime import time , timedelta
4
+ from typing import Callable , Dict
4
5
6
+ import homeassistant .helpers .config_validation as cv
7
+ import voluptuous as vol
5
8
from homeassistant .config_entries import ConfigEntry
6
- from homeassistant .core import HomeAssistant , callback
9
+ from homeassistant .const import ATTR_DEVICE_ID
10
+ from homeassistant .core import (
11
+ HomeAssistant ,
12
+ ServiceCall ,
13
+ ServiceResponse ,
14
+ SupportsResponse ,
15
+ callback ,
16
+ )
17
+ from homeassistant .exceptions import HomeAssistantError
18
+ from homeassistant .helpers import device_registry as dr
7
19
from homeassistant .helpers .dispatcher import dispatcher_send
8
20
from homeassistant .helpers .event import async_track_time_change
9
21
from homeassistant .util import dt
10
22
11
- from .const import (CONF_MARKET_AREA , CONF_SOURCE , CONF_SOURCE_AWATTAR ,
12
- CONF_SOURCE_EPEX_SPOT_WEB , CONF_SOURCE_SMARD_DE ,
13
- CONF_SURCHARGE_ABS , CONF_SURCHARGE_PERC , CONF_TAX ,
14
- DEFAULT_SURCHARGE_ABS , DEFAULT_SURCHARGE_PERC , DEFAULT_TAX ,
15
- DOMAIN , UPDATE_SENSORS_SIGNAL )
23
+ from .const import (
24
+ CONF_DURATION ,
25
+ CONF_EARLIEST_START ,
26
+ CONF_LATEST_END ,
27
+ CONF_MARKET_AREA ,
28
+ CONF_SOURCE ,
29
+ CONF_SOURCE_AWATTAR ,
30
+ CONF_SOURCE_EPEX_SPOT_WEB ,
31
+ CONF_SOURCE_SMARD_DE ,
32
+ CONF_SURCHARGE_ABS ,
33
+ CONF_SURCHARGE_PERC ,
34
+ CONF_TAX ,
35
+ DEFAULT_SURCHARGE_ABS ,
36
+ DEFAULT_SURCHARGE_PERC ,
37
+ DEFAULT_TAX ,
38
+ DOMAIN ,
39
+ UPDATE_SENSORS_SIGNAL ,
40
+ )
16
41
from .EPEXSpot import SMARD , Awattar , EPEXSpotWeb
42
+ from .extreme_price_interval import find_extreme_price_interval , get_start_times
17
43
18
44
_LOGGER = logging .getLogger (__name__ )
19
45
20
-
21
46
PLATFORMS = ["sensor" ]
22
47
48
+ GET_EXTREME_PRICE_INTERVAL_SCHEMA = vol .Schema (
49
+ {
50
+ ** cv .ENTITY_SERVICE_FIELDS , # for device_id
51
+ vol .Optional (CONF_EARLIEST_START ): cv .time ,
52
+ vol .Optional (CONF_LATEST_END ): cv .time ,
53
+ vol .Required (CONF_DURATION ): cv .positive_time_period ,
54
+ }
55
+ )
56
+
57
+ EMPTY_EXTREME_PRICE_INTERVAL_RESP = {
58
+ "start" : None ,
59
+ "end" : None ,
60
+ "price_eur_per_mwh" : None ,
61
+ "price_ct_per_kwh" : None ,
62
+ "net_price_ct_per_kwh" : None ,
63
+ }
64
+
23
65
24
66
async def async_setup_entry (hass : HomeAssistant , entry : ConfigEntry ) -> bool :
25
- """Set up component from a config entry, config_entry contains data from config entry database."""
67
+ """Set up component from a config entry, config_entry contains data
68
+ from config entry database."""
26
69
# store shell object
27
70
shell = hass .data .setdefault (DOMAIN , EpexSpotShell (hass ))
28
71
@@ -33,6 +76,53 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
33
76
34
77
entry .async_on_unload (entry .add_update_listener (on_update_options_listener ))
35
78
79
+ async def get_lowest_price_interval (call : ServiceCall ) -> ServiceResponse :
80
+ """Get the time interval during which the price is at its lowest point."""
81
+ return _find_extreme_price_interval (call , lambda a , b : a < b )
82
+
83
+ async def get_highest_price_interval (call : ServiceCall ) -> ServiceResponse :
84
+ """Get the time interval during which the price is at its highest point."""
85
+ return _find_extreme_price_interval (call , lambda a , b : a > b )
86
+
87
+ def _find_extreme_price_interval (
88
+ call : ServiceCall , cmp : Callable [[float , float ], bool ]
89
+ ) -> ServiceResponse :
90
+ if ATTR_DEVICE_ID in call .data :
91
+ device_id = call .data [ATTR_DEVICE_ID ][0 ]
92
+ device_registry = dr .async_get (hass )
93
+ if not (device_entry := device_registry .async_get (device_id )):
94
+ raise HomeAssistantError (f"No device found for device id: { device_id } " )
95
+ source = shell .get_source_by_config_entry_id (
96
+ next (iter (device_entry .config_entries ))
97
+ )
98
+ else :
99
+ source = next (iter (shell ._sources .values ()))
100
+
101
+ if source is None :
102
+ return EMPTY_EXTREME_PRICE_INTERVAL_RESP
103
+
104
+ earliest_start_time = call .data .get (CONF_EARLIEST_START )
105
+ latest_end_time = call .data .get (CONF_LATEST_END )
106
+ duration = call .data [CONF_DURATION ]
107
+ return source .find_extreme_price_interval (
108
+ earliest_start_time , latest_end_time , duration , cmp
109
+ )
110
+
111
+ hass .services .async_register (
112
+ DOMAIN ,
113
+ "get_lowest_price_interval" ,
114
+ get_lowest_price_interval ,
115
+ schema = GET_EXTREME_PRICE_INTERVAL_SCHEMA ,
116
+ supports_response = SupportsResponse .ONLY ,
117
+ )
118
+ hass .services .async_register (
119
+ DOMAIN ,
120
+ "get_highest_price_interval" ,
121
+ get_highest_price_interval ,
122
+ schema = GET_EXTREME_PRICE_INTERVAL_SCHEMA ,
123
+ supports_response = SupportsResponse .ONLY ,
124
+ )
125
+
36
126
return True
37
127
38
128
@@ -70,6 +160,10 @@ def __init__(self, config_entry, source):
70
160
def unique_id (self ):
71
161
return self ._config_entry .unique_id
72
162
163
+ @property
164
+ def config_entry_id (self ):
165
+ return self ._config_entry .entry_id
166
+
73
167
@property
74
168
def name (self ):
75
169
return self ._source .name
@@ -88,6 +182,7 @@ def marketdata_now(self):
88
182
89
183
@property
90
184
def sorted_marketdata_today (self ):
185
+ """Sorted by price."""
91
186
return self ._sorted_marketdata_today
92
187
93
188
def fetch (self ):
@@ -142,14 +237,36 @@ def to_net_price(self, price_eur_per_mwh):
142
237
143
238
return net_p
144
239
240
+ def find_extreme_price_interval (
241
+ self , earliestStartTime : time , latestEndTime : time , duration : timedelta , cmp
242
+ ):
243
+ priceMap = {item .start_time : item .price_eur_per_mwh for item in self .marketdata }
244
+
245
+ startTimes = get_start_times (
246
+ earliestStartTime , latestEndTime , self .marketdata [- 1 ].end_time , duration
247
+ )
248
+
249
+ result = find_extreme_price_interval (priceMap , startTimes , duration , cmp )
250
+
251
+ if result is None :
252
+ return EMPTY_EXTREME_PRICE_INTERVAL_RESP
253
+
254
+ return {
255
+ "start" : result ["start" ],
256
+ "end" : result ["start" ] + duration ,
257
+ "price_eur_per_mwh" : result ["price" ],
258
+ "price_ct_per_kwh" : result ["price" ] / 10 ,
259
+ "net_price_ct_per_kwh" : self .to_net_price (result ["price" ]),
260
+ }
261
+
145
262
146
263
class EpexSpotShell :
147
264
"""Shell object for EPEX Spot. Stored in hass.data."""
148
265
149
266
def __init__ (self , hass : HomeAssistant ):
150
267
"""Initialize the instance."""
151
268
self ._hass = hass
152
- self ._sources = {}
269
+ self ._sources : Dict [ str , SourceDecorator ] = {}
153
270
self ._timer_listener_hour_change = None
154
271
self ._timer_listener_fetch = None
155
272
@@ -159,6 +276,12 @@ def is_idle(self) -> bool:
159
276
def get_source (self , unique_id ):
160
277
return self ._sources [unique_id ]
161
278
279
+ def get_source_by_config_entry_id (self , entry_id ):
280
+ for s in self ._sources .values ():
281
+ if s .config_entry_id == entry_id :
282
+ return s
283
+ return None
284
+
162
285
def add_entry (self , config_entry : ConfigEntry ):
163
286
"""Add entry."""
164
287
is_idle = self .is_idle ()
0 commit comments