Skip to content

Commit 1a10ca1

Browse files
committed
Add the pull-elexon-imbalance command
1 parent 71780e4 commit 1a10ca1

File tree

8 files changed

+160
-8
lines changed

8 files changed

+160
-8
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# pull-elexon-imbalance
2+
3+
This is a script to pulls a months worth of half-hourly imbalance price and volume data from Elexon and saves it to disk.
4+
The data is saved in monthly CSV files in the directory defined by the `MARKET_DATA_DIR` variable in the environment configuration.

src/skypro/commands/pull_elexon_imbalance/__init__.py

Whitespace-only changes.
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import functools
2+
import json
3+
import logging
4+
5+
import requests
6+
from datetime import date, datetime, timedelta
7+
from io import StringIO
8+
from typing import Callable
9+
10+
import pandas as pd
11+
12+
from skypro.common.cli_utils.cli_utils import get_user_ack_of_warning_or_exit, read_yaml_file
13+
from skypro.common.data.utility import prepare_data_dir
14+
15+
from skypro.common.timeutils.month_str import get_first_and_last_date
16+
from skypro.commands.pull_elexon_imbalance.utils import daterange, with_retries
17+
18+
ELEXON_API_MAX_RETRIES = 5
19+
ELEXON_API_RETRY_DELAY = timedelta(seconds=1)
20+
21+
22+
def pull_elexon_imbalance(month_str: str, env_file_path: str):
23+
"""
24+
Pulls a months worth of half-hourly imbalance price and volume data from Elexon and saves it to disk.
25+
The data is saved in monthly CSV files in the directory defined by the MARKET_DATA_DIR in the environment configuration.
26+
"""
27+
28+
start_date, end_date = get_first_and_last_date(month_str)
29+
30+
today = datetime.now().date()
31+
if end_date > today:
32+
end_date = today
33+
get_user_ack_of_warning_or_exit(f"The month has not ended yet, so data will be incomplete after {end_date}")
34+
35+
env_config = read_yaml_file(env_file_path)
36+
data_dir = env_config["vars"]["MARKET_DATA_DIR"]
37+
38+
df = _fetch_multiple_days(
39+
start=start_date,
40+
end=end_date,
41+
fetch_func=_fetch_day,
42+
)
43+
44+
df["price"] = df["price"] / 10 # £/MW to p/kW
45+
46+
prices_file_path = prepare_data_dir(data_dir, "elexon", "imbalance_price", start_date)
47+
volume_file_path = prepare_data_dir(data_dir, "elexon", "imbalance_volume", start_date)
48+
49+
logging.info(f"Saving pricing data to '{prices_file_path}'")
50+
df[["spUTCTime", "spClockTime", "price"]].to_csv(prices_file_path, index=False)
51+
logging.info(f"Saving volume data to '{volume_file_path}'")
52+
df[["spUTCTime", "spClockTime", "volume"]].to_csv(volume_file_path, index=False)
53+
54+
55+
def _fetch_multiple_days(start: date, end: date, fetch_func: Callable) -> pd.DataFrame:
56+
"""
57+
The elexon API pulls for a single day, so this function calls the elexon API repeatedly for each day and stacks up
58+
the results.
59+
"""
60+
df = pd.DataFrame()
61+
for day in daterange(start, end):
62+
63+
logging.info(f"Fetching imbalance data for '{str(day)}'...")
64+
day_df = with_retries( # The Elexon API can be busy/unreliable at times so use retries to get past temporary failures
65+
functools.partial(fetch_func, day),
66+
ELEXON_API_MAX_RETRIES,
67+
ELEXON_API_RETRY_DELAY
68+
)
69+
70+
df = pd.concat([df, day_df])
71+
72+
# The values come through in the opposite order to what you'd expect
73+
df = df.sort_values(by=["spUTCTime"], ignore_index=True)
74+
75+
return df
76+
77+
78+
def _fetch_day(day: date) -> pd.DataFrame:
79+
"""
80+
Pulls a single days worth of imbalance data from Elexon.
81+
"""
82+
83+
day_str = day.isoformat()
84+
# Send a GET request to the API
85+
response = requests.get(
86+
url=f"https://data.elexon.co.uk/bmrs/api/v1/balancing/settlement/system-prices/{day_str}",
87+
params={"format": "json"}
88+
)
89+
response.raise_for_status()
90+
91+
json_data = json.load(StringIO(response.text))
92+
day_df = pd.DataFrame.from_dict(json_data["data"])
93+
94+
day_df["startTime"] = pd.to_datetime(day_df["startTime"], utc=True)
95+
day_df = day_df[["startTime", "systemSellPrice", "netImbalanceVolume"]]
96+
day_df = day_df.rename(columns={
97+
"startTime": "spUTCTime",
98+
"systemSellPrice": "price",
99+
"netImbalanceVolume": "volume"
100+
})
101+
102+
day_df.insert(1, "spClockTime", day_df["spUTCTime"].dt.tz_convert("Europe/London"))
103+
104+
return day_df
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import logging
2+
from datetime import date, timedelta
3+
from time import sleep
4+
from typing import Callable, Any
5+
6+
7+
def with_retries(func: Callable, max_attempts: int, delay: timedelta) -> Any:
8+
"""
9+
This will call the given `func`, and retry up to `max_attempt` times if an exception is encountered.
10+
"""
11+
attempts = 0
12+
while True:
13+
try:
14+
attempts += 1
15+
result = func()
16+
return result
17+
except Exception as e:
18+
logging.warning(f"Call to function failed: {e}")
19+
if attempts < max_attempts:
20+
logging.info(f"Retrying {attempts}/{max_attempts}")
21+
sleep(delay.total_seconds())
22+
continue
23+
else:
24+
logging.error("Max attempts reached.")
25+
raise e
26+
27+
28+
def daterange(start: date, end: date):
29+
"""
30+
Yields every date between start and end.
31+
"""
32+
for n in range(int((end - start).days + 1)):
33+
yield start + timedelta(days=n)

