detquantlib 2.0.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- detquantlib/__init__.py +1 -0
- detquantlib/data/databases/detdatabase.py +535 -0
- detquantlib/data/entsoe/entsoe.py +174 -0
- detquantlib/data/sftp/sftp.py +89 -0
- detquantlib/dates/dates.py +154 -0
- detquantlib/figures/plotly_figures.py +82 -0
- detquantlib/stats/data_analysis.py +143 -0
- detquantlib/tradable_products/tradable_products.py +117 -0
- detquantlib/utils/utils.py +11 -0
- detquantlib-2.0.4.dist-info/LICENSE.txt +3 -0
- detquantlib-2.0.4.dist-info/METADATA +153 -0
- detquantlib-2.0.4.dist-info/RECORD +13 -0
- detquantlib-2.0.4.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# Python built-in packages
|
|
2
|
+
import shutil
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
# Third-party packages
|
|
6
|
+
import pandas as pd
|
|
7
|
+
|
|
8
|
+
# Internal modules
|
|
9
|
+
from detquantlib.data.sftp.sftp import Sftp
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Entsoe:
|
|
13
|
+
"""
|
|
14
|
+
A class that handles ENTSOE data, including importing and processing data from their SFTP
|
|
15
|
+
server.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, sftp: Sftp = None):
|
|
19
|
+
self.sftp = sftp
|
|
20
|
+
|
|
21
|
+
@staticmethod
|
|
22
|
+
def get_sftp_folder_dir_day_ahead_spot_prices():
|
|
23
|
+
# Directory of SFTP folder containing day-ahead spot prices.
|
|
24
|
+
return "/TP_export/EnergyPrices_12.1.D_r3/"
|
|
25
|
+
|
|
26
|
+
@staticmethod
|
|
27
|
+
def get_sftp_filename_day_ahead_spot_prices(year: int, month: int) -> str:
|
|
28
|
+
"""
|
|
29
|
+
Name of SFTP files containing day-ahead spot prices.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
year: Delivery year
|
|
33
|
+
month: Delivery month
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Filename of day-ahead spot prices
|
|
37
|
+
"""
|
|
38
|
+
year_str = str(year)
|
|
39
|
+
month_str = str(month) if month >= 10 else f"0{month}"
|
|
40
|
+
return f"{year_str}_{month_str}_EnergyPrices_12.1.D_r3.csv"
|
|
41
|
+
|
|
42
|
+
def import_day_ahead_spot_prices_file_from_sftp(
|
|
43
|
+
self, year: int, month: int, local_folder_dir: str
|
|
44
|
+
):
|
|
45
|
+
"""
|
|
46
|
+
Downloads the file containing the day-ahead spot prices of a given delivery month from
|
|
47
|
+
the ENTSOE SFTP server to a local directory.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
year: Delivery year
|
|
51
|
+
month: Delivery month
|
|
52
|
+
local_folder_dir: Local directory where file will be copied
|
|
53
|
+
"""
|
|
54
|
+
filename = Entsoe.get_sftp_filename_day_ahead_spot_prices(year=year, month=month)
|
|
55
|
+
remote_folder_dir = Entsoe.get_sftp_folder_dir_day_ahead_spot_prices()
|
|
56
|
+
remote_dir = f"/{remote_folder_dir}/{filename}"
|
|
57
|
+
local_dir = f"{local_folder_dir}/{filename}"
|
|
58
|
+
|
|
59
|
+
self.sftp.open_session()
|
|
60
|
+
self.sftp.get_file(remote_dir, local_dir)
|
|
61
|
+
self.sftp.close_session()
|
|
62
|
+
|
|
63
|
+
@staticmethod
|
|
64
|
+
def read_day_ahead_spot_prices_from_file(
|
|
65
|
+
country: str,
|
|
66
|
+
timezone: str,
|
|
67
|
+
year: int,
|
|
68
|
+
month: int,
|
|
69
|
+
local_folder_dir: str,
|
|
70
|
+
) -> pd.DataFrame:
|
|
71
|
+
"""
|
|
72
|
+
Reads and processes the content of the file containing day-ahead spot prices for a given
|
|
73
|
+
delivery month.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
country: Country/area of requested day-ahead spot prices
|
|
77
|
+
timezone: Timezone of requested day-ahead spot prices
|
|
78
|
+
year: Delivery year
|
|
79
|
+
month: Delivery month
|
|
80
|
+
local_folder_dir: Local directory of folder containing the price data file
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Dataframe containing day-ahead spot prices
|
|
84
|
+
"""
|
|
85
|
+
# Read data from csv
|
|
86
|
+
filename = Entsoe.get_sftp_filename_day_ahead_spot_prices(year=year, month=month)
|
|
87
|
+
file_dir = f"{local_folder_dir}/{filename}"
|
|
88
|
+
df = pd.read_csv(file_dir, sep="\t")
|
|
89
|
+
|
|
90
|
+
# Filter columns
|
|
91
|
+
columns = ["DateTime(UTC)", "ResolutionCode", "MapCode", "Price[Currency/MWh]", "Currency"]
|
|
92
|
+
df = df[columns]
|
|
93
|
+
|
|
94
|
+
# Filter rows for relevant country
|
|
95
|
+
idx = df["MapCode"] == country
|
|
96
|
+
df = df.loc[idx, :]
|
|
97
|
+
df.reset_index(drop=True, inplace=True)
|
|
98
|
+
|
|
99
|
+
# Convert dates from strings to datetime
|
|
100
|
+
df["DateTime(UTC)"] = pd.to_datetime(
|
|
101
|
+
df["DateTime(UTC)"], format="%Y-%m-%d %H:%M:%S", utc=True
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# Convert from UTC timezone to local timezone
|
|
105
|
+
df["DateTime(UTC)"] = df["DateTime(UTC)"].dt.tz_convert(timezone)
|
|
106
|
+
df["DateTime(UTC)"] = df["DateTime(UTC)"].dt.tz_localize(None)
|
|
107
|
+
|
|
108
|
+
return df
|
|
109
|
+
|
|
110
|
+
def get_day_ahead_spot_prices_from_sftp(
|
|
111
|
+
self, country: str, timezone: str, year: int, month: int, keep_local_file: bool = False
|
|
112
|
+
) -> pd.DataFrame:
|
|
113
|
+
"""
|
|
114
|
+
Main method to fetch and process day-ahead spot price data from the ENSTOE SFTP server.
|
|
115
|
+
|
|
116
|
+
The function performs the following steps:
|
|
117
|
+
1) Download the file containing day-ahead spot prices from the SFTP to a temporary local
|
|
118
|
+
directory.
|
|
119
|
+
2) Read, process and store the price data into a dataframe.
|
|
120
|
+
3) Delete the temporary local directory containing the downloaded file, unless the user
|
|
121
|
+
indicates that the file should be kept.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
country: Country/area of requested day-ahead spot prices
|
|
125
|
+
timezone: Timezone of requested day-ahead spot prices
|
|
126
|
+
year: Delivery year
|
|
127
|
+
month: Delivery month
|
|
128
|
+
keep_local_file: Indicates whether the downloaded data file should be kept or not
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
Dataframe containing day-ahead spot prices
|
|
132
|
+
"""
|
|
133
|
+
# Define local directory where price data file will be stored
|
|
134
|
+
input_folder_dir = Path.cwd().joinpath("Inputs")
|
|
135
|
+
entsoe_data_folder_dir = input_folder_dir.joinpath("EntsoeData")
|
|
136
|
+
|
|
137
|
+
# Check if folders already exist
|
|
138
|
+
input_folder_exists = input_folder_dir.exists()
|
|
139
|
+
entsoe_data_folder_exists = entsoe_data_folder_dir.exists()
|
|
140
|
+
|
|
141
|
+
# Create local directory if it doesn't exist
|
|
142
|
+
entsoe_data_folder_dir.mkdir(parents=True, exist_ok=True)
|
|
143
|
+
entsoe_data_folder_dir = str(entsoe_data_folder_dir)
|
|
144
|
+
|
|
145
|
+
# Import price data file from ENTSOE SFTP to local directory
|
|
146
|
+
self.import_day_ahead_spot_prices_file_from_sftp(
|
|
147
|
+
year=year, month=month, local_folder_dir=entsoe_data_folder_dir
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# Read price data file
|
|
151
|
+
df = self.read_day_ahead_spot_prices_from_file(
|
|
152
|
+
country=country,
|
|
153
|
+
timezone=timezone,
|
|
154
|
+
year=year,
|
|
155
|
+
month=month,
|
|
156
|
+
local_folder_dir=entsoe_data_folder_dir,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
# Delete price data file
|
|
160
|
+
if not keep_local_file:
|
|
161
|
+
if input_folder_exists and entsoe_data_folder_exists:
|
|
162
|
+
# Local directory already existed. Only delete the price data file.
|
|
163
|
+
filename = Entsoe.get_sftp_filename_day_ahead_spot_prices(year=year, month=month)
|
|
164
|
+
file_dir = Path(entsoe_data_folder_dir).joinpath(filename)
|
|
165
|
+
file_dir.unlink()
|
|
166
|
+
elif input_folder_exists:
|
|
167
|
+
# Only the Inputs folder already existed. Delete the EntsoeData folder and its
|
|
168
|
+
# content.
|
|
169
|
+
shutil.rmtree(entsoe_data_folder_dir)
|
|
170
|
+
else:
|
|
171
|
+
# The entire temporary local directory did not exist. Delete the entire directory.
|
|
172
|
+
shutil.rmtree(input_folder_dir)
|
|
173
|
+
|
|
174
|
+
return df
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import paramiko
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Sftp:
|
|
5
|
+
"""A class that handles imports and exports of data from and to an SFTP server."""
|
|
6
|
+
|
|
7
|
+
def __init__(
|
|
8
|
+
self,
|
|
9
|
+
hostname: str,
|
|
10
|
+
port: int,
|
|
11
|
+
username: str,
|
|
12
|
+
password: str = None,
|
|
13
|
+
private_key_dir: str = None,
|
|
14
|
+
authentication_type: str = "password",
|
|
15
|
+
sftp_session: paramiko.SFTPClient = None,
|
|
16
|
+
transport: paramiko.Transport = None,
|
|
17
|
+
):
|
|
18
|
+
"""
|
|
19
|
+
Constructor method.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
hostname: SFTP server hostname
|
|
23
|
+
port: SFTP server port
|
|
24
|
+
username: Username to connect to the SFTP server
|
|
25
|
+
password: Password to connect to the SFTP server (only needed if
|
|
26
|
+
authentication_type="password")
|
|
27
|
+
private_key_dir: Directory of file containing the private key to connect to the SFTP
|
|
28
|
+
server (only needed if authentication_type="private_key")
|
|
29
|
+
authentication_type: Can take value "password" or "private_key"
|
|
30
|
+
sftp_session: SFTP session object
|
|
31
|
+
transport: Transport object
|
|
32
|
+
|
|
33
|
+
Raises:
|
|
34
|
+
ValueError: Raises an error in case of invalid authentication type
|
|
35
|
+
"""
|
|
36
|
+
# Input validation
|
|
37
|
+
valid_authentication_types = ["password", "private_key"]
|
|
38
|
+
if authentication_type not in valid_authentication_types:
|
|
39
|
+
raise ValueError(
|
|
40
|
+
f"Invalid input 'authentication_type' value '{authentication_type}'. "
|
|
41
|
+
f"Supported values: {valid_authentication_types}."
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
self.hostname = hostname
|
|
45
|
+
self.port = port
|
|
46
|
+
self.username = username
|
|
47
|
+
self.password = password
|
|
48
|
+
self.private_key_dir = private_key_dir
|
|
49
|
+
self.authentication_type = authentication_type
|
|
50
|
+
self.sftp_session = sftp_session
|
|
51
|
+
self.transport = transport
|
|
52
|
+
|
|
53
|
+
def open_session(self):
|
|
54
|
+
"""Opens an SFTP session."""
|
|
55
|
+
# Establish an SSH client and connect to the server
|
|
56
|
+
try:
|
|
57
|
+
self.transport = paramiko.Transport((self.hostname, self.port))
|
|
58
|
+
self.transport.connect(username=self.username, password=self.password)
|
|
59
|
+
|
|
60
|
+
# Open an SFTP session
|
|
61
|
+
self.sftp_session = paramiko.SFTPClient.from_transport(self.transport)
|
|
62
|
+
|
|
63
|
+
except Exception as e:
|
|
64
|
+
print(f"Error: {e}")
|
|
65
|
+
|
|
66
|
+
def close_session(self):
|
|
67
|
+
"""Closes an SFTP session and transport."""
|
|
68
|
+
self.sftp_session.close()
|
|
69
|
+
self.transport.close()
|
|
70
|
+
|
|
71
|
+
def get_file(self, remote_dir: str, local_dir: str):
|
|
72
|
+
"""
|
|
73
|
+
Imports a file from an SFTP server to a local directory.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
remote_dir: SFTP server file directory
|
|
77
|
+
local_dir: Local file directory
|
|
78
|
+
"""
|
|
79
|
+
self.sftp_session.get(remote_dir, local_dir)
|
|
80
|
+
|
|
81
|
+
def put_file(self, local_dir: str, remote_dir: str):
|
|
82
|
+
"""
|
|
83
|
+
Exports a file from a local directory to an SFTP server.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
local_dir: Local file directory
|
|
87
|
+
remote_dir: SFTP server file directory
|
|
88
|
+
"""
|
|
89
|
+
self.sftp_session.put(local_dir, remote_dir)
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# Python built-in packages
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
# Third-party packages
|
|
6
|
+
import pandas as pd
|
|
7
|
+
from dateutil.relativedelta import *
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def count_delivery_periods(
|
|
11
|
+
start_date: datetime,
|
|
12
|
+
end_date: datetime,
|
|
13
|
+
delivery_frequency: str,
|
|
14
|
+
full_periods_only: bool = False,
|
|
15
|
+
timezone: str = None,
|
|
16
|
+
) -> int:
|
|
17
|
+
"""
|
|
18
|
+
Counts the number of delivery periods within a time interval.
|
|
19
|
+
|
|
20
|
+
Note: When the end date is exactly equal to the start of the next delivery period, that next
|
|
21
|
+
period is not included (because it has not yet started). For example, if the end date is
|
|
22
|
+
15-Jan-2025 00:00:00 and the delivery frequency is daily, then the period
|
|
23
|
+
15-Jan-2025 00:00:00 to 16-Jan-2025 00:00:00 is not included.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
start_date: Delivery start date
|
|
27
|
+
end_date: Delivery end date
|
|
28
|
+
delivery_frequency: Delivery frequency, expressed as Pandas offset aliases
|
|
29
|
+
full_periods_only: Indicates whether to count only fully elapsed periods.
|
|
30
|
+
For example, suppose that start date is 15-Jan-2025 00:15:00, end date is
|
|
31
|
+
3-Feb-2025 03:45:00, and delivery frequency is hourly. Then:
|
|
32
|
+
- If full_periods_only=True, the number of periods is 2 (01:00:00-02:00:00 and
|
|
33
|
+
02:00:00-03:00:00), because the hours 00:00:00-01:00:00 and 00:03:00-04:00:00
|
|
34
|
+
are not full.
|
|
35
|
+
- If full_periods_only=False, the number of periods is 4, because incomplete hours
|
|
36
|
+
00:00:00-01:00:00 and 00:03:00-04:00:00 are also included. Note:
|
|
37
|
+
- If end date is 3-Feb-2025 04:00:00, hour 04:00:00-05:00:00 is not included.
|
|
38
|
+
- If end date is 3-Feb-2025 04:00:01, hour 04:00:00-05:00:00 is included.
|
|
39
|
+
timezone: Timezone (needed to account for DST switches)
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
Number of delivery periods in the interval
|
|
43
|
+
"""
|
|
44
|
+
# Convert to pandas timestamp
|
|
45
|
+
start_date = pd.Timestamp(start_date)
|
|
46
|
+
end_date = pd.Timestamp(end_date)
|
|
47
|
+
|
|
48
|
+
if full_periods_only:
|
|
49
|
+
start_date = start_date.ceil(delivery_frequency)
|
|
50
|
+
end_date = end_date.floor(delivery_frequency)
|
|
51
|
+
else:
|
|
52
|
+
start_date = start_date.floor(delivery_frequency)
|
|
53
|
+
|
|
54
|
+
periods = pd.date_range(
|
|
55
|
+
start=start_date,
|
|
56
|
+
end=end_date,
|
|
57
|
+
freq=delivery_frequency,
|
|
58
|
+
inclusive="left",
|
|
59
|
+
tz=timezone,
|
|
60
|
+
)
|
|
61
|
+
nr_periods = len(periods)
|
|
62
|
+
return nr_periods
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def calc_months_diff(
|
|
66
|
+
start_date: datetime,
|
|
67
|
+
end_date: datetime,
|
|
68
|
+
diff_method: Literal["month", "time", "full_months_only"] = "month",
|
|
69
|
+
) -> int:
|
|
70
|
+
"""
|
|
71
|
+
Calculate the month difference between 2 dates.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
start_date: Start date
|
|
75
|
+
end_date: End date
|
|
76
|
+
diff_method: Method to count the month difference.
|
|
77
|
+
- diff_method="month": Counts the month difference based on the months of the start
|
|
78
|
+
date and end date, irrespective of the day. For example, suppose that start date
|
|
79
|
+
is 15-Jan-2025 and end date is 3-Mar-2025. Then, the month difference between
|
|
80
|
+
Jan-2025 and Mar-2025 is 2.
|
|
81
|
+
- diff_method="time": Counts the month difference, accounting the day and time of the
|
|
82
|
+
start and end dates. For example:
|
|
83
|
+
- Suppose that start date is 15-Jan-2025 and end date is 3-Mar-2025. Then:
|
|
84
|
+
- start date + 1 months = 15-Feb-2025
|
|
85
|
+
- start date + 2 months = 15-Mar-2025
|
|
86
|
+
- The month difference is 1, because
|
|
87
|
+
[start date + 1 months] <= end date < [start date + 2 months].
|
|
88
|
+
- Suppose that start date is 15-Jan-2025 and end date is 16-Mar-2025. Then:
|
|
89
|
+
- start date + 2 months = 15-Mar-2025
|
|
90
|
+
- start date + 3 months = 15-Apr-2025
|
|
91
|
+
- The month difference is 2, because
|
|
92
|
+
[start date + 2 months] <= end date < [start date + 3 months].
|
|
93
|
+
- diff_method="full_months_only": Counts the number of fully elapsed months between
|
|
94
|
+
the start date and the end date. For example, suppose that start date
|
|
95
|
+
is 15-Jan-2025 and end date is 3-Mar-2025. Then, the month difference is 1,
|
|
96
|
+
because only Feb-2025 was elapsed from start to finish.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
Month difference between 2 dates
|
|
100
|
+
|
|
101
|
+
Raises:
|
|
102
|
+
ValueError: Raises an error when end_date < start_date
|
|
103
|
+
ValueError: Raises an error when the input argument 'diff_method' is invalid
|
|
104
|
+
"""
|
|
105
|
+
# Input validation
|
|
106
|
+
if end_date < start_date:
|
|
107
|
+
raise ValueError("End date cannot be smaller than start date.")
|
|
108
|
+
|
|
109
|
+
# Calculate month difference
|
|
110
|
+
start_mc = datetime_to_month_code(start_date)
|
|
111
|
+
end_mc = datetime_to_month_code(end_date)
|
|
112
|
+
|
|
113
|
+
if diff_method == "month":
|
|
114
|
+
diff = end_mc - start_mc
|
|
115
|
+
elif diff_method == "time":
|
|
116
|
+
# Check month difference, accounting for day and time. For example, suppose that start
|
|
117
|
+
# date is 10-Jan-2025 14:00:00. Then:
|
|
118
|
+
# - If end date is 10-Mar-2025 14:00:00, month difference is 2.
|
|
119
|
+
# - If end date is 10-Mar-2025 13:55:00, month difference is 1.
|
|
120
|
+
start_day = start_date + relativedelta(year=2000, month=1)
|
|
121
|
+
end_day = end_date + relativedelta(year=2000, month=1)
|
|
122
|
+
diff = end_mc - start_mc
|
|
123
|
+
if end_day < start_day:
|
|
124
|
+
diff -= 1
|
|
125
|
+
elif diff_method == "full_months_only":
|
|
126
|
+
# Calculate number of fully elapsed months
|
|
127
|
+
# Note: We need to use the max operator, otherwise diff = -1 when start month = end month.
|
|
128
|
+
if start_date > datetime(start_date.year, start_date.month, 1):
|
|
129
|
+
start_mc += 1
|
|
130
|
+
diff = max(end_mc - start_mc, 0)
|
|
131
|
+
else:
|
|
132
|
+
raise ValueError("Invalid value of input argument 'diff_method'.")
|
|
133
|
+
|
|
134
|
+
return diff
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def datetime_to_month_code(d: datetime) -> int:
|
|
138
|
+
"""
|
|
139
|
+
Converts a datetime to its corresponding month code. A month code is calculated as the number
|
|
140
|
+
of months since 1 January 1900 (including the month of the input datetime).
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
d: Datetime
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
Corresponding month code
|
|
147
|
+
|
|
148
|
+
Raises:
|
|
149
|
+
ValueError: Raises an error if the input datetime is before 1 January 1900
|
|
150
|
+
"""
|
|
151
|
+
if d < datetime(1900, 1, 1):
|
|
152
|
+
raise ValueError("Input date cannot be before 1 January 1900.")
|
|
153
|
+
month_code = (d.year - 1900) * 12 + d.month
|
|
154
|
+
return month_code
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# Python built-in packages
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
# Third-party packages
|
|
5
|
+
import plotly.graph_objects as go
|
|
6
|
+
import plotly.io as pio
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_default_plotly_folder_dir():
|
|
10
|
+
# Default path to output folder containing plotly figures.
|
|
11
|
+
return Path.cwd().joinpath("Outputs", "PlotlyFigures")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def set_standard_layout(fig: go.Figure) -> go.Figure:
|
|
15
|
+
"""
|
|
16
|
+
Short helper function to improve and standardize the layout of plotly figures.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
fig: Plotly figure
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
Plotly figure with updated layout options
|
|
23
|
+
"""
|
|
24
|
+
fig.update_layout(
|
|
25
|
+
template="plotly_white",
|
|
26
|
+
title_x=0.5,
|
|
27
|
+
xaxis=dict(showline=True, linecolor="black", showgrid=False, ticks="outside"),
|
|
28
|
+
yaxis=dict(showline=True, linecolor="black", showgrid=True, ticks="outside"),
|
|
29
|
+
hovermode="x",
|
|
30
|
+
)
|
|
31
|
+
return fig
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def save_plotly_fig_to_json(
|
|
35
|
+
fig: go.Figure,
|
|
36
|
+
filename: str,
|
|
37
|
+
folder_dir: Path = get_default_plotly_folder_dir(),
|
|
38
|
+
standard_layout: bool = True,
|
|
39
|
+
):
|
|
40
|
+
"""
|
|
41
|
+
Saves a plotly figure object to a json file.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
fig: Plotly figure
|
|
45
|
+
filename: Name of json file in which plotly figure will be saved
|
|
46
|
+
folder_dir: Folder directory where the json file will be saved
|
|
47
|
+
standard_layout: If true, enforces a standard plotly layout
|
|
48
|
+
"""
|
|
49
|
+
# Create plotly folder if it doesn't exist yet
|
|
50
|
+
folder_dir.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
|
|
52
|
+
# Define json file directory
|
|
53
|
+
file_dir = folder_dir.joinpath(f"{filename}.json")
|
|
54
|
+
|
|
55
|
+
# Adjust figure layout
|
|
56
|
+
if standard_layout:
|
|
57
|
+
fig = set_standard_layout(fig)
|
|
58
|
+
|
|
59
|
+
# Save figure to json
|
|
60
|
+
fig_json = fig.to_json()
|
|
61
|
+
with open(file_dir, "w") as f:
|
|
62
|
+
f.write(fig_json)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def show_plotly_fig_json(filename: str, folder_dir: Path = get_default_plotly_folder_dir()):
|
|
66
|
+
"""
|
|
67
|
+
Short helper function to display a plotly figure stored in a json file.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
filename: Name of json file containing the plotly figure
|
|
71
|
+
folder_dir: Folder directory containing the json file
|
|
72
|
+
"""
|
|
73
|
+
# Get json file directory
|
|
74
|
+
file_dir = folder_dir.joinpath(f"{filename}.json")
|
|
75
|
+
|
|
76
|
+
# Load figure from json
|
|
77
|
+
with open(file_dir, "r") as f:
|
|
78
|
+
fig_json = f.read()
|
|
79
|
+
fig = pio.from_json(fig_json)
|
|
80
|
+
|
|
81
|
+
# Display figure
|
|
82
|
+
fig.show()
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pandas as pd
|
|
3
|
+
import plotly.graph_objects as go
|
|
4
|
+
|
|
5
|
+
from detquantlib.figures.plotly_figures import set_standard_layout
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def filter_outliers(
|
|
9
|
+
df: pd.DataFrame,
|
|
10
|
+
column: str,
|
|
11
|
+
stats_radius: int,
|
|
12
|
+
std_dev_excl_factor: int = 3,
|
|
13
|
+
max_iter: int = 10,
|
|
14
|
+
plot_series_at_every_iter: bool = False,
|
|
15
|
+
) -> pd.DataFrame:
|
|
16
|
+
"""
|
|
17
|
+
Filters out outliers from a given time series.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
df: Times series dataframe
|
|
21
|
+
column: Name of the column containing outliers to be filtered out
|
|
22
|
+
stats_radius: For an observation at a given time t, the mean and the standard deviation
|
|
23
|
+
will be computed over the interval [t - stats_radius, t + stats_radius]
|
|
24
|
+
std_dev_excl_factor: An observation at a given time t will be flagged as an outlier if it
|
|
25
|
+
is outside the interval
|
|
26
|
+
[mean(t) - std(t) * std_dev_excl_factor, mean(t) + std(t) * std_dev_excl_factor]
|
|
27
|
+
max_iter: Outliers affect the mean and standard deviation of neighbouring observations,
|
|
28
|
+
such that smaller neighbouring outliers may go undetected if we perform the filtering
|
|
29
|
+
process only once. Hence, the operation needs to be repeated multiple times. Parameter
|
|
30
|
+
'max_iter' determines the maximum number of iterations of the filtering processes.
|
|
31
|
+
plot_series_at_every_iter: If plot_series=True, the time series will be plotted at
|
|
32
|
+
every iteration. This parameter can be used to assess the effectiveness of the
|
|
33
|
+
other input parameters in the outliers filtering process.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
df: Time series dataframe with outliers removed
|
|
37
|
+
"""
|
|
38
|
+
# Initialize number of outliers found in time series (filtering will stop when this reaches 0)
|
|
39
|
+
nr_outliers = 1
|
|
40
|
+
|
|
41
|
+
# Iteratively remove outliers
|
|
42
|
+
for count in range(max_iter):
|
|
43
|
+
if nr_outliers > 0:
|
|
44
|
+
# Count number of rows in df
|
|
45
|
+
nr_rows = df.shape[0]
|
|
46
|
+
|
|
47
|
+
# Calculate rolling mean and standard deviation per observation
|
|
48
|
+
for i, loc in enumerate(df.index):
|
|
49
|
+
# Determine time interval
|
|
50
|
+
min_i = max(i - stats_radius, 0)
|
|
51
|
+
max_i = min(i + stats_radius, nr_rows)
|
|
52
|
+
|
|
53
|
+
# Calculate mean and standard deviation around current observation
|
|
54
|
+
idx = df.index[min_i:max_i]
|
|
55
|
+
df.loc[loc, "mean"] = np.mean(df.loc[idx, column])
|
|
56
|
+
df.loc[loc, "std_dev"] = np.std(df.loc[idx, column], ddof=1)
|
|
57
|
+
|
|
58
|
+
# Identify lower and upper bounds
|
|
59
|
+
lower_bound = df["mean"] - df["std_dev"] * std_dev_excl_factor
|
|
60
|
+
upper_bound = df["mean"] + df["std_dev"] * std_dev_excl_factor
|
|
61
|
+
|
|
62
|
+
# Plot time series
|
|
63
|
+
if plot_series_at_every_iter:
|
|
64
|
+
create_outliers_iteration_plot(
|
|
65
|
+
x_values=df.index,
|
|
66
|
+
data=df[column],
|
|
67
|
+
lower_bound=lower_bound,
|
|
68
|
+
upper_bound=upper_bound,
|
|
69
|
+
y_label=column,
|
|
70
|
+
title=f"Outliers Filtering (Iteration {count+1}/{max_iter})",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# Exclude outliers, i.e. observations outside std dev boundaries
|
|
74
|
+
idx_keep = (df[column] >= lower_bound) & (df[column] <= upper_bound)
|
|
75
|
+
df = df.loc[idx_keep, :]
|
|
76
|
+
|
|
77
|
+
# Count number of outliers found in time series
|
|
78
|
+
nr_outliers = np.sum(np.invert(idx_keep))
|
|
79
|
+
|
|
80
|
+
if nr_outliers == 0:
|
|
81
|
+
print(f"No (more) outliers detected (iteration {count+1} out of max {max_iter}).")
|
|
82
|
+
else:
|
|
83
|
+
print(
|
|
84
|
+
f"Outliers filtering excluded {nr_outliers}/{idx_keep.shape[0]}"
|
|
85
|
+
f" observations (iteration {count+1} out of max {max_iter})."
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# Plot time series
|
|
89
|
+
if plot_series_at_every_iter:
|
|
90
|
+
create_outliers_iteration_plot(
|
|
91
|
+
x_values=df.index,
|
|
92
|
+
data=df[column],
|
|
93
|
+
lower_bound=lower_bound,
|
|
94
|
+
upper_bound=upper_bound,
|
|
95
|
+
y_label=column,
|
|
96
|
+
title="Outliers Filtering (Final Data)",
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
# Dataframe post-processing
|
|
100
|
+
df.reset_index(drop=True, inplace=True)
|
|
101
|
+
df.drop(["mean", "std_dev"], axis=1, inplace=True)
|
|
102
|
+
|
|
103
|
+
return df
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def create_outliers_iteration_plot(
|
|
107
|
+
x_values: pd.Series,
|
|
108
|
+
data: pd.Series,
|
|
109
|
+
lower_bound: pd.Series,
|
|
110
|
+
upper_bound: pd.Series,
|
|
111
|
+
y_label: str,
|
|
112
|
+
title: str,
|
|
113
|
+
):
|
|
114
|
+
"""
|
|
115
|
+
Short utility function used in the 'filter_outliers()' function to plot outliers at every
|
|
116
|
+
iteration.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
x_values: X-axis values
|
|
120
|
+
data: Time series data
|
|
121
|
+
lower_bound: Time series lower bound to identify outliers
|
|
122
|
+
upper_bound: Time series upper bound to identify outliers
|
|
123
|
+
y_label: Y-axis label
|
|
124
|
+
title: Plot title
|
|
125
|
+
"""
|
|
126
|
+
# Define plot line colors
|
|
127
|
+
data_color = "blue"
|
|
128
|
+
data_line = dict(color=data_color)
|
|
129
|
+
bounds_color = "#ED595C"
|
|
130
|
+
bounds_line = dict(color=bounds_color)
|
|
131
|
+
|
|
132
|
+
# Create plot
|
|
133
|
+
fig = go.Figure()
|
|
134
|
+
fig.add_trace(
|
|
135
|
+
go.Scatter(x=x_values, y=upper_bound, mode="lines", name="Upper bound", line=bounds_line)
|
|
136
|
+
)
|
|
137
|
+
fig.add_trace(go.Scatter(x=x_values, y=data, mode="lines", name="Data", line=data_line))
|
|
138
|
+
fig.add_trace(
|
|
139
|
+
go.Scatter(x=x_values, y=lower_bound, mode="lines", name="Lower bound", line=bounds_line)
|
|
140
|
+
)
|
|
141
|
+
fig.update_layout(yaxis_title=y_label, title=title)
|
|
142
|
+
fig = set_standard_layout(fig)
|
|
143
|
+
fig.show()
|