Skip to content

Commit bd14410

Browse files
authored
Merge pull request #247 from Matthijsy/hotfix/15m-data
Parse data based on 15M data, and allow for configuring averaging to 60M
2 parents 712d77b + 00ff9df commit bd14410

File tree

7 files changed

+168
-97
lines changed

7 files changed

+168
-97
lines changed

custom_components/entsoe/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
DEFAULT_MODIFYER,
2121
DEFAULT_ENERGY_SCALE,
2222
DOMAIN,
23+
CONF_PERIOD,
2324
)
2425
from .coordinator import EntsoeCoordinator
2526
from .services import async_setup_services
@@ -42,6 +43,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
4243
# Initialise the coordinator and save it as domain-data
4344
api_key = entry.options[CONF_API_KEY]
4445
area = entry.options[CONF_AREA]
46+
period = entry.options.get(CONF_PERIOD, "PT60M")
4547
energy_scale = entry.options.get(CONF_ENERGY_SCALE, DEFAULT_ENERGY_SCALE)
4648
modifyer = entry.options.get(CONF_MODIFYER, DEFAULT_MODIFYER)
4749
vat = entry.options.get(CONF_VAT_VALUE, 0)
@@ -52,6 +54,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
5254
hass,
5355
api_key=api_key,
5456
area=area,
57+
period=period,
5558
energy_scale=energy_scale,
5659
modifyer=modifyer,
5760
calculation_mode=calculation_mode,

custom_components/entsoe/api_client.py

Lines changed: 62 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,29 @@
33
import enum
44
import logging
55
import xml.etree.ElementTree as ET
6+
from collections import defaultdict
67
from datetime import datetime, timedelta
78
from typing import Dict, Union
89

910
import pytz
1011
import requests
1112

13+
from custom_components.entsoe.const import DEFAULT_PERIOD
14+
from custom_components.entsoe.utils import get_interval_minutes
15+
from .utils import bucket_time
16+
1217
_LOGGER = logging.getLogger(__name__)
1318
URL = "https://web-api.tp.entsoe.eu/api"
1419
DATETIMEFORMAT = "%Y%m%d%H00"
1520

1621

1722
class EntsoeClient:
1823

19-
def __init__(self, api_key: str):
24+
def __init__(self, api_key: str, period: str = DEFAULT_PERIOD) -> None:
2025
if api_key == "":
2126
raise TypeError("API key cannot be empty")
2227
self.api_key = api_key
28+
self.configuration_period = period
2329

2430
def _base_request(
2531
self, params: Dict, start: datetime, end: datetime
@@ -48,7 +54,7 @@ def _remove_namespace(self, tree):
4854

4955
def query_day_ahead_prices(
5056
self, country_code: Union[Area, str], start: datetime, end: datetime
51-
) -> str:
57+
) -> dict:
5258
"""
5359
Parameters
5460
----------
@@ -74,14 +80,16 @@ def query_day_ahead_prices(
7480
return dict(sorted(series.items()))
7581

7682
except Exception as exc:
77-
_LOGGER.debug(f"Failed to parse response content:{response.content}")
83+
_LOGGER.debug(
84+
f"Failed to parse response content error: {exc} content:{response.content}"
85+
)
7886
raise exc
7987
else:
80-
print(f"Failed to retrieve data: {response.status_code}")
88+
_LOGGER.error(f"Failed to retrieve data: {response.status_code}")
8189
return None
8290

8391
# lets process the received document
84-
def parse_price_document(self, document: str) -> str:
92+
def parse_price_document(self, document: str) -> dict:
8593

8694
root = self._remove_namespace(ET.fromstring(document))
8795
_LOGGER.debug(f"content: {root}")
@@ -125,65 +133,64 @@ def parse_price_document(self, document: str) -> str:
125133
)
126134
continue
127135

128-
if resolution == "PT60M":
129-
series.update(self.process_PT60M_points(period, start_time))
130-
else:
131-
series.update(self.process_PT15M_points(period, start_time))
132-
133-
# Now fill in any missing hours
134-
current_time = start_time
135-
last_price = series[current_time]
136-
137-
while current_time < end_time: # upto excluding! the endtime
138-
if current_time in series:
139-
last_price = series[current_time] # Update to the current price
140-
else:
141-
_LOGGER.debug(
142-
f"Extending the price {last_price} of the previous hour to {current_time}"
143-
)
144-
series[current_time] = (
145-
last_price # Fill with the last known price
146-
)
147-
current_time += timedelta(hours=1)
148-
136+
# Parse the resolution, we only support the 'PTxM' format
137+
interval = get_interval_minutes(resolution)
138+
data = self.process_points(period, start_time, interval)
139+
if resolution != self.configuration_period:
140+
_LOGGER.debug(
141+
f"Got {interval} minutes interval prices, but period is configured on {self.configuration_period} minutes. Averaging data into intervals of {self.configuration_period} minutes."
142+
)
143+
data = self.average_to_interval(
144+
data,
145+
expected_interval=get_interval_minutes(
146+
self.configuration_period
147+
),
148+
)
149+
series.update(data)
149150
return series
150151

151152
# processing hourly prices info -> thats easy
152-
def process_PT60M_points(self, period: Element, start_time: datetime):
153-
data = {}
154-
for point in period.findall(".//Point"):
155-
position = point.find(".//position").text
156-
price = point.find(".//price.amount").text
157-
hour = int(position) - 1
158-
time = start_time + timedelta(hours=hour)
159-
data[time] = float(price)
160-
return data
153+
def process_points(
154+
self, period: Element, start_time: datetime, interval: int
155+
) -> dict:
156+
_LOGGER.debug(f"Processing prices based on interval {interval} minutes")
157+
# Extract (position, price) pairs
158+
points = sorted(
159+
(int(p.findtext(".//position")), float(p.findtext(".//price.amount")))
160+
for p in period.findall(".//Point")
161+
)
162+
if not points:
163+
return {}
161164

162-
# processing quarterly prices -> this is more complex
163-
def process_PT15M_points(self, period: Element, start_time: datetime):
164-
positions = {}
165+
data = {}
166+
last_price = None
167+
for pos in range(points[0][0], points[-1][0] + 1):
168+
if points and pos == points[0][0]:
169+
last_price = points.pop(0)[1]
170+
data[start_time + timedelta(minutes=(pos - 1) * interval)] = last_price
165171

166-
# first store all positions
167-
for point in period.findall(".//Point"):
168-
position = point.find(".//position").text
169-
price = point.find(".//price.amount").text
170-
positions[int(position)] = float(price)
172+
return data
171173

172-
# now calculate hourly averages based on available points
173-
data = {}
174-
last_hour = (max(positions.keys()) + 3) // 4
175-
last_price = 0
174+
def average_to_interval(self, data: dict, expected_interval: int) -> dict:
175+
"""
176+
Average prices into the expected interval buckets
176177
177-
for hour in range(last_hour):
178-
sum_prices = 0
179-
for idx in range(hour * 4 + 1, hour * 4 + 5):
180-
last_price = positions.get(idx, last_price)
181-
sum_prices += last_price
178+
args:
179+
data: The data to average
180+
expected_interval: The interval in minutes after transformation (e.g. 30, 60)
181+
"""
182182

183-
time = start_time + timedelta(hours=hour)
184-
data[time] = round(sum_prices / 4, 2)
183+
# Create buckets of expected_interval
184+
by_hour = defaultdict(list)
185+
for timestamp, price in data.items():
186+
bucket = bucket_time(timestamp, expected_interval)
187+
by_hour[bucket].append(price)
185188

186-
return data
189+
# Calculate the average for each bucket
190+
return {
191+
hour: round(sum(prices) / len(prices), 2)
192+
for hour, prices in by_hour.items()
193+
}
187194

188195

189196
class Area(enum.Enum):

custom_components/entsoe/config_flow.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@
3838
DOMAIN,
3939
ENERGY_SCALES,
4040
UNIQUE_ID,
41+
CONF_PERIOD,
42+
PERIOD_OPTIONS,
43+
DEFAULT_PERIOD,
4144
)
4245

4346

@@ -102,6 +105,7 @@ async def async_step_user(
102105
options={
103106
CONF_API_KEY: user_input[CONF_API_KEY],
104107
CONF_AREA: user_input[CONF_AREA],
108+
CONF_PERIOD: user_input[CONF_PERIOD],
105109
CONF_MODIFYER: user_input[CONF_MODIFYER],
106110
CONF_CURRENCY: user_input[CONF_CURRENCY],
107111
CONF_ENERGY_SCALE: user_input[CONF_ENERGY_SCALE],
@@ -129,6 +133,9 @@ async def async_step_user(
129133
]
130134
),
131135
),
136+
vol.Required(CONF_PERIOD): SelectSelector(
137+
SelectSelectorConfig(options=PERIOD_OPTIONS),
138+
),
132139
vol.Optional(CONF_ADVANCED_OPTIONS, default=False): bool,
133140
},
134141
),
@@ -316,7 +323,9 @@ async def async_step_init(
316323
): vol.All(vol.Coerce(float, "must be a number")),
317324
vol.Optional(
318325
CONF_MODIFYER,
319-
description={"suggested_value": self.config_entry.options[CONF_MODIFYER]},
326+
description={
327+
"suggested_value": self.config_entry.options[CONF_MODIFYER]
328+
},
320329
default=DEFAULT_MODIFYER,
321330
): TemplateSelector(TemplateSelectorConfig()),
322331
vol.Optional(
@@ -331,6 +340,12 @@ async def async_step_init(
331340
CONF_ENERGY_SCALE, DEFAULT_ENERGY_SCALE
332341
),
333342
): vol.In(list(ENERGY_SCALES.keys())),
343+
vol.Optional(
344+
CONF_PERIOD,
345+
default=self.config_entry.options.get(
346+
CONF_PERIOD, DEFAULT_PERIOD
347+
),
348+
): vol.In(PERIOD_OPTIONS),
334349
vol.Optional(
335350
CONF_CALCULATION_MODE,
336351
default=calculation_mode_default,

custom_components/entsoe/const.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
CONF_API_KEY = "api_key"
99
CONF_ENTITY_NAME = "name"
1010
CONF_AREA = "area"
11+
CONF_PERIOD = "period"
1112
CONF_MODIFYER = "modifyer"
1213
CONF_CURRENCY = "currency"
1314
CONF_ENERGY_SCALE = "energy_scale"
@@ -18,6 +19,7 @@
1819
DEFAULT_MODIFYER = "{{current_price}}"
1920
DEFAULT_CURRENCY = CURRENCY_EURO
2021
DEFAULT_ENERGY_SCALE = "kWh"
22+
DEFAULT_PERIOD = "PT60M"
2123

2224
# default is only for internal use / backwards compatibility
2325
CALCULATION_MODE = {
@@ -27,7 +29,8 @@
2729
"publish": "publish",
2830
}
2931

30-
ENERGY_SCALES = { "kWh": 1000, "MWh": 1 }
32+
ENERGY_SCALES = {"kWh": 1000, "MWh": 1}
33+
PERIOD_OPTIONS = ["PT60M", "PT15M"]
3134

3235
# Commented ones are not working at entsoe
3336
AREA_INFO = {

0 commit comments

Comments
 (0)