api-24sea 0.1.0__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.
api_24sea/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ # -*- coding: utf-8 -*-
2
+ """The `api_24sea` package contains modules that are aimed at helping a user
3
+ interact with the 24SEA API.
4
+
5
+ The modules are:
6
+
7
+ - :mod:`api_24sea.datasignals`: Contains the :class:`DataSignals` class, which
8
+ is an accessor for transforming data signals from the 24SEA API into
9
+ pandas DataFrames.
10
+
11
+ Besides the modules, the package also contains the :mod:`api_24sea.version`
12
+ module, which contains the version number of the package.
13
+ """
14
+
15
+ from . import datasignals
16
+
17
+ __all__ = ["datasignals"]
@@ -0,0 +1,319 @@
1
+ # -*- coding: utf-8 -*-
2
+ """The module :mod:`py_fatigue.damage.crack_growth` contains all the
3
+ damage models related to the crack growth approach.
4
+ """
5
+ import datetime
6
+ import logging
7
+
8
+ import pandas as pd
9
+ import requests as req
10
+ from pydantic import validate_call
11
+
12
+ # Local imports
13
+ from .. import utils as U
14
+ from . import schemas as S
15
+
16
+ try:
17
+ # delete the accessor to avoid warning
18
+ del pd.DataFrame.datasignals
19
+ except AttributeError:
20
+ pass
21
+
22
+ BASE_URL = "https://api.24sea.eu/routes/v1/"
23
+
24
+
25
+ logging.basicConfig(format="%(message)s", level=logging.INFO)
26
+
27
+
28
+ def check_authentication(func):
29
+ """Check authentication before making the request to the 24SEA API."""
30
+
31
+ def wrapper(self, *args, **kwargs):
32
+ """Wrapper function to check authentication."""
33
+ # fmt: off
34
+ if not isinstance(self.auth, req.auth.HTTPBasicAuth) or not self.authenticated:
35
+ raise U.AuthenticationError(
36
+ "\033[31;1mAuthentication needed before querying the metrics.\n"
37
+ "\033[0mUse the \033[34;1mdatasignals.\033[0mauthenticate("
38
+ "\033[32;1m<username>\033[0m, \033[32;1m<password>\033[0m) "
39
+ "method."
40
+ )
41
+ return func(self, *args, **kwargs)
42
+ # fmt: on
43
+
44
+ return wrapper
45
+
46
+
47
+ @pd.api.extensions.register_dataframe_accessor("datasignals")
48
+ class DataSignals:
49
+ """Accessor for working with data signals coming from the 24SEA API."""
50
+
51
+ def __init__(self, pandasdata: pd.DataFrame):
52
+ self.data = pandasdata
53
+ self.base_url: str = f"{BASE_URL}datasignals/"
54
+ self.username: str | None = None
55
+ self.password: str | None = None
56
+ self.auth: req.auth.HTTPBasicAuth | None = None
57
+ self.authenticated: bool = False
58
+ self.metrics_overview: pd.DataFrame | None = None
59
+ self.selected_metrics: pd.DataFrame | None = None
60
+
61
+ @validate_call
62
+ def authenticate(self, username: str, password: str) -> None:
63
+ """Authenticate the user with the 24SEA API. Additionally, define
64
+ the ``metrics_overview`` dataframe.
65
+
66
+ Parameters
67
+ ----------
68
+ username : str
69
+ The username to authenticate.
70
+ password : str
71
+ The password to authenticate.
72
+ """
73
+ # -- Step 1: Authenticate and check the credentials
74
+ self.username = username
75
+ self.password = password
76
+ self.auth = req.auth.HTTPBasicAuth(self.username, self.password)
77
+ # fmt: off
78
+ try:
79
+ r_profile = U.handle_request(
80
+ f"{self.base_url}profile",
81
+ {"username": self.username},
82
+ self.auth,
83
+ {"accept": "application/json"},
84
+ )
85
+ self.authenticated = True
86
+ logging.info("\033[32;1mThis dataframe has now access to https://api.24sea.eu/.\033[0m") # noqa: E501 # pylint: disable=C0301
87
+ except req.exceptions.HTTPError:
88
+ raise U.AuthenticationError("\033[31;1mThe username and/or password are incorrect.\033[0m") # noqa: E501 # pylint: disable=C0301
89
+ # fmt: on
90
+ # -- Step 2: Define the metrics_overview dataframe
91
+ if self.metrics_overview is not None:
92
+ return None
93
+ logging.info("Now getting your metrics_overview table...")
94
+ r_metrics = U.handle_request(
95
+ f"{self.base_url}metrics",
96
+ {"project": None, "locations": None, "metrics": None},
97
+ self.auth,
98
+ {"accept": "application/json"},
99
+ )
100
+ # fmt: off
101
+ if not isinstance(r_metrics, type(None)):
102
+ try:
103
+ m_ = pd.DataFrame(r_metrics.json())
104
+ except Exception:
105
+ raise U.ProfileError(f"\033[31;1mThe metrics overview is empty. This is your profile information:" # noqa: E501 # pylint: disable=C0301
106
+ f"\n {r_profile.json()}")
107
+ if m_.empty:
108
+ raise U.ProfileError(f"\033[31;1mThe metrics overview is empty. This is your profile information:" # noqa: E501 # pylint: disable=C0301
109
+ f"\n {r_profile.json()}")
110
+ try:
111
+ s_ = m_.apply(lambda x: x["metric"]
112
+ .replace(x["statistic"], "")
113
+ .replace(x["short_hand"], "")
114
+ .strip(), axis=1).str.strip("_").str.split("_", expand=True) # noqa: E501 # pylint: disable=C0301
115
+ s_.columns = ["site_id", "location_id"]
116
+ # fmt: on
117
+ except Exception:
118
+ self.metrics_overview = m_
119
+ self.metrics_overview = pd.concat([m_, s_], axis=1)
120
+ return
121
+
122
+ @check_authentication
123
+ @validate_call
124
+ def get_metrics(
125
+ self,
126
+ site: str | None = None,
127
+ locations: str | list[str] | None = None,
128
+ metrics: str | list[str] | None = None,
129
+ headers: dict[str, str] | None = None,
130
+ ) -> list[dict[str, str | None]] | None:
131
+ """Get the metrics names for a site, provided the following parameters.
132
+
133
+ Parameters
134
+ ----------
135
+ site : str | None
136
+ The site name. If None, the queryable metrics for all sites
137
+ will be returned, and the locations and metrics parameters will be
138
+ ignored.
139
+ locations : list[str] | None
140
+ The locations for which to get the metrics. If None, all locations
141
+ will be considered.
142
+ metrics : list[str] | None
143
+ The metrics to get. They can be specified as regular expressions.
144
+ If None, all metrics will be considered.
145
+ For example:
146
+ * metrics=["^ACC", "^DEM"] will return all the metrics that start
147
+ with ACC or DEM,
148
+ * Similarly, metrics=["windspeed$", "winddirection$"] will return
149
+ all the metrics that end with windspeed and winddirection,
150
+ * and metrics=[".*WF_A01.*",".*WF_A02.*"] will return all metrics
151
+ that contain WF_A01 or WF_A02.
152
+
153
+ Returns
154
+ -------
155
+ list[dict[str, str | None]] | None
156
+ The metrics names for the given site, locations and metrics.
157
+
158
+ .. note::
159
+ This class method is legacy because it does not add functionality to
160
+ the DataSignals pandas accessor.
161
+
162
+ """
163
+ url = f"{self.base_url}metrics"
164
+ # fmt: on
165
+ if headers is None:
166
+ headers = {"accept": "application/json"}
167
+ if site is None:
168
+ params = {}
169
+ if isinstance(locations, list):
170
+ locations = ",".join(locations)
171
+ if isinstance(metrics, list):
172
+ metrics = ",".join(metrics)
173
+ params = {
174
+ "project": site,
175
+ "locations": locations,
176
+ "metrics": metrics,
177
+ }
178
+
179
+ r_ = U.handle_request(url, params, self.auth, headers)
180
+
181
+ # Set the return type of the get_metrics method to the Metrics schema
182
+ return r_.json() # type: ignore
183
+
184
+ @check_authentication
185
+ @validate_call
186
+ def get_data(
187
+ self,
188
+ sites: list | str | None,
189
+ locations: list | str | None,
190
+ metrics: list | str | None,
191
+ start_timestamp: str | datetime.datetime,
192
+ end_timestamp: str | datetime.datetime,
193
+ outer_join_on_timestamp: bool = False,
194
+ headers: dict[str, str] | None = None,
195
+ ):
196
+ """Get the data signals from the 24SEA API.
197
+
198
+ Parameters
199
+ ----------
200
+ filters : dict
201
+ The filters to apply when querying the data signals.
202
+
203
+ Returns
204
+ -------
205
+ pd.DataFrame
206
+ The DataFrame containing the data signals.
207
+ """
208
+ # Clean the DataFrame
209
+ self.data = pd.DataFrame()
210
+ # -- Step 1: Build the query object from GetData
211
+ query = S.GetData(
212
+ start_timestamp=start_timestamp,
213
+ end_timestamp=end_timestamp,
214
+ sites=sites,
215
+ locations=locations,
216
+ metrics=metrics,
217
+ headers=headers,
218
+ outer_join_on_timestamp=outer_join_on_timestamp,
219
+ )
220
+
221
+ match query.sites, query.locations:
222
+ case None, None:
223
+ query_str = "metric.str.contains(@query.metrics, case=False, regex=True)"
224
+ case None, _:
225
+ query_str = (
226
+ "(location.str.lower() == @query.locations or location_id.str.lower() == @query.locations) "
227
+ "and metric.str.contains(@query.metrics, case=False, regex=True)"
228
+ )
229
+ case _, None:
230
+ query_str = (
231
+ "(site.str.lower() == @query.sites or site_id.str.lower() == @query.sites) "
232
+ "and metric.str.contains(@query.metrics, case=False, regex=True)"
233
+ )
234
+ case _, _:
235
+ query_str = (
236
+ "(site_id.str.lower() == @query.sites or site.str.lower() == @query.sites) "
237
+ "and (location.str.lower() == @query.locations or location_id.str.lower() == @query.locations) "
238
+ "and metric.str.contains(@query.metrics, case=False, regex=True)"
239
+ )
240
+
241
+ logging.info(
242
+ f"\033[30;1mQuery:\033[0;34m {query_str.replace(" and ", "\n and ")}\n"
243
+ )
244
+ self.selected_metrics = self.metrics_overview.query(query_str).pipe( # type: ignore # noqa: E501 # pylint: disable=E501
245
+ lambda df: df.sort_values(
246
+ ["site", "location", "data_group", "short_hand", "statistic"],
247
+ ascending=[True, True, False, True, True],
248
+ )
249
+ )
250
+ logging.info("\033[32;1mMetrics selected for the query:\033[0m\n")
251
+ # fmt: off
252
+ logging.info(self.selected_metrics[["metric", "unit_str", "data_group",
253
+ "location", "site"]]
254
+ .reset_index(drop=True))
255
+ # fmt: on
256
+ data_frames = []
257
+ grouped_metrics = self.selected_metrics.groupby(["site", "location"])
258
+
259
+ # return grouped_metrics
260
+ logging.info(
261
+ f"\n\033[32;1mRequested time range:\033[0;33m "
262
+ f"From {str(query.start_timestamp)[:-4].replace("T", " ")} "
263
+ f"To {str(query.end_timestamp)[:-4].replace("T", " ")}\n\033[0m"
264
+ )
265
+ import concurrent.futures
266
+
267
+ def fetch_data(site, location, group):
268
+ # fmt: off
269
+ s_ = "• " + ",".join(group["metric"].tolist()).replace(",", "\n • ") # noqa: E501 # pylint: disable=C0301
270
+ logging.info(f"\033[32;1m\033[32;1m⏳ Getting data for {site} - "
271
+ f"{location}...\n Metrics: \033[0;33m{s_}\n\033[0m")
272
+ # fmt: on
273
+ r_ = U.handle_request(
274
+ f"{self.base_url}data",
275
+ {
276
+ "start_timestamp": query.start_timestamp,
277
+ "end_timestamp": query.end_timestamp,
278
+ "project": [site],
279
+ "location": [location],
280
+ "metrics": ",".join(group["metric"].tolist()),
281
+ },
282
+ self.auth,
283
+ query.headers,
284
+ )
285
+ return pd.DataFrame(r_.json())
286
+
287
+ with concurrent.futures.ThreadPoolExecutor() as executor:
288
+ # fmt: off
289
+ future_to_data = {
290
+ executor.submit(fetch_data,
291
+ site,
292
+ location,
293
+ group): (site, location)
294
+ for (site, location), group in grouped_metrics
295
+ }
296
+ # fmt: on
297
+ for future in concurrent.futures.as_completed(future_to_data):
298
+ data_frames.append(future.result())
299
+ # data_frames.append(pd.DataFrame(r_.json()))
300
+
301
+ # if left_join_on_timestamp is True, lose the location and site columns
302
+ # and join on timestamp
303
+ if outer_join_on_timestamp:
304
+ for i, df in enumerate(data_frames):
305
+ data_frames[i] = df.set_index("timestamp")
306
+ # drop site and location
307
+ data_frames[i].drop(["site", "location"], axis=1, inplace=True)
308
+ self.data = pd.concat(
309
+ [self.data] + data_frames, axis=1, join="outer"
310
+ )
311
+ else:
312
+ self.data = pd.concat([self.data] + data_frames, ignore_index=True)
313
+ # To access the data, use the .data attribute of the datasignals accessor
314
+ logging.info("\033[32;1m✔️ Data successfully retrieved.\033[0m")
315
+ print(
316
+ "To access the data, use the \033[30;1m.data\033[0m attribute "
317
+ "of the \033[30;1m.datasignals\033[0m accessor."
318
+ )
319
+ return self.data
@@ -0,0 +1,103 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Data signals types."""
3
+
4
+ import datetime
5
+
6
+ from pydantic import BaseModel, ValidationInfo, field_validator
7
+
8
+
9
+ class Metric(BaseModel):
10
+ """A pydantic schema for the metrics names."""
11
+
12
+ start_timestamp: str
13
+ end_timestamp: str
14
+ site: str
15
+ location: str
16
+ data_group: str | None
17
+ metric: str
18
+ statistic: str | None
19
+ short_hand: str | None
20
+ unit_str: str | None
21
+ print_str: str | None
22
+ description: str | None
23
+ crud_privileges: str | None
24
+
25
+
26
+ class Metrics(BaseModel):
27
+ """A pydantic schema for the metrics names."""
28
+
29
+ metrics: list[Metric]
30
+
31
+
32
+ class GetData(BaseModel):
33
+ """A pydantic schema for the data signals."""
34
+
35
+ start_timestamp: str | datetime.datetime
36
+ end_timestamp: str | datetime.datetime
37
+ sites: str | list[str] | None
38
+ locations: str | list[str] | None
39
+ metrics: str | list[str] | None
40
+ outer_join_on_timestamp: bool | None
41
+ headers: dict[str, str] | None
42
+
43
+ @field_validator("start_timestamp", "end_timestamp", mode="before")
44
+ def validate_timestamp(cls, v: str | datetime.datetime) -> str:
45
+ """Validate the timestamps."""
46
+ if isinstance(v, str):
47
+ try:
48
+ datetime.datetime.strptime(v, "%Y-%m-%dT%H:%M:%SZ")
49
+ except ValueError:
50
+ raise ValueError(
51
+ "Incorrect start timestamp format, expected ISO 8601."
52
+ )
53
+
54
+ if isinstance(v, datetime.datetime):
55
+ # Enforce timezone UTC as well
56
+ return v.strftime("%Y-%m-%dT%H:%M:%SZ")
57
+
58
+ return v
59
+
60
+ @field_validator("end_timestamp")
61
+ def validate_end_timestamp(cls, v, info: ValidationInfo):
62
+ """Validate the end timestamp."""
63
+ if "start_timestamp" in info.data and v < info.data["start_timestamp"]:
64
+ raise ValueError(
65
+ "End timestamp must be greater than start timestamp."
66
+ )
67
+ return v
68
+
69
+ @field_validator("sites", "locations", mode="before")
70
+ def validate_sites_locations(cls, v):
71
+ """Validate and normalize sites and locations."""
72
+ if isinstance(v, str):
73
+ v = [v]
74
+ if isinstance(v, list):
75
+ v = [item.lower() for item in v]
76
+ return v
77
+
78
+ @field_validator("metrics", mode="before")
79
+ def validate_metrics(cls, v):
80
+ """Validate and normalize metrics."""
81
+ if isinstance(v, str):
82
+ v = [v]
83
+ if isinstance(v, list):
84
+ # fmt: off
85
+ v = [item.replace(" ", ".*")
86
+ .replace("_", ".*")
87
+ .replace("-", ".*") for item in v]
88
+ # fmt: on
89
+ return "|".join(v)
90
+
91
+ @field_validator("outer_join_on_timestamp", mode="before")
92
+ def validate_outer_join_on_timestamp(cls, v):
93
+ """Validate the outer join on timestamp."""
94
+ if v is None:
95
+ return False
96
+ return v
97
+
98
+ @field_validator("headers", mode="before")
99
+ def validate_headers(cls, v):
100
+ """Validate the headers."""
101
+ if v is None:
102
+ return {"accept": "application/json"}
103
+ return v
api_24sea/utils.py ADDED
@@ -0,0 +1,63 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Utility functions and classes."""
3
+
4
+ import requests as req
5
+
6
+
7
+ class AuthenticationError(Exception):
8
+ """An exception to raise when the user is not authenticated."""
9
+
10
+ pass
11
+
12
+
13
+ class ProfileError(Exception):
14
+ """An exception to raise when the user is authenticated, but its profile
15
+ is not properly configured."""
16
+
17
+ pass
18
+
19
+
20
+ def handle_request(
21
+ url: str, params: dict, auth: req.auth.HTTPBasicAuth, headers: dict
22
+ ) -> req.models.Response:
23
+ """Handle the request to the 24SEA API and manage errors.
24
+
25
+ Parameters
26
+ ----------
27
+ url : str
28
+ The URL to which to send the request.
29
+ params : dict
30
+ The parameters to send with the request.
31
+ auth : requests.auth.HTTPBasicAuth
32
+ The authentication object.
33
+ headers : dict
34
+ The headers to send with the request.
35
+
36
+ Returns
37
+ -------
38
+ requests.models.Response
39
+ The response object if the request was successful, otherwise error.
40
+ """
41
+ try:
42
+ r_ = req.get(url, params=params, auth=auth, headers=headers)
43
+
44
+ if r_.status_code in [400, 401, 403, 404, 502, 503, 504]:
45
+ print(f"Request failed because: \033[31;1m{r_.text}\033[0m")
46
+ r_.raise_for_status()
47
+ # this will handle all other errors
48
+ elif r_.status_code == 500:
49
+ # fmt: off
50
+ print("\033[31;1mInternal server error. You will need to contact "
51
+ "support at \033[32;1;4msupport.api@24sea.eu\033[0m")
52
+ # fmt: on
53
+ r_.raise_for_status()
54
+ elif r_.status_code > 400:
55
+ # fmt: off
56
+ print("Request failed with status code: "
57
+ f"\033[31;1m{r_.status_code}\033[0m")
58
+ # fmt: on
59
+ r_.raise_for_status()
60
+ except (req.exceptions.ConnectionError, req.exceptions.Timeout) as exc:
61
+ print(f" Request failed because: \033[31;1m{exc}\033[0m")
62
+ raise exc
63
+ return r_
api_24sea/version.py ADDED
@@ -0,0 +1,69 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ This module exposes the current version of the package
4
+ so the version can be retreived directly within other code
5
+ files. The version assumes the semver notation (major.minor.patch-release.num).
6
+
7
+ Example:
8
+ Import the version as constant, dict or tuple:
9
+
10
+ >>> from api_24sea.version import __version__
11
+
12
+ >>> from api_24sea.version import parse_version
13
+ >>> parse_version(__version__)
14
+
15
+ Attributes:
16
+ __version__ (str): Current api_24sea version.
17
+
18
+ """
19
+ import re
20
+ from typing import NamedTuple, Optional
21
+
22
+ __version__: str = "0.1.0"
23
+
24
+ _REGEX = "".join(
25
+ [
26
+ r"(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)",
27
+ r"(?:\-(?P<release>.*)\.(?P<num>\d+))?",
28
+ ]
29
+ )
30
+
31
+
32
+ class Version(NamedTuple):
33
+ """Convenience structure for interpreting the version information"""
34
+
35
+ major: int
36
+ minor: int
37
+ patch: int
38
+ release: Optional[str] = None
39
+ num: Optional[int] = None
40
+
41
+
42
+ def parse_version(version: str) -> Version:
43
+ """Converts the given version string to a named tuple per the semantic
44
+ version guidelines"""
45
+ match = re.search(_REGEX, version)
46
+ if not match:
47
+ raise ValueError(
48
+ f"Version '{version}' does not comply with the semantic"
49
+ "versioning naming scheme"
50
+ )
51
+
52
+ major = int(match.group("major"))
53
+ minor = int(match.group("minor"))
54
+ patch = int(match.group("patch"))
55
+ release = match.group("release")
56
+
57
+ num = None # type: Optional[int]
58
+ try:
59
+ num = int(match.group("num"))
60
+ except TypeError:
61
+ pass
62
+
63
+ return Version(
64
+ major=major,
65
+ minor=minor,
66
+ patch=patch,
67
+ release=release,
68
+ num=num,
69
+ )