import asyncio import logging from collections import defaultdict from datetime import date, datetime, timedelta from datetime import timezone as ts # import aiohttp # import backoff from dateutil.parser import parse as parse_dt from homeassistant.util import dt as dt_utils # from nordpool.elspot import Prices from pytz import timezone, utc from .misc import add_junk from .const import tzs, INVALID_VALUES _LOGGER = logging.getLogger(__name__) class InvalidValueException(ValueError): pass class CurrencyMismatch(ValueError): # pylint: disable=missing-class-docstring pass async def join_result_for_correct_time(results, dt): """Parse a list of responses from the api to extract the correct hours in there timezone. """ # utc = datetime.utcnow() fin = defaultdict(dict) # _LOGGER.debug("join_result_for_correct_time %s", dt) if dt is None: utc = datetime.now(ts.utc) else: utc = dt for day_ in results: for key, value in day_.get("areas", {}).items(): zone = tzs.get(key) if zone is None: _LOGGER.debug("Skipping %s", key) continue else: zone = await dt_utils.async_get_time_zone(zone) # We add junk here as the peak etc # from the api is based on cet, not the # hours in the we want so invalidate them # its later corrected in the sensor. value = add_junk(value) values = day_["areas"][key].pop("values") # We need to check this so we dont overwrite stuff. if key not in fin["areas"]: fin["areas"][key] = {} fin["areas"][key].update(value) if "values" not in fin["areas"][key]: fin["areas"][key]["values"] = [] start_of_day = utc.astimezone(zone).replace( hour=0, minute=0, second=0, microsecond=0 ) end_of_day = utc.astimezone(zone).replace( hour=23, minute=59, second=59, microsecond=999999 ) for val in values: local = val["start"].astimezone(zone) local_end = val["end"].astimezone(zone) if start_of_day <= local <= end_of_day: if local == local_end: _LOGGER.info( "Hour has the same start and end, most likly due to dst change %s exluded this hour", val, ) elif val["value"] in INVALID_VALUES: raise InvalidValueException( f"Invalid value in {val} for area '{key}'" ) else: fin["areas"][key]["values"].append(val) return fin class AioPrices: """Interface""" def __init__(self, currency, client, timeezone=None): # super().__init__(currency) self.client = client self.timeezone = timeezone (self.HOURLY, self.DAILY, self.WEEKLY, self.MONTHLY, self.YEARLY) = ( "DayAheadPrices", "AggregatePrices", "AggregatePrices", "AggregatePrices", "AggregatePrices/GetAnnuals", ) self.API_URL = "https://dataportal-api.nordpoolgroup.com/api/%s" self.currency = currency async def _io(self, url, **kwargs): resp = await self.client.get(url, params=kwargs) _LOGGER.debug("requested %s %s", resp.url, kwargs) if resp.status == 204: return None return await resp.json() def _parse_dt(self, time_str): """Parse datetimes to UTC from Stockholm time, which Nord Pool uses.""" time = parse_dt(time_str, tzinfos={"Z": timezone("Europe/Stockholm")}) if time.tzinfo is None: return timezone("Europe/Stockholm").localize(time).astimezone(utc) return time.astimezone(utc) def _parse_json(self, data, areas=None, data_type=None): """ Parse json response from fetcher. Returns dictionary with - start time - end time - update time - currency - dictionary of areas, based on selection - list of values (dictionary with start and endtime and value) - possible other values, such as min, max, average for hourly """ if areas is None: areas = [] if not isinstance(areas, list) and areas is not None: areas = [i.strip() for i in areas.split(",")] _LOGGER.debug("data type in _parser %s, areas %s", data_type, areas) # Ripped from Kipe's nordpool if data_type == self.HOURLY: data_source = ("multiAreaEntries", "entryPerArea") elif data_type == self.DAILY: data_source = ("multiAreaDailyAggregates", "averagePerArea") elif data_type == self.WEEKLY: data_source = ("multiAreaWeeklyAggregates", "averagePerArea") elif data_type == self.MONTHLY: data_source = ("multiAreaMonthlyAggregates", "averagePerArea") elif data_type == self.YEARLY: data_source = ("prices", "averagePerArea") else: data_source = ("multiAreaEntries", "entryPerArea") if data.get("status", 200) != 200 and "version" not in data: raise Exception(f"Invalid response from Nordpool API: {data}") # Update currency from data # currency it not avaiable in yearly... We just have to trust that the one # we set in the class is correct. currency = data.get("currency", self.currency) # Ensure that the provided currency match the requested one if currency != self.currency: raise CurrencyMismatch start_time = None end_time = None # multiAreaDailyAggregates if len(data[data_source[0]]) > 0: start_time = self._parse_dt(data[data_source[0]][0]["deliveryStart"]) end_time = self._parse_dt(data[data_source[0]][-1]["deliveryEnd"]) updated = self._parse_dt(data["updatedAt"]) area_data = {} # Loop through response rows for r in data[data_source[0]]: row_start_time = self._parse_dt(r["deliveryStart"]) row_end_time = self._parse_dt(r["deliveryEnd"]) # Loop through columns for area_key in r[data_source[1]].keys(): area_price = r[data_source[1]][area_key] # If areas is defined and name isn't in areas, skip column if area_key not in areas: continue # If name isn't in area_data, initialize dictionary if area_key not in area_data: area_data[area_key] = { "values": [], } # Append dictionary to value list area_data[area_key]["values"].append( { "start": row_start_time, "end": row_end_time, "value": self._conv_to_float(area_price), } ) return { "start": start_time, "end": end_time, "updated": updated, "currency": currency, "areas": area_data, } async def _fetch_json(self, data_type, end_date=None, areas=None): """Fetch JSON from API""" # If end_date isn't set, default to tomorrow if data_type is None: data_type = self.HOURLY if areas is None or len(areas) == 0: raise Exception("Cannot query with empty areas") if end_date is None: end_date = date.today() + timedelta(days=1) # If end_date isn't a date or datetime object, try to parse a string if not isinstance(end_date, date) and not isinstance(end_date, datetime): end_date = parse_dt(end_date) if not isinstance(areas, list) and areas is not None: areas = [i.strip() for i in areas.split(",")] kws = { "currency": self.currency, "market": "DayAhead", "deliveryArea": ",".join(areas), # This one is default for hourly.. "date": end_date.strftime("%Y-%m-%d"), } if data_type != self.HOURLY: kws.pop("date") kws["year"] = end_date.strftime("%Y") return await self._io(self.API_URL % data_type, **kws) # Add more exceptions as we find them. KeyError is raised when the api return # junk due to currency not being available in the data. # @backoff.on_exception( # backoff.expo, (aiohttp.ClientError, KeyError), logger=_LOGGER, max_value=20 # ) async def fetch(self, data_type, end_date=None, areas=None, raw=False): """ Fetch data from API. Inputs: - data_type API page id, one of Prices.HOURLY, Prices.DAILY etc - end_date datetime to end the data fetching defaults to tomorrow - areas list of areas to fetch, such as ['SE1', 'SE2', 'FI'] defaults to all areas Returns dictionary with - start time - end time - update time - currency - dictionary of areas, based on selection - list of values (dictionary with start and endtime and value) - possible other values, such as min, max, average for hourly """ if areas is None: areas = [] if end_date is None: end_date = datetime.now() if isinstance(end_date, str): end_date = parse_dt(end_date) today = end_date yesterday = today - timedelta(days=1) tomorrow = today + timedelta(days=1) if data_type == self.HOURLY: if raw: return await self._fetch_json(data_type, today, areas) jobs = [ self._fetch_json(data_type, yesterday, areas), self._fetch_json(data_type, today, areas), self._fetch_json(data_type, tomorrow, areas), ] else: # This is really not today but a year.. # All except from hourly returns the raw values return await self._fetch_json(data_type, today, areas) res = await asyncio.gather(*jobs) raw = [ await self._async_parse_json(i, areas, data_type=data_type) for i in res if i ] return await join_result_for_correct_time(raw, end_date) async def _async_parse_json(self, data, areas, data_type): """ Async version of _parse_json to prevent blocking calls inside the event loop. """ loop = asyncio.get_running_loop() return await loop.run_in_executor( None, self._parse_json, data, areas, data_type ) async def hourly(self, end_date=None, areas=None, raw=False): """Helper to fetch hourly data, see Prices.fetch()""" if areas is None: areas = [] return await self.fetch(self.HOURLY, end_date, areas, raw=raw) async def daily(self, end_date=None, areas=None): """Helper to fetch daily data, see Prices.fetch()""" if areas is None: areas = [] return await self.fetch(self.DAILY, end_date, areas) async def weekly(self, end_date=None, areas=None): """Helper to fetch weekly data, see Prices.fetch()""" if areas is None: areas = [] return await self.fetch(self.WEEKLY, end_date, areas) async def monthly(self, end_date=None, areas=None): """Helper to fetch monthly data, see Prices.fetch()""" if areas is None: areas = [] return await self.fetch(self.MONTHLY, end_date, areas) async def yearly(self, end_date=None, areas=None): """Helper to fetch yearly data, see Prices.fetch()""" if areas is None: areas = [] return await self.fetch(self.YEARLY, end_date, areas) def _conv_to_float(self, s): """Convert numbers to float. Return infinity, if conversion fails.""" # Skip if already float if isinstance(s, float): return s try: return float(s.replace(",", ".").replace(" ", "")) except ValueError: return float("inf")