Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 44 additions & 28 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,47 +15,63 @@ To install from [PyPi](https://pypi.org/project/nordpool/), use

`pip install nordpool`

### To upgrade
## To upgrade

To upgrade installation from PyPi, use
To upgrade installation from [PyPi](https://pypi.org/project/nordpool/), use

`pip install -U nordpool`

#### Example
## Usage example

Below is a very basic example of the library usage. More advanced example(s) can be found in `examples` -directory.

```python
# Import library for fetching Elspot data
from nordpool import elspot
from pprint import pprint
from nordpool import elspot

# Initialize class for fetching Elspot prices
# Initialize class for fetching the prices.
# An optional currency parameter can be provided, default is EUR.
prices_spot = elspot.Prices()

# Fetch hourly Elspot prices for Finland and print the resulting dictionary
# Fetch tomorrow's prices for Finland and print the resulting dictionary.
# If the prices are reported as None, it means that the prices fetched aren't yet available.
# The library by default tries to fetch prices for tomorrow and they're released ~13:00 Swedish time.
pprint(prices_spot.hourly(areas=['FI']))
pprint(prices_spot.fetch(areas=["FI"]))
```

###### Output

Output:
```python
{u'areas': {
u'FI': {
u'values': [
{u'end': datetime.datetime(2014, 10, 3, 23, 0, tzinfo=<UTC>),
u'start': datetime.datetime(2014, 10, 3, 22, 0, tzinfo=<UTC>),
u'value': 31.2},
{u'end': datetime.datetime(2014, 10, 4, 0, 0, tzinfo=<UTC>),
u'start': datetime.datetime(2014, 10, 3, 23, 0, tzinfo=<UTC>),
u'value': 30.68},
... SNIP ...
{u'end': datetime.datetime(2014, 10, 4, 22, 0, tzinfo=<UTC>),
u'start': datetime.datetime(2014, 10, 4, 21, 0, tzinfo=<UTC>),
u'value': 30.82}]}},
u'currency': u'EUR',
u'end': datetime.datetime(2014, 10, 4, 22, 0, tzinfo=<UTC>),
u'start': datetime.datetime(2014, 10, 3, 22, 0, tzinfo=<UTC>),
u'updated': datetime.datetime(2014, 10, 3, 10, 42, 42, 110000, tzinfo=<UTC>)}
...
{
"areas": {
"FI": {
"values": [
{
"end": datetime.datetime(2025, 5, 12, 23, 0, tzinfo=tzutc()),
"start": datetime.datetime(2025, 5, 12, 22, 0, tzinfo=tzutc()),
"value": 5.11,
},
{
"end": datetime.datetime(2025, 5, 13, 0, 0, tzinfo=tzutc()),
"start": datetime.datetime(2025, 5, 12, 23, 0, tzinfo=tzutc()),
"value": 5.8,
},
{
"end": datetime.datetime(2025, 5, 13, 1, 0, tzinfo=tzutc()),
"start": datetime.datetime(2025, 5, 13, 0, 0, tzinfo=tzutc()),
"value": 4.51,
},
# ... SNIP ...
{
"end": datetime.datetime(2025, 5, 13, 22, 0, tzinfo=tzutc()),
"start": datetime.datetime(2025, 5, 13, 21, 0, tzinfo=tzutc()),
"value": -10.24,
},
]
}
},
"currency": "EUR",
"end": datetime.datetime(2025, 5, 13, 22, 0, tzinfo=tzutc()),
"start": datetime.datetime(2025, 5, 12, 22, 0, tzinfo=tzutc()),
"updated": datetime.datetime(2025, 5, 12, 11, 26, 3, 811220, tzinfo=tzutc()),
}
```
43 changes: 43 additions & 0 deletions examples/advanced.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
#! /usr/bin/env python3

# Import library for fetching Elspot data
from datetime import date
from nordpool import elspot

# Initialize class for fetching Elspot prices.
# An optional currency parameter can be provided, default is EUR.
prices_spot = elspot.Prices("SEK") # Fetch prices in Swedish kronor

# Fetch prices for today in Swedish pricing areas 2 and 4 with 15 minute resolution.
price = prices_spot.fetch(
# Need to specify end_date to fetch prices for today,
# as otherwise the library defaults to tomorrow.
end_date=date.today(),
# Set areas to fetch the prices for, library defaults to all areas.
areas=["SE2", "SE4"],
# Set resolution to 15 minutes, library defaults to 60 minutes.
resolution=15,
)

# Get basic info about the price data.
# Note: The timestamps are timezone-aware.
start = price["start"].strftime("%Y-%m-%d %H:%M %Z")
end = price["end"].strftime("%Y-%m-%d %H:%M %Z")
updated = price["updated"].strftime("%Y-%m-%d %H:%M %Z")
currency = price["currency"]

print(f"Energy prices for the period {start} to {end}.")
print(f"Last updated: {updated}.")
print(f"Currency: {currency}.")
print()

# Loop through each area and print the prices.
for area, area_data in price["areas"].items():
print(f"Area: {area}")
print("-" * 40)
for entry in area_data["values"]:
start = entry["start"].strftime("%H:%M %Z")
end = entry["end"].strftime("%H:%M %Z")
value = entry["value"]
print(f"{start} - {end}: {value:.2f} {currency}/MWh")
print()
10 changes: 5 additions & 5 deletions example.py → examples/basic.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
#!/usr/bin/env python

# Import library for fetching Elspot data
from nordpool import elspot
from pprint import pprint
from nordpool import elspot

# Initialize class for fetching Elspot prices
# Initialize class for fetching the prices.
# An optional currency parameter can be provided, default is EUR.
prices_spot = elspot.Prices()

# Fetch hourly Elspot prices for Finland and print the resulting dictionary.
# Fetch tomorrow's prices for Finland and print the resulting dictionary.
# If the prices are reported as None, it means that the prices fetched aren't yet available.
# The library by default tries to fetch prices for tomorrow and they're released ~13:00 Swedish time.
pprint(prices_spot.hourly(areas=["FI"]))
pprint(prices_spot.fetch(areas=["FI"]))
94 changes: 78 additions & 16 deletions nordpool/elspot.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,17 @@
from dateutil.parser import parse as parse_dt


class CurrencyMismatch(ValueError): # pylint: disable=missing-class-docstring
pass
class UnsupportedResolution(ValueError):
"""
Raised when the requested resolution is not supported.
Supported resolutions are 15, 30 and 60 minutes.
"""


class CurrencyMismatch(ValueError):
"""
Raised when the currency of the data does not match the currency of the request.
"""


class Prices:
Expand All @@ -30,6 +39,7 @@ class Prices:
"NO2",
"NO3",
"NO4",
"NO5",
"SE1",
"SE2",
"SE3",
Expand All @@ -38,17 +48,24 @@ class Prices:
"EE",
"LT",
"LV",
# CWE
# Central Western Europe
"AT",
"BE",
"FR",
"GER",
"NL",
"PL",
# South East Europe
"BG",
"TEL",
# Nordic system price
# NOTE: Some API endpoints use "SYSTEM" for "SYS",
# this is handled internally and area "SYS" should be used by external code
"SYS",
]

SUPPORTED_RESOLUTIONS = [15, 30, 60]

def __init__(self, currency="EUR", timeout=None):
self.currency = currency
self.timeout = timeout or 2
Expand Down Expand Up @@ -83,7 +100,7 @@ def _parse_json(self, data, data_type, areas):
currency = data.get("currency", self.currency)

area_prices = {}
data_source = ("multiAreaEntries", "entryPerArea") # Defaults to HOURLY
data_source = ("multiIndexEntries", "entryPerArea") # Defaults to HOURLY
if data_type == self.DAILY:
data_source = ("multiAreaDailyAggregates", "averagePerArea")
if data_type == self.WEEKLY:
Expand All @@ -97,6 +114,10 @@ def _parse_json(self, data, data_type, areas):
start = parse_dt(entry["deliveryStart"])
end = parse_dt(entry["deliveryEnd"])
for area, price in entry.get(data_source[1], {}).items():
# Price indices -endpoint uses "SYSTEM" for "SYS"
# -> replace it when responding
if area == "SYSTEM":
area = "SYS"
if area not in areas:
continue # pragma: no cover
if area not in area_prices:
Expand All @@ -115,6 +136,10 @@ def _parse_json(self, data, data_type, areas):
if currency != self.currency:
raise CurrencyMismatch # pragma: no cover

if not area_prices:
# No data found, behavior changed when moving to using price indices
return None

return {
"start": start_time,
"end": end_time,
Expand All @@ -123,7 +148,13 @@ def _parse_json(self, data, data_type, areas):
"areas": area_prices,
}

def _get_url_params_areas(self, data_type, end_date=None, areas=None):
def _get_url_params_areas(
self,
data_type,
end_date=None,
areas=None,
resolution=60,
):
# If end_date isn't set, default to tomorrow
if end_date is None:
end_date = date.today() + timedelta(days=1) # pragma: no cover
Expand All @@ -132,8 +163,13 @@ def _get_url_params_areas(self, data_type, end_date=None, areas=None):
end_date = parse_dt(end_date)
if areas is None:
areas = self.AREAS # pragma: no cover
if resolution not in self.SUPPORTED_RESOLUTIONS:
raise UnsupportedResolution(
f"Resolution {resolution} is not supported, "
f"must be one of {self.SUPPORTED_RESOLUTIONS}"
)

endpoint = "DayAheadPrices" # default to hourly
endpoint = "DayAheadPriceIndices" # default to hourly
if data_type in [self.DAILY, self.WEEKLY, self.MONTHLY]:
endpoint = "AggregatePrices"
if data_type == self.YEARLY:
Expand All @@ -143,34 +179,46 @@ def _get_url_params_areas(self, data_type, end_date=None, areas=None):
params = {
"currency": self.currency,
"market": "DayAhead",
"deliveryArea": ",".join(areas),
}

if data_type == self.HOURLY:
params["date"] = end_date.strftime("%Y-%m-%d")
params["resolutionInMinutes"] = resolution
params["indexNames"] = ",".join(
# Price indices -endpoint uses "SYSTEM" for "SYS"
# -> replace it when requesting
"SYSTEM" if area == "SYS" else area
for area in areas
)
else:
params["deliveryArea"] = ",".join(areas)
if data_type in [self.DAILY, self.WEEKLY, self.MONTHLY]:
params["year"] = end_date.strftime("%Y")
return api_url, params, areas

def _fetch_json(self, data_type, end_date=None, areas=None):
def _fetch_json(self, data_type, end_date=None, areas=None, resolution=60):
"""Fetch JSON from API"""
api_url, params, areas = self._get_url_params_areas(data_type, end_date, areas)
api_url, params, areas = self._get_url_params_areas(
data_type, end_date, areas, resolution
)
response = requests.get(
api_url,
params=params,
timeout=self.timeout,
)
response.raise_for_status()
if response.status_code == 204:
return None
# "Old" API returns 204 for no data
return None # pragma: no cover
return self._parse_json(response.json(), data_type, areas)

def fetch(self, data_type, end_date=None, areas=None):
def fetch(self, data_type=None, end_date=None, areas=None, resolution=60):
"""
Fetch data from API.
Inputs:
- data_type
one of Prices.HOURLY, Prices.DAILY etc
defaults to Prices.HOURLY (used for hourly and sub-hourly data)
- end_date
datetime to end the data fetching
defaults to tomorrow
Expand All @@ -188,7 +236,10 @@ def fetch(self, data_type, end_date=None, areas=None):
- possible other values, such as min, max, average for hourly
"""

return self._fetch_json(data_type, end_date, areas)
if data_type is None:
data_type = self.HOURLY

return self._fetch_json(data_type, end_date, areas, resolution)

def hourly(self, end_date=None, areas=None):
"""Helper to fetch hourly data, see Prices.fetch()"""
Expand Down Expand Up @@ -231,20 +282,23 @@ async def _io(self, url, params):
# Httpx and asks
return resp.json()

async def _fetch_json(self, data_type, end_date=None, areas=None):
async def _fetch_json(self, data_type, end_date=None, areas=None, resolution=60):
"""Fetch JSON from API"""
api_url, params, areas = self._get_url_params_areas(data_type, end_date, areas)
api_url, params, areas = self._get_url_params_areas(
data_type, end_date, areas, resolution
)
return await self._io(
api_url,
params,
)

async def fetch(self, data_type, end_date=None, areas=None):
async def fetch(self, data_type=None, end_date=None, areas=None, resolution=60):
"""
Fetch data from API.
Inputs:
- data_type
API page id, one of Prices.HOURLY, Prices.DAILY etc
defaults to Prices.HOURLY, used for hourly and sub-hourly data
- end_date
datetime to end the data fetching
defaults to tomorrow
Expand All @@ -262,7 +316,15 @@ async def fetch(self, data_type, end_date=None, areas=None):
"""
if areas is None: # If no areas are provided, inherit from the parent class
areas = self.AREAS
data = await self._fetch_json(data_type, end_date, areas=areas)
if data_type is None:
data_type = self.HOURLY

data = await self._fetch_json(
data_type,
end_date,
areas=areas,
resolution=resolution,
)
return self._parse_json(data, data_type, areas)

async def hourly(self, end_date=None, areas=None):
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "nordpool"
version = "0.4.5"
version = "0.5.0"
description = "Python library for fetching Nord Pool spot prices."
authors = ["Kimmo Huoman <kipenroskaposti@gmail.com>"]
license = "MIT"
Expand Down
11 changes: 11 additions & 0 deletions tests/_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import pathlib
from vcr import VCR

CASSETTE_LIBRARY = pathlib.Path(__file__).parent.resolve() / "vcr"
vcr = VCR(
serializer="yaml",
cassette_library_dir=str(CASSETTE_LIBRARY),
record_mode="once", # Change to "once" to record new cassettes, using "none" to avoid requests made by accident
match_on=["uri", "method", "query", "raw_body"],
decode_compressed_response=True,
)
Loading