src/skypro/commands/report/main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323

2424
from skypro.common.cli_utils.cli_utils import read_yaml_file
2525
from skypro.common.data.utility import prepare_data_dir
26-
from skypro.commands.report.time import get_month_timerange
26+
from skypro.common.timeutils.month_str import get_month_timerange
2727
from skypro.commands.report.config.config import parse_config, Config
2828
from skypro.commands.report.microgrid_flow_calcs import calc_flows
2929
from skypro.commands.report.plots import plot_load_and_solar

src/skypro/common/data/utility.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import os
2-
from datetime import datetime, timedelta
3-
from typing import Callable, Optional, List
2+
from datetime import datetime, timedelta, date
3+
from typing import Callable, Optional, List, Union
44

55
import pandas as pd
66

@@ -103,7 +103,7 @@ def _notice_level_for_duration(duration: timedelta) -> NoticeLevel:
103103
return NoticeLevel.INFO
104104

105105

106-
def prepare_data_dir(data_dir: str, data_source: str, sub_dir: str, date_tag: datetime) -> str:
106+
def prepare_data_dir(data_dir: str, data_source: str, sub_dir: str, date_tag: Union[datetime | date]) -> str:
107107
"""
108108
Creates a directory for saving data into and returns the file name to use
109109
- `data_dir` is the base directory
Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
def get_first_and_last_date(month_str: str) -> Tuple[date, date]:
99
"""
10-
Returns the first and last date of the given month.
10+
Returns the first and last date of the given month string (formatted like '2025-06').
1111
"""
1212
try:
1313
start_date = datetime.strptime(month_str, '%Y-%m').date()
@@ -23,7 +23,7 @@ def get_first_and_last_date(month_str: str) -> Tuple[date, date]:
2323
def get_month_timerange(month_str: str, timezone_str: str) -> Tuple[datetime, datetime]:
2424
"""
2525
Returns the first instant of the given month, and the first instant of the next month. So the months range
26-
is start <= t < end.
26+
is start <= t < end. The month is specified as a string in a format like '2025-06'.
2727
Note, this function doesn't use dateutils.relativedelta because it does not seem to honour DST timezones!
2828
"""
2929
try:
@@ -38,4 +38,4 @@ def get_month_timerange(month_str: str, timezone_str: str) -> Tuple[datetime, da
3838
start = tz.localize(start)
3939
end = tz.localize(end)
4040

41-
return start, end
41+
return start, end

src/skypro/main.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import logging
33
import importlib.metadata
44

5+
from skypro.commands.pull_elexon_imbalance.main import pull_elexon_imbalance
56
from skypro.commands.report.main import report_cli
67
from skypro.commands.simulator.main import simulate
78

@@ -20,7 +21,8 @@ def main():
2021
# Create a dictionary of commands, mapping to their python function
2122
commands = {
2223
"simulate": simulate,
23-
"report": report_cli
24+
"report": report_cli,
25+
"pull-elexon-imbalance": pull_elexon_imbalance,
2426
}
2527

2628
parser = argparse.ArgumentParser()
@@ -100,6 +102,15 @@ def main():
100102
)
101103
add_env_file_arg(parser_report)
102104

105+
parser_pull_elexon_imbalance = subparsers.add_parser('pull-elexon-imbalance')
106+
parser_pull_elexon_imbalance.add_argument(
107+
'-m', '--month',
108+
dest='month_str',
109+
required=True,
110+
help='The month to pull data for, e.g. 2024-04'
111+
)
112+
add_env_file_arg(parser_pull_elexon_imbalance)
113+
103114
kwargs = vars(parser.parse_args())
104115

105116
command = kwargs.pop('subparser')

0 commit comments

Comments
 (0)