Files
HomeAssistant/custom_components/nordpool/aio_price.py
T
2025-09-11 10:47:34 +03:00

356 lines
12 KiB
Python

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")