33import enum
44import logging
55import xml .etree .ElementTree as ET
6+ from collections import defaultdict
67from datetime import datetime , timedelta
78from typing import Dict , Union
89
910import pytz
1011import 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__ )
1318URL = "https://web-api.tp.entsoe.eu/api"
1419DATETIMEFORMAT = "%Y%m%d%H00"
1520
1621
1722class 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
189196class Area (enum .Enum ):
0 commit comments