meteole 0.0.1__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.
Potentially problematic release.
This version of meteole might be problematic. Click here for more details.
- meteole/__init__.py +28 -0
- meteole/_arome.py +70 -0
- meteole/_arpege.py +119 -0
- meteole/_vigilance.py +153 -0
- meteole/clients.py +228 -0
- meteole/errors.py +53 -0
- meteole/forecast.py +744 -0
- meteole/py.typed +0 -0
- meteole-0.0.1.dist-info/LICENCE +177 -0
- meteole-0.0.1.dist-info/METADATA +186 -0
- meteole-0.0.1.dist-info/NOTICE +15 -0
- meteole-0.0.1.dist-info/RECORD +14 -0
- meteole-0.0.1.dist-info/WHEEL +5 -0
- meteole-0.0.1.dist-info/top_level.txt +1 -0
meteole/__init__.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from importlib.metadata import version
|
|
3
|
+
|
|
4
|
+
from meteole._arome import AromeForecast
|
|
5
|
+
from meteole._arpege import ArpegeForecast
|
|
6
|
+
from meteole._vigilance import Vigilance
|
|
7
|
+
|
|
8
|
+
__all__ = ["AromeForecast", "ArpegeForecast", "Vigilance"]
|
|
9
|
+
|
|
10
|
+
__version__ = version("meteole")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def setup_logger():
|
|
14
|
+
"""Setup logger with proper StreamHandler and formatter"""
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
logger.setLevel(logging.INFO)
|
|
17
|
+
|
|
18
|
+
handler = logging.StreamHandler()
|
|
19
|
+
handler.setLevel(logging.INFO)
|
|
20
|
+
|
|
21
|
+
# formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
|
22
|
+
formatter = logging.Formatter("%(message)s")
|
|
23
|
+
handler.setFormatter(formatter)
|
|
24
|
+
|
|
25
|
+
return logger.addHandler(handler)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
logger = setup_logger()
|
meteole/_arome.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""The interface for the observational data from the meteo-France API.
|
|
2
|
+
|
|
3
|
+
See :
|
|
4
|
+
- https://portail-api.meteofrance.fr/web/fr/api/arome
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from typing import final
|
|
11
|
+
|
|
12
|
+
from meteole.clients import BaseClient, MeteoFranceClient
|
|
13
|
+
from meteole.forecast import Forecast
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
AVAILABLE_AROME_TERRITORY: list[str] = [
|
|
18
|
+
"FRANCE",
|
|
19
|
+
"NCALED",
|
|
20
|
+
"INDIEN",
|
|
21
|
+
"POLYN",
|
|
22
|
+
"GUYANE",
|
|
23
|
+
"ANTIL",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
AROME_INSTANT_INDICATORS: list[str] = [
|
|
27
|
+
"GEOMETRIC_HEIGHT__GROUND_OR_WATER_SURFACE",
|
|
28
|
+
"BRIGHTNESS_TEMPERATURE__GROUND_OR_WATER_SURFACE",
|
|
29
|
+
"CONVECTIVE_AVAILABLE_POTENTIAL_ENERGY__GROUND_OR_WATER_SURFACE",
|
|
30
|
+
"WIND_SPEED_GUST__SPECIFIC_HEIGHT_LEVEL_ABOVE_GROUND",
|
|
31
|
+
"WIND_SPEED__SPECIFIC_HEIGHT_LEVEL_ABOVE_GROUND",
|
|
32
|
+
"RELATIVE_HUMIDITY__SPECIFIC_HEIGHT_LEVEL_ABOVE_GROUND",
|
|
33
|
+
"LOW_CLOUD_COVER__GROUND_OR_WATER_SURFACE",
|
|
34
|
+
"HIGH_CLOUD_COVER__GROUND_OR_WATER_SURFACE",
|
|
35
|
+
"MEDIUM_CLOUD_COVER__GROUND_OR_WATER_SURFACE",
|
|
36
|
+
"PRESSURE__GROUND_OR_WATER_SURFACE",
|
|
37
|
+
"TOTAL_PRECIPITATION_RATE__GROUND_OR_WATER_SURFACE",
|
|
38
|
+
"TEMPERATURE__SPECIFIC_HEIGHT_LEVEL_ABOVE_GROUND",
|
|
39
|
+
"U_COMPONENT_OF_WIND_GUST__SPECIFIC_HEIGHT_LEVEL_ABOVE_GROUND",
|
|
40
|
+
"U_COMPONENT_OF_WIND__SPECIFIC_HEIGHT_LEVEL_ABOVE_GROUND",
|
|
41
|
+
"V_COMPONENT_OF_WIND_GUST__SPECIFIC_HEIGHT_LEVEL_ABOVE_GROUND",
|
|
42
|
+
"V_COMPONENT_OF_WIND__SPECIFIC_HEIGHT_LEVEL_ABOVE_GROUND",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
AROME_OTHER_INDICATORS: list[str] = [
|
|
46
|
+
"TOTAL_WATER_PRECIPITATION__GROUND_OR_WATER_SURFACE",
|
|
47
|
+
"TOTAL_SNOW_PRECIPITATION__GROUND_OR_WATER_SURFACE",
|
|
48
|
+
"TOTAL_PRECIPITATION__GROUND_OR_WATER_SURFACE",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@final
|
|
53
|
+
class AromeForecast(Forecast):
|
|
54
|
+
"""Access the AROME numerical forecast data."""
|
|
55
|
+
|
|
56
|
+
# Model constants
|
|
57
|
+
MODEL_NAME: str = "arome"
|
|
58
|
+
INDICATORS: list[str] = AROME_INSTANT_INDICATORS + AROME_OTHER_INDICATORS
|
|
59
|
+
INSTANT_INDICATORS: list[str] = AROME_INSTANT_INDICATORS
|
|
60
|
+
BASE_ENTRY_POINT: str = "wcs/MF-NWP-HIGHRES-AROME"
|
|
61
|
+
DEFAULT_TERRITORY: str = "FRANCE"
|
|
62
|
+
DEFAULT_PRECISION: float = 0.01
|
|
63
|
+
CLIENT_CLASS: type[BaseClient] = MeteoFranceClient
|
|
64
|
+
|
|
65
|
+
def _validate_parameters(self):
|
|
66
|
+
"""Assert the parameters are valid."""
|
|
67
|
+
if self.precision not in [0.01, 0.025]:
|
|
68
|
+
raise ValueError("Parameter `precision` must be in (0.01, 0.025). It is inferred from argument `territory`")
|
|
69
|
+
if self.territory not in AVAILABLE_AROME_TERRITORY:
|
|
70
|
+
raise ValueError(f"Parameter `territory` must be in {AVAILABLE_AROME_TERRITORY}")
|
meteole/_arpege.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""The interface for the observational data from the meteo-France API.
|
|
2
|
+
|
|
3
|
+
See :
|
|
4
|
+
- https://portail-api.meteofrance.fr/web/fr/api/arpege
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any, final
|
|
10
|
+
|
|
11
|
+
from meteole.clients import BaseClient, MeteoFranceClient
|
|
12
|
+
from meteole.forecast import Forecast
|
|
13
|
+
|
|
14
|
+
AVAILABLE_ARPEGE_TERRITORY: list[str] = ["EUROPE", "GLOBE", "ATOURX", "EURAT"]
|
|
15
|
+
|
|
16
|
+
ARPEGE_INSTANT_INDICATORS: list[str] = [
|
|
17
|
+
"GEOMETRIC_HEIGHT__GROUND_OR_WATER_SURFACE",
|
|
18
|
+
"BRIGHTNESS_TEMPERATURE__GROUND_OR_WATER_SURFACE",
|
|
19
|
+
"CONVECTIVE_AVAILABLE_POTENTIAL_ENERGY__GROUND_OR_WATER_SURFACE",
|
|
20
|
+
"SPECIFIC_CLOUD_ICE_WATER_CONTENT__SPECIFIC_HEIGHT_LEVEL_ABOVE_GROUND",
|
|
21
|
+
"SPECIFIC_CLOUD_ICE_WATER_CONTENT__ISOBARIC_SURFACE",
|
|
22
|
+
"WIND_SPEED_GUST__SPECIFIC_HEIGHT_LEVEL_ABOVE_GROUND",
|
|
23
|
+
"WIND_SPEED__SPECIFIC_HEIGHT_LEVEL_ABOVE_GROUND",
|
|
24
|
+
"WIND_SPEED__ISOBARIC_SURFACE",
|
|
25
|
+
"RELATIVE_HUMIDITY__SPECIFIC_HEIGHT_LEVEL_ABOVE_GROUND",
|
|
26
|
+
"RELATIVE_HUMIDITY__ISOBARIC_SURFACE",
|
|
27
|
+
"PLANETARY_BOUNDARY_LAYER_HEIGHT__GROUND_OR_WATER_SURFACE",
|
|
28
|
+
"LOW_CLOUD_COVER__GROUND_OR_WATER_SURFACE",
|
|
29
|
+
"HIGH_CLOUD_COVER__GROUND_OR_WATER_SURFACE",
|
|
30
|
+
"MEDIUM_CLOUD_COVER__GROUND_OR_WATER_SURFACE",
|
|
31
|
+
"PRESSURE__GROUND_OR_WATER_SURFACE",
|
|
32
|
+
"PRESSURE__SPECIFIC_HEIGHT_LEVEL_ABOVE_GROUND",
|
|
33
|
+
"PRESSURE__MEAN_SEA_LEVEL",
|
|
34
|
+
"ABSOLUTE_VORTICITY__ISOBARIC_SURFACE",
|
|
35
|
+
"DEW_POINT_TEMPERATURE__SPECIFIC_HEIGHT_LEVEL_ABOVE_GROUND",
|
|
36
|
+
"DEW_POINT_TEMPERATURE__ISOBARIC_SURFACE",
|
|
37
|
+
"TURBULENT_KINETIC_ENERGY__SPECIFIC_HEIGHT_LEVEL_ABOVE_GROUND",
|
|
38
|
+
"TURBULENT_KINETIC_ENERGY__ISOBARIC_SURFACE",
|
|
39
|
+
"MAXIMUM_TEMPERATURE__SPECIFIC_HEIGHT_LEVEL_ABOVE_GROUND",
|
|
40
|
+
"MINIMUM_TEMPERATURE__SPECIFIC_HEIGHT_LEVEL_ABOVE_GROUND",
|
|
41
|
+
"PSEUDO_ADIABATIC_POTENTIAL_TEMPERATURE__ISOBARIC_SURFACE",
|
|
42
|
+
"POTENTIAL_VORTICITY__ISOBARIC_SURFACE",
|
|
43
|
+
"TEMPERATURE__GROUND_OR_WATER_SURFACE",
|
|
44
|
+
"TEMPERATURE__SPECIFIC_HEIGHT_LEVEL_ABOVE_GROUND",
|
|
45
|
+
"TEMPERATURE__ISOBARIC_SURFACE",
|
|
46
|
+
"U_COMPONENT_OF_WIND_GUST__SPECIFIC_HEIGHT_LEVEL_ABOVE_GROUND",
|
|
47
|
+
"U_COMPONENT_OF_WIND__SPECIFIC_HEIGHT_LEVEL_ABOVE_GROUND",
|
|
48
|
+
"U_COMPONENT_OF_WIND__ISOBARIC_SURFACE",
|
|
49
|
+
"U_COMPONENT_OF_WIND__POTENTIAL_VORTICITY_SURFACE_1500",
|
|
50
|
+
"U_COMPONENT_OF_WIND__POTENTIAL_VORTICITY_SURFACE_2000",
|
|
51
|
+
"VERTICAL_VELOCITY_PRESSURE__ISOBARIC_SURFACE",
|
|
52
|
+
"V_COMPONENT_OF_WIND_GUST__SPECIFIC_HEIGHT_LEVEL_ABOVE_GROUND",
|
|
53
|
+
"V_COMPONENT_OF_WIND__SPECIFIC_HEIGHT_LEVEL_ABOVE_GROUND",
|
|
54
|
+
"V_COMPONENT_OF_WIND__ISOBARIC_SURFACE",
|
|
55
|
+
"V_COMPONENT_OF_WIND__POTENTIAL_VORTICITY_SURFACE_1500",
|
|
56
|
+
"V_COMPONENT_OF_WIND__POTENTIAL_VORTICITY_SURFACE_2000",
|
|
57
|
+
"GEOPOTENTIAL__ISOBARIC_SURFACE",
|
|
58
|
+
"TOTAL_CLOUD_COVER__GROUND_OR_WATER_SURFACE",
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
ARPEGE_OTHER_INDICATORS: list[str] = [
|
|
62
|
+
"TOTAL_WATER_PRECIPITATION__GROUND_OR_WATER_SURFACE",
|
|
63
|
+
"TOTAL_SNOW_PRECIPITATION__GROUND_OR_WATER_SURFACE",
|
|
64
|
+
"TOTAL_PRECIPITATION__GROUND_OR_WATER_SURFACE",
|
|
65
|
+
"DOWNWARD_SHORT_WAVE_RADIATION_FLUX__GROUND_OR_WATER_SURFACE",
|
|
66
|
+
"SHORT_WAVE_RADIATION_FLUX__GROUND_OR_WATER_SURFACE",
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@final
|
|
71
|
+
class ArpegeForecast(Forecast):
|
|
72
|
+
"""Access the ARPEGE numerical forecast data."""
|
|
73
|
+
|
|
74
|
+
# Model constants
|
|
75
|
+
MODEL_NAME: str = "arpege"
|
|
76
|
+
INDICATORS: list[str] = ARPEGE_INSTANT_INDICATORS + ARPEGE_OTHER_INDICATORS
|
|
77
|
+
INSTANT_INDICATORS: list[str] = ARPEGE_INSTANT_INDICATORS
|
|
78
|
+
BASE_ENTRY_POINT: str = "wcs/MF-NWP-GLOBAL-ARPEGE"
|
|
79
|
+
DEFAULT_TERRITORY: str = "EUROPE"
|
|
80
|
+
RELATION_TERRITORY_TO_PREC_ARPEGE: dict[str, float] = {"EUROPE": 0.1, "GLOBE": 0.25, "ATOURX": 0.1, "EURAT": 0.05}
|
|
81
|
+
CLIENT_CLASS: type[BaseClient] = MeteoFranceClient
|
|
82
|
+
|
|
83
|
+
def __init__(
|
|
84
|
+
self,
|
|
85
|
+
client: BaseClient | None = None,
|
|
86
|
+
*,
|
|
87
|
+
territory: str = "EUROPE",
|
|
88
|
+
**kwargs: Any,
|
|
89
|
+
):
|
|
90
|
+
"""
|
|
91
|
+
Initializes an ArpegeForecast object for accessing ARPEGE forecast data.
|
|
92
|
+
|
|
93
|
+
The `precision` of the forecast is inferred from the specified `territory`.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
territory (str, optional): The ARPEGE territory to fetch. Defaults to "EUROPE".
|
|
97
|
+
api_key (str | None, optional): The API key for authentication. Defaults to None.
|
|
98
|
+
token (str | None, optional): The API token for authentication. Defaults to None.
|
|
99
|
+
application_id (str | None, optional): The Application ID for authentication. Defaults to None.
|
|
100
|
+
cache_dir (str | None, optional): Path to the cache directory. Defaults to None.
|
|
101
|
+
If not provided, the cache directory is set to "/tmp/cache".
|
|
102
|
+
|
|
103
|
+
Notes:
|
|
104
|
+
- See `MeteoFranceClient` for additional details on the parameters `api_key`, `token`,
|
|
105
|
+
and `application_id`.
|
|
106
|
+
- Available territories are listed in the `AVAILABLE_TERRITORY` constant.
|
|
107
|
+
|
|
108
|
+
"""
|
|
109
|
+
super().__init__(
|
|
110
|
+
client=client,
|
|
111
|
+
territory=territory,
|
|
112
|
+
precision=self.RELATION_TERRITORY_TO_PREC_ARPEGE[territory],
|
|
113
|
+
**kwargs,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def _validate_parameters(self):
|
|
117
|
+
"""Assert the parameters are valid."""
|
|
118
|
+
if self.territory not in AVAILABLE_ARPEGE_TERRITORY:
|
|
119
|
+
raise ValueError(f"The parameter precision must be in {AVAILABLE_ARPEGE_TERRITORY}")
|
meteole/_vigilance.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from io import BytesIO
|
|
5
|
+
from typing import Any, final
|
|
6
|
+
|
|
7
|
+
import matplotlib.image as mpimg
|
|
8
|
+
import matplotlib.pyplot as plt
|
|
9
|
+
import pandas as pd
|
|
10
|
+
from requests import Response
|
|
11
|
+
|
|
12
|
+
from meteole.clients import BaseClient, MeteoFranceClient
|
|
13
|
+
from meteole.errors import MissingDataError
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@final
|
|
19
|
+
class Vigilance:
|
|
20
|
+
"""Wrapper around the meteo-France API for the vigilance data.
|
|
21
|
+
Ressources are:
|
|
22
|
+
- textesvigilance
|
|
23
|
+
- cartevigilance
|
|
24
|
+
|
|
25
|
+
Documentation
|
|
26
|
+
-------------
|
|
27
|
+
See:
|
|
28
|
+
- https://portail-api.meteofrance.fr/web/fr/api/DonneesPubliquesVigilance
|
|
29
|
+
- https://donneespubliques.meteofrance.fr/client/document/descriptiftechnique_vigilancemetropole_
|
|
30
|
+
donneespubliques_v4_20230911_307.pdf
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
API_VERSION: str = "v1"
|
|
34
|
+
VIGILANCE_BASE_PATH: str = "DPVigilance/"
|
|
35
|
+
PHENOMENO_IDS: dict[str, str] = {
|
|
36
|
+
"1": "wind",
|
|
37
|
+
"2": "rain",
|
|
38
|
+
"3": "storm",
|
|
39
|
+
"4": "flood",
|
|
40
|
+
"5": "snow_ice",
|
|
41
|
+
"6": "heat_wave",
|
|
42
|
+
"7": "extreme_cold",
|
|
43
|
+
"8": "avalanches",
|
|
44
|
+
"9": "waves_submergence",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
client: BaseClient | None = None,
|
|
50
|
+
**kwargs: Any,
|
|
51
|
+
) -> None:
|
|
52
|
+
"""TODO"""
|
|
53
|
+
if client is not None:
|
|
54
|
+
self._client = client
|
|
55
|
+
else:
|
|
56
|
+
# Try to instantiate it (can be user friendly)
|
|
57
|
+
self._client = MeteoFranceClient(**kwargs)
|
|
58
|
+
|
|
59
|
+
def get_bulletin(self) -> dict[str, Any]:
|
|
60
|
+
"""
|
|
61
|
+
Retrieve the vigilance bulletin.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
dict: a Dict representing the vigilance bulletin
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
path: str = self.VIGILANCE_BASE_PATH + self.API_VERSION + "/textesvigilance/encours"
|
|
68
|
+
logger.debug(f"GET {path}")
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
resp: Response = self._client.get(path)
|
|
72
|
+
return resp.json()
|
|
73
|
+
|
|
74
|
+
except MissingDataError as e:
|
|
75
|
+
if "no matching blob" in e.message:
|
|
76
|
+
logger.warning("Ongoing vigilance requires no publication")
|
|
77
|
+
else:
|
|
78
|
+
logger.error(f"Unexpected error: {e}")
|
|
79
|
+
return {}
|
|
80
|
+
except Exception as e:
|
|
81
|
+
logger.error(f"Unexpected error: {e}")
|
|
82
|
+
return {}
|
|
83
|
+
|
|
84
|
+
def get_map(self) -> dict[str, Any]:
|
|
85
|
+
"""
|
|
86
|
+
Get the vigilance map with predicted risk displayed.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
dict: a Dict with the predicted risk.
|
|
90
|
+
"""
|
|
91
|
+
path: str = self.VIGILANCE_BASE_PATH + self.API_VERSION + "/cartevigilance/encours"
|
|
92
|
+
logger.debug(f"GET {path}")
|
|
93
|
+
|
|
94
|
+
resp: Response = self._client.get(path)
|
|
95
|
+
|
|
96
|
+
return resp.json()
|
|
97
|
+
|
|
98
|
+
def get_phenomenon(self) -> tuple[pd.DataFrame, pd.DataFrame]:
|
|
99
|
+
"""
|
|
100
|
+
Get risk prediction by phenomenon and by domain.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
pd.DataFrame: a DataFrame with phenomenon by id
|
|
104
|
+
pd.DataFrame: a DataFrame with phenomenon by domain
|
|
105
|
+
"""
|
|
106
|
+
df_carte = pd.DataFrame(self.get_map())
|
|
107
|
+
periods_data = df_carte.loc["periods", "product"]
|
|
108
|
+
df_periods = pd.json_normalize(periods_data)
|
|
109
|
+
|
|
110
|
+
df_j = df_periods[df_periods["echeance"] == "J"]
|
|
111
|
+
df_j1 = df_periods[df_periods["echeance"] == "J1"]
|
|
112
|
+
|
|
113
|
+
df_phenomenon_j = pd.json_normalize(df_j["per_phenomenon_items"].explode())
|
|
114
|
+
df_phenomenon_j1 = pd.json_normalize(df_j1["per_phenomenon_items"].explode())
|
|
115
|
+
df_phenomenon_j["echeance"] = "J"
|
|
116
|
+
df_phenomenon_j1["echeance"] = "J1"
|
|
117
|
+
|
|
118
|
+
df_phenomenon = pd.concat([df_phenomenon_j, df_phenomenon_j1]).reset_index(drop=True)
|
|
119
|
+
df_phenomenon["phenomenon_libelle"] = df_phenomenon["phenomenon_id"].map(self.PHENOMENO_IDS)
|
|
120
|
+
|
|
121
|
+
df_timelaps_j = pd.json_normalize(df_j["timelaps.domain_ids"].explode())
|
|
122
|
+
df_timelaps_j1 = pd.json_normalize(df_j1["timelaps.domain_ids"].explode())
|
|
123
|
+
df_timelaps_j["echeance"] = "J"
|
|
124
|
+
df_timelaps_j1["echeance"] = "J1"
|
|
125
|
+
|
|
126
|
+
df_timelaps = pd.concat([df_timelaps_j, df_timelaps_j1]).reset_index(drop=True)
|
|
127
|
+
|
|
128
|
+
return df_phenomenon, df_timelaps
|
|
129
|
+
|
|
130
|
+
def get_vignette(self) -> None:
|
|
131
|
+
"""
|
|
132
|
+
Get png.
|
|
133
|
+
"""
|
|
134
|
+
path: str = self.VIGILANCE_BASE_PATH + self.API_VERSION + "/vignettenationale-J-et-J1/encours"
|
|
135
|
+
|
|
136
|
+
logger.debug(f"GET {path}")
|
|
137
|
+
resp: Response = self._client.get(path)
|
|
138
|
+
|
|
139
|
+
if resp.status_code == 200:
|
|
140
|
+
content: str | None = resp.headers.get("content-disposition")
|
|
141
|
+
if content is not None:
|
|
142
|
+
filename: str = content.split("filename=")[1]
|
|
143
|
+
filename = filename.strip('"')
|
|
144
|
+
|
|
145
|
+
with open(filename, "wb") as f:
|
|
146
|
+
f.write(resp.content)
|
|
147
|
+
|
|
148
|
+
img = mpimg.imread(BytesIO(resp.content), format="png")
|
|
149
|
+
plt.imshow(img)
|
|
150
|
+
plt.axis("off")
|
|
151
|
+
plt.show()
|
|
152
|
+
else:
|
|
153
|
+
resp.raise_for_status()
|
meteole/clients.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""API client classes"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import requests
|
|
13
|
+
from requests import Response, Session
|
|
14
|
+
|
|
15
|
+
from meteole.errors import GenericMeteofranceApiError, MissingDataError
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class HttpStatus(int, Enum):
|
|
21
|
+
"""Http status codes"""
|
|
22
|
+
|
|
23
|
+
OK: int = 200
|
|
24
|
+
BAD_REQUEST: int = 400
|
|
25
|
+
UNAUTHORIZED: int = 401
|
|
26
|
+
FORBIDDEN: int = 403
|
|
27
|
+
NOT_FOUND: int = 404
|
|
28
|
+
TOO_MANY_REQUESTS: int = 429
|
|
29
|
+
INTERNAL_ERROR: int = 500
|
|
30
|
+
BAD_GATEWAY: int = 502
|
|
31
|
+
UNAVAILABLE: int = 503
|
|
32
|
+
GATEWAY_TIMEOUT: int = 504
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class BaseClient(ABC):
|
|
36
|
+
"""TODO"""
|
|
37
|
+
|
|
38
|
+
@abstractmethod
|
|
39
|
+
def get(self, path: str, *, params: dict[str, Any] | None = None, max_retries: int = 5) -> Response:
|
|
40
|
+
"""TODO"""
|
|
41
|
+
raise NotImplementedError
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class MeteoFranceClient(BaseClient):
|
|
45
|
+
"""A client for interacting with the Meteo France API.
|
|
46
|
+
|
|
47
|
+
This class handles the connection setup and token refreshment required for
|
|
48
|
+
authenticating and making requests to the Meteo France API.
|
|
49
|
+
|
|
50
|
+
Attributes:
|
|
51
|
+
api_key (str | None): The API key for accessing the Meteo France API.
|
|
52
|
+
token (str | None): The authentication token for accessing the API.
|
|
53
|
+
application_id (str | None): The application ID used for identification.
|
|
54
|
+
verify (Path | None): The path to a file or directory of trusted CA certificates for SSL verification.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
# Class constants
|
|
58
|
+
API_BASE_URL: str = "https://public-api.meteofrance.fr/public/"
|
|
59
|
+
TOKEN_URL: str = "https://portail-api.meteofrance.fr/token"
|
|
60
|
+
GET_TOKEN_TIMEOUT_SEC: int = 10
|
|
61
|
+
INVALID_JWT_ERROR_CODE: str = "900901"
|
|
62
|
+
RETRY_DELAY_SEC: int = 5
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
*,
|
|
67
|
+
token: str | None = None,
|
|
68
|
+
api_key: str | None = None,
|
|
69
|
+
application_id: str | None = None,
|
|
70
|
+
certs_path: Path | None = None,
|
|
71
|
+
) -> None:
|
|
72
|
+
"""
|
|
73
|
+
Initializes the MeteoFranceClient object.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
api_key (str | None): The API key for accessing the Meteo France API.
|
|
77
|
+
token (str | None): The authentication token for accessing the API.
|
|
78
|
+
application_id (str | None): The application ID used for identification.
|
|
79
|
+
verify (Path | None): The path to a file or directory of trusted CA certificates for SSL verification.
|
|
80
|
+
"""
|
|
81
|
+
self._token = token
|
|
82
|
+
self._api_key = api_key
|
|
83
|
+
self._application_id = application_id
|
|
84
|
+
self._verify: str | None = str(certs_path) if certs_path is not None else None
|
|
85
|
+
|
|
86
|
+
self._session = Session()
|
|
87
|
+
|
|
88
|
+
self._token_expired: bool = False
|
|
89
|
+
|
|
90
|
+
# Initialize the requests session object
|
|
91
|
+
self._connect()
|
|
92
|
+
|
|
93
|
+
def get(self, path: str, *, params: dict[str, Any] | None = None, max_retries: int = 5) -> Response:
|
|
94
|
+
"""
|
|
95
|
+
Makes a GET request to the API with optional retries.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
url (str): The URL to send the GET request to.
|
|
99
|
+
params (dict, optional): The query parameters to include in the request. Defaults to None.
|
|
100
|
+
max_retries (int, optional): The maximum number of retry attempts in case of failure. Defaults to 5.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
requests.Response: The response returned by the API.
|
|
104
|
+
|
|
105
|
+
"""
|
|
106
|
+
url: str = self.API_BASE_URL + path
|
|
107
|
+
attempt: int = 0
|
|
108
|
+
logger.debug(f"GET {url}")
|
|
109
|
+
|
|
110
|
+
while attempt < max_retries:
|
|
111
|
+
# HTTP GET request
|
|
112
|
+
resp: Response = self._session.get(url, params=params, verify=self._verify)
|
|
113
|
+
|
|
114
|
+
if resp.status_code == HttpStatus.OK:
|
|
115
|
+
logger.debug("Successful request")
|
|
116
|
+
return resp
|
|
117
|
+
|
|
118
|
+
elif self._is_token_expired(resp):
|
|
119
|
+
logger.info("Token expired, requesting a new one")
|
|
120
|
+
|
|
121
|
+
# Refresh the cached token
|
|
122
|
+
self._token = self._get_token()
|
|
123
|
+
|
|
124
|
+
# Reconnect with the new token
|
|
125
|
+
self._connect()
|
|
126
|
+
|
|
127
|
+
elif resp.status_code == HttpStatus.FORBIDDEN:
|
|
128
|
+
logger.error("Access forbidden")
|
|
129
|
+
raise GenericMeteofranceApiError(resp.text)
|
|
130
|
+
|
|
131
|
+
elif resp.status_code == HttpStatus.BAD_REQUEST:
|
|
132
|
+
logger.error("Parameter error")
|
|
133
|
+
raise GenericMeteofranceApiError(resp.text)
|
|
134
|
+
|
|
135
|
+
elif resp.status_code == HttpStatus.NOT_FOUND:
|
|
136
|
+
logger.error("Missing data")
|
|
137
|
+
raise MissingDataError(resp.text)
|
|
138
|
+
|
|
139
|
+
elif (
|
|
140
|
+
resp.status_code == HttpStatus.BAD_GATEWAY
|
|
141
|
+
or resp.status_code == HttpStatus.UNAVAILABLE
|
|
142
|
+
or resp.status_code == HttpStatus.GATEWAY_TIMEOUT
|
|
143
|
+
):
|
|
144
|
+
logger.error("Service not available")
|
|
145
|
+
time.sleep(self.RETRY_DELAY_SEC)
|
|
146
|
+
attempt += 1
|
|
147
|
+
logger.info(f"Retrying... Attempt {attempt} of {max_retries}")
|
|
148
|
+
continue
|
|
149
|
+
|
|
150
|
+
raise GenericMeteofranceApiError(f"Failed to get a successful response from API after {attempt} retries")
|
|
151
|
+
|
|
152
|
+
def _connect(self):
|
|
153
|
+
"""Connect to the MeteoFrance API.
|
|
154
|
+
|
|
155
|
+
If the API key is provided, it is used to authenticate the user.
|
|
156
|
+
If the token is provided, it is used to authenticate the user.
|
|
157
|
+
If the application ID is provided, a token is requested from the API.
|
|
158
|
+
"""
|
|
159
|
+
if self._api_key is None and self._token is None:
|
|
160
|
+
if self._application_id is None:
|
|
161
|
+
raise ValueError("api_key or token or application_id must be provided")
|
|
162
|
+
|
|
163
|
+
# Connection with application_id
|
|
164
|
+
self._token = self._get_token()
|
|
165
|
+
|
|
166
|
+
if self._api_key is not None:
|
|
167
|
+
logger.debug("using api key")
|
|
168
|
+
|
|
169
|
+
# Connection with api_key
|
|
170
|
+
self._session.headers.update({"apikey": self._api_key})
|
|
171
|
+
|
|
172
|
+
else:
|
|
173
|
+
logger.debug("using token")
|
|
174
|
+
|
|
175
|
+
# Connection with token
|
|
176
|
+
self._session.headers.update({"Authorization": f"Bearer {self._token}"})
|
|
177
|
+
|
|
178
|
+
def _get_token(self) -> str:
|
|
179
|
+
"""request a token from the meteo-France API.
|
|
180
|
+
|
|
181
|
+
The token lasts 1 hour, and is used to authenticate the user.
|
|
182
|
+
If a new token is requested before the previous one expires, the previous one is invalidated.
|
|
183
|
+
A local cache is used to avoid requesting a new token at each run of the script.
|
|
184
|
+
"""
|
|
185
|
+
if self._token_expired is False and self._token is not None:
|
|
186
|
+
# Use cached token
|
|
187
|
+
token: str = self._token
|
|
188
|
+
|
|
189
|
+
elif self._token_expired is True and self._application_id is None:
|
|
190
|
+
# Can do nothing
|
|
191
|
+
raise ValueError("The 'application_id' is unknown, can't get a new token")
|
|
192
|
+
|
|
193
|
+
else:
|
|
194
|
+
# Retrieve a new token
|
|
195
|
+
|
|
196
|
+
# Seems useless (TODO remove if it's True):
|
|
197
|
+
# params: dict[str, str] = {"grant_type": "client_credentials"}
|
|
198
|
+
headers: dict[str, str] = {"Authorization": "Basic " + str(self._application_id)}
|
|
199
|
+
|
|
200
|
+
resp: Response = requests.post(
|
|
201
|
+
self.TOKEN_URL,
|
|
202
|
+
# params=params,
|
|
203
|
+
headers=headers,
|
|
204
|
+
timeout=self.GET_TOKEN_TIMEOUT_SEC,
|
|
205
|
+
verify=self._verify,
|
|
206
|
+
)
|
|
207
|
+
token = resp.json()["access_token"]
|
|
208
|
+
|
|
209
|
+
return token
|
|
210
|
+
|
|
211
|
+
def _is_token_expired(self, response: Response) -> bool:
|
|
212
|
+
"""Check if the token is expired.
|
|
213
|
+
|
|
214
|
+
Returns
|
|
215
|
+
-------
|
|
216
|
+
bool
|
|
217
|
+
True if the token is expired, False otherwise.
|
|
218
|
+
"""
|
|
219
|
+
result: bool = False
|
|
220
|
+
|
|
221
|
+
if response.status_code == HttpStatus.UNAUTHORIZED and "application/json" in response.headers["Content-Type"]:
|
|
222
|
+
error: str = response.json()["code"]
|
|
223
|
+
|
|
224
|
+
if error == self.INVALID_JWT_ERROR_CODE:
|
|
225
|
+
result = True
|
|
226
|
+
self._token_expired = True
|
|
227
|
+
|
|
228
|
+
return result
|
meteole/errors.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import xmltodict
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class GenericMeteofranceApiError(Exception):
|
|
7
|
+
"""Exception raised errors in the input parameters where a required field is missing.
|
|
8
|
+
|
|
9
|
+
Args:
|
|
10
|
+
message (str): Human-readable string descipting the exceptetion.
|
|
11
|
+
description (str): More detailed description of the error."""
|
|
12
|
+
|
|
13
|
+
def init(self, text: str) -> None:
|
|
14
|
+
"""Initialize the exception with an error message parsed from an XML
|
|
15
|
+
string.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
text (str): XML string containing the error details,
|
|
19
|
+
expected to follow a specific schema with 'am:fault' as the root
|
|
20
|
+
element and 'am:message' and 'am:description' as child elements."""
|
|
21
|
+
|
|
22
|
+
# parse the error message with xmltodict
|
|
23
|
+
data = xmltodict.parse(text)
|
|
24
|
+
message = data["am:fault"]["am:message"]
|
|
25
|
+
description = data["am:fault"]["am:description"]
|
|
26
|
+
self.message = f"{message}\n {description}"
|
|
27
|
+
super().__init__(self.message)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class MissingDataError(Exception):
|
|
31
|
+
"""Exception raised errors in the input data is missing"""
|
|
32
|
+
|
|
33
|
+
def init(self, text: str) -> None:
|
|
34
|
+
"""Initialize the exception with an error message parsed from an XML
|
|
35
|
+
string.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
text (str): XML string containing the error details,
|
|
39
|
+
expected to follow a specific schema with 'am:fault' as the root
|
|
40
|
+
element and 'am:message' and 'am:description' as child elements."""
|
|
41
|
+
|
|
42
|
+
# parse the error message with xmltodict
|
|
43
|
+
try:
|
|
44
|
+
data = xmltodict.parse(text)
|
|
45
|
+
exception = data["mw:fault"]["mw:description"]["ns0:ExceptionReport"]["ns0:Exception"]
|
|
46
|
+
code = exception["@exceptionCode"]
|
|
47
|
+
locator = exception["@locator"]
|
|
48
|
+
exception_text = exception["ns0:ExceptionText"]
|
|
49
|
+
message = f"Error code: {code}\nLocator: {locator}\nText: {exception_text}"
|
|
50
|
+
except Exception:
|
|
51
|
+
message = text
|
|
52
|
+
self.message = message
|
|
53
|
+
super().__init__(self.message)
|