diff --git a/CHANGELOG.md b/CHANGELOG.md index 425472a7d..04c973cb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ Attention: The newest changes should be on top --> ### Added +- ENH: Support for Meteomatics API in the `Environment` class [#1079](https://github.com/RocketPy-Team/RocketPy/pull/1079) - ENH: BUG/MNT: pre-release v1.13.0 review fixes [#1074](https://github.com/RocketPy-Team/RocketPy/pull/1074) ### Changed diff --git a/docs/user/environment/3-further/other_apis.rst b/docs/user/environment/3-further/other_apis.rst index 37a9a0949..9aacd8273 100644 --- a/docs/user/environment/3-further/other_apis.rst +++ b/docs/user/environment/3-further/other_apis.rst @@ -159,6 +159,56 @@ For custom dictionaries, the canonical structure is: simulation workflow. +Meteomatics API +--------------- + +RocketPy can build an ``Environment`` directly from the +`Meteomatics `_ weather API. +Meteomatics authenticates with a personal **username** and **password** (a +short-lived access token is generated automatically under the hood), so you +need a Meteomatics account to use this feature. + +The API is queried for temperature, pressure and both wind components at +several altitudes above ground level around the launch site, which are then +converted to profiles above sea level using the ``Environment`` elevation. +Because of that, make sure a launch ``date`` and a reasonable ``elevation`` are +set before calling the method. + +.. code-block:: python + + from datetime import datetime, timedelta + from rocketpy import Environment + + env = Environment( + latitude=39.3897, + longitude=-8.28896, + elevation=113, + date=datetime.now() + timedelta(days=1), # forecast instant + ) + + env.set_atmospheric_model( + type="Meteomatics", + file="mix", # Meteomatics weather model + username="your_username", + password="your_password", + ) + + env.info() + +If you prefer not to hardcode the credentials, omit the ``username`` and +``password`` arguments and RocketPy will read them from the +``METEOMATICS_USERNAME`` and ``METEOMATICS_PASSWORD`` environment variables. + +.. note:: + + The altitude range and sampling resolution can be tuned by calling + :meth:`rocketpy.Environment.process_meteomatics_atmosphere` directly (for + example, to change ``min_altitude``, ``max_altitude`` or the number of + levels). The API returns an error if the requested altitude is outside the + range supported by the chosen model, and your account may not have access + to every model. + + Without OPeNDAP protocol ------------------------- @@ -166,7 +216,6 @@ On the other hand, one can also load data from APIs that do not support the OPeN In these cases, what we recommend is to download the data and then load it as a custom atmosphere. There are some efforts to natively support other APIs in RocketPy's -Environment class, for example: +Environment class, for example: -- `Meteomatics `_: `#545 `_ - `Open-Meteo `_: `#520 `_ diff --git a/rocketpy/environment/environment.py b/rocketpy/environment/environment.py index 6a5fb2c1c..7155133fc 100644 --- a/rocketpy/environment/environment.py +++ b/rocketpy/environment/environment.py @@ -2,6 +2,7 @@ import bisect import json import logging +import os import re import warnings from collections import namedtuple @@ -13,6 +14,7 @@ from rocketpy.environment.fetchers import ( fetch_aigfs_file_return_dataset, + fetch_atmospheric_data_from_meteomatics, fetch_atmospheric_data_from_windy, fetch_gefs_ensemble, fetch_gfs_file_return_dataset, @@ -145,8 +147,8 @@ class Environment: Environment.atmospheric_model_type : string Describes the atmospheric model which is being used. Can only assume the following values: ``standard_atmosphere``, ``custom_atmosphere``, - ``wyoming_sounding``, ``windy``, ``forecast``, ``reanalysis``, - ``ensemble``. + ``wyoming_sounding``, ``windy``, ``meteomatics``, ``forecast``, + ``reanalysis``, ``ensemble``. Environment.atmospheric_model_file : string Address of the file used for the atmospheric model being used. Only defined for ``wyoming_sounding``, ``windy``, ``forecast``, @@ -1188,6 +1190,8 @@ def set_atmospheric_model( # pylint: disable=too-many-statements wind_u=0, wind_v=0, pressure_conversion_factor=None, + username=None, + password=None, ): """Define the atmospheric model for this Environment. @@ -1196,8 +1200,8 @@ def set_atmospheric_model( # pylint: disable=too-many-statements type : string Atmospheric model selector (case-insensitive). Accepted values are ``"standard_atmosphere"``, ``"wyoming_sounding"``, ``"windy"``, - ``"forecast"``, ``"reanalysis"``, ``"ensemble"`` and - ``"custom_atmosphere"``. + ``"forecast"``, ``"reanalysis"``, ``"ensemble"``, + ``"custom_atmosphere"`` and ``"meteomatics"``. file : string | netCDF4.Dataset, optional Data source or model shortcut. Meaning depends on ``type``: @@ -1205,6 +1209,9 @@ def set_atmospheric_model( # pylint: disable=too-many-statements - ``"wyoming_sounding"``: URL of the sounding text page. - ``"windy"``: one of ``"ECMWF"``, ``"GFS"``, ``"ICON"`` or ``"ICONEU"``. + - ``"meteomatics"``: the Meteomatics weather model to query, such + as ``"mix"`` (the default when omitted). See the Meteomatics + documentation for the models available to your account. - ``"forecast"``: local path, OPeNDAP URL, open ``netCDF4.Dataset``, or one of ``"AIGFS"``, ``"GFS"``, ``"NAM"``, ``"RAP"``, ``"HRRR"`` or ``"HIRESW"`` for the @@ -1290,6 +1297,14 @@ def set_atmospheric_model( # pylint: disable=too-many-statements model name (e.g. ERA5/ECMWF/MERRA2 reanalysis files commonly use hPa, while online GFS/NAM/RAP/HRRR forecast models use Pa) or, if unavailable, by reading the pressure unit attribute from the file. + username : string, optional + Meteomatics account username. Only used when ``type`` is + ``"meteomatics"``. If None (the default), the value is read from the + ``METEOMATICS_USERNAME`` environment variable. + password : string, optional + Meteomatics account password. Only used when ``type`` is + ``"meteomatics"``. If None (the default), the value is read from the + ``METEOMATICS_PASSWORD`` environment variable. Returns ------- @@ -1338,6 +1353,10 @@ def set_atmospheric_model( # pylint: disable=too-many-statements self.process_custom_atmosphere(pressure, temperature, wind_u, wind_v) case "windy": self.process_windy_atmosphere(file) + case "meteomatics": + self.process_meteomatics_atmosphere( + model=file, username=username, password=password + ) case "forecast" | "reanalysis" | "ensemble": # Capture the user-supplied names before __validate_dictionary # converts them to dicts, so they can drive auto-detection. @@ -1736,6 +1755,174 @@ def __parse_windy_file(self, response, time_index, pressure_levels): wind_v_array, ) + def process_meteomatics_atmosphere( # pylint: disable=too-many-locals + self, + model="mix", + username=None, + password=None, + min_altitude=10, + max_altitude=12000, + wind_resolution=20, + temperature_pressure_resolution=10, + query_limit=10, + ): + """Process data from the Meteomatics API to retrieve a vertical + atmospheric profile at the launch site. + + The Meteomatics API is queried for temperature, pressure and both wind + components at several altitudes above ground level, which are then + converted to profiles above sea level using the ``Environment`` + elevation. Authentication uses a personal username and password; when + not provided, they are read from the ``METEOMATICS_USERNAME`` and + ``METEOMATICS_PASSWORD`` environment variables. + + Parameters + ---------- + model : str, optional + The Meteomatics weather model to query. Default is ``"mix"``. Your + account may not have access to every model. + username : str, optional + Meteomatics account username. Defaults to the + ``METEOMATICS_USERNAME`` environment variable. + password : str, optional + Meteomatics account password. Defaults to the + ``METEOMATICS_PASSWORD`` environment variable. + min_altitude : float, optional + Lowest altitude above ground level (in meters) to query. Default is + 10. + max_altitude : float, optional + Highest altitude above ground level (in meters) to query. Default + is 12000. The API errors if it lies outside the model's supported + range. + wind_resolution : int, optional + Number of altitude levels used for the wind components. Default is + 20. + temperature_pressure_resolution : int, optional + Number of altitude levels used for temperature and pressure. + Default is 10. + query_limit : int, optional + Maximum number of parameters requested at once. Parameters are + grouped accordingly to respect the account's per-request limit. + Default is 10. + + Raises + ------ + ValueError + If credentials are missing, if no launch date is set, or if the API + returns no usable data. + """ + model = model if isinstance(model, str) else "mix" + username = username or os.environ.get("METEOMATICS_USERNAME") + password = password or os.environ.get("METEOMATICS_PASSWORD") + if not username or not password: + raise ValueError( + "Meteomatics requires a username and password. Provide them via " + "the 'username' and 'password' arguments of set_atmospheric_model, " + "or set the METEOMATICS_USERNAME and METEOMATICS_PASSWORD " + "environment variables." + ) + if getattr(self, "datetime_date", None) is None: + raise ValueError( + "A launch date is required to use the Meteomatics atmospheric " + "model. Provide it when creating the Environment or via set_date()." + ) + + profiles = fetch_atmospheric_data_from_meteomatics( + username=username, + password=password, + latitude=self.latitude, + longitude=self.longitude, + date=self.datetime_date, + model=model, + min_altitude=min_altitude, + max_altitude=max_altitude, + wind_resolution=wind_resolution, + temperature_pressure_resolution=temperature_pressure_resolution, + query_limit=query_limit, + ) + + if self.elevation == 0: + warnings.warn( + "The Environment elevation is 0 m (possibly unset), so " + "Meteomatics heights above ground level are being treated as " + "heights above sea level. If the launch site is not at sea " + "level, set the elevation (e.g. Environment(elevation=...) or " + "set_elevation('Open-Elevation')) before this call for an " + "accurate profile.", + UserWarning, + stacklevel=2, + ) + + def to_profile_array(profile): + """Convert an {AGL height: value} mapping into a sorted + (ASL height, value) array, dropping any missing value.""" + heights = sorted(h for h, value in profile.items() if value is not None) + return np.array( + [(h + self.elevation, profile[h]) for h in heights], dtype=float + ) + + pressure_array = to_profile_array(profiles["pressure"]) + temperature_array = to_profile_array(profiles["temperature"]) + + # Wind u and v share the same altitude grid; keep only common levels. + wind_heights = sorted( + h + for h in set(profiles["wind_u"]) & set(profiles["wind_v"]) + if profiles["wind_u"][h] is not None and profiles["wind_v"][h] is not None + ) + if not (len(pressure_array) and len(temperature_array) and wind_heights): + raise ValueError( + "Meteomatics returned no usable atmospheric data. Check the " + "requested model, altitude range and your account permissions." + ) + + wind_u_values = np.array([profiles["wind_u"][h] for h in wind_heights]) + wind_v_values = np.array([profiles["wind_v"][h] for h in wind_heights]) + wind_asl_heights = np.array(wind_heights, dtype=float) + self.elevation + wind_u_array = np.column_stack((wind_asl_heights, wind_u_values)) + wind_v_array = np.column_stack((wind_asl_heights, wind_v_values)) + + wind_speed_array = calculate_wind_speed(wind_u_values, wind_v_values) + wind_heading_array = calculate_wind_heading(wind_u_values, wind_v_values) + wind_direction_array = convert_wind_heading_to_direction(wind_heading_array) + + # Save atmospheric data + self.__set_pressure_function(pressure_array) + self.__set_barometric_height_function(pressure_array[:, (1, 0)]) + self.__set_temperature_function(temperature_array) + self.__set_wind_velocity_x_function(wind_u_array) + self.__set_wind_velocity_y_function(wind_v_array) + self.__set_wind_heading_function( + np.column_stack((wind_asl_heights, wind_heading_array)) + ) + self.__set_wind_direction_function( + np.column_stack((wind_asl_heights, wind_direction_array)) + ) + self.__set_wind_speed_function( + np.column_stack((wind_asl_heights, wind_speed_array)) + ) + + # Save maximum expected height + self._max_expected_height = float( + max(pressure_array[-1, 0], temperature_array[-1, 0], wind_asl_heights[-1]) + ) + + # Save model info metadata (single point in space and time) + self.atmospheric_model_init_date = self.datetime_date + self.atmospheric_model_end_date = self.datetime_date + self.atmospheric_model_interval = 0 + self.atmospheric_model_init_lat = self.latitude + self.atmospheric_model_end_lat = self.latitude + self.atmospheric_model_init_lon = self.longitude + self.atmospheric_model_end_lon = self.longitude + + # Save debugging data + self.wind_us = wind_u_values + self.wind_vs = wind_v_values + self.temperatures = temperature_array[:, 1] + self.pressures = pressure_array[:, 1] + self.height = wind_asl_heights + def process_wyoming_sounding(self, file): # pylint: disable=too-many-statements """Import and process the upper air sounding data from `Wyoming Upper Air Soundings` database given by the url in file. Sets @@ -3004,7 +3191,13 @@ def from_dict(cls, data): # pylint: disable=too-many-statements env.elevation = data["elevation"] env.max_expected_height = data["max_expected_height"] - if atmospheric_model in ("windy", "forecast", "reanalysis", "ensemble"): + if isinstance(atmospheric_model, str) and atmospheric_model.lower() in ( + "windy", + "meteomatics", + "forecast", + "reanalysis", + "ensemble", + ): env.atmospheric_model_init_date = data["atmospheric_model_init_date"] env.atmospheric_model_end_date = data["atmospheric_model_end_date"] env.atmospheric_model_interval = data["atmospheric_model_interval"] diff --git a/rocketpy/environment/fetchers.py b/rocketpy/environment/fetchers.py index 740e9818a..af0d2c893 100644 --- a/rocketpy/environment/fetchers.py +++ b/rocketpy/environment/fetchers.py @@ -3,12 +3,14 @@ functions may be changed without notice in future feature releases. """ +import base64 import logging import re import time from datetime import datetime, timedelta, timezone import netCDF4 +import numpy as np import requests from rocketpy.tools import exponential_backoff @@ -17,6 +19,13 @@ MAX_RETRY_DELAY_SECONDS = 600 +METEOMATICS_BASE_URL = "https://api.meteomatics.com" +METEOMATICS_LOGIN_URL = "https://login.meteomatics.com/api/v1/token" +METEOMATICS_TIMEOUT_SECONDS = 30 +# Matches Meteomatics height-level parameters such as "t_500m:K", +# "pressure_1000m:Pa" or "wind_speed_u_120m:ms". +_METEOMATICS_PARAMETER_REGEX = re.compile(r"^(?P[a-z_]+)_(?P\d+)m:") + @exponential_backoff(max_attempts=3, base_delay=1, max_delay=60) def fetch_open_elevation(lat, lon): @@ -444,3 +453,316 @@ def fetch_cmc_ensemble(): time.sleep(min(2**attempt_count, MAX_RETRY_DELAY_SECONDS)) if not success: raise RuntimeError("Unable to load latest weather data for CMC through " + file) + + +@exponential_backoff(max_attempts=3, base_delay=1, max_delay=60) +def _meteomatics_get(url, headers=None, params=None): + """Performs a single Meteomatics GET request, retrying transient failures. + + Connection-level errors (and server-side 5xx responses) raise and are + retried by the decorator. Client-side 4xx responses are returned as-is so + the caller can turn them into an actionable, non-retried error, since + retrying a deterministic 4xx only wastes time and API quota. + """ + response = requests.get( + url, headers=headers, params=params, timeout=METEOMATICS_TIMEOUT_SECONDS + ) + if response.status_code >= 500: + # Server-side error: raise so the backoff decorator retries it. + response.raise_for_status() + return response + + +def fetch_meteomatics_token(username, password): + """Requests a short-lived access token from the Meteomatics login service. + + The Meteomatics API authenticates with a personal ``username`` and + ``password``. Instead of sending the credentials on every request, a token + is generated once and reused. Each token is valid for a couple of hours, + which is more than enough to build a single ``Environment``. + + Parameters + ---------- + username : str + The Meteomatics account username. + password : str + The Meteomatics account password. + + Returns + ------- + str + The access token to be used as the ``access_token`` query parameter in + subsequent data requests. + + Raises + ------ + RuntimeError + If the login service cannot be reached, rejects the credentials, or + does not return a token. + """ + credentials = f"{username}:{password}" + encoded_credentials = base64.b64encode(credentials.encode()).decode() + headers = {"Authorization": f"Basic {encoded_credentials}"} + try: + response = _meteomatics_get(METEOMATICS_LOGIN_URL, headers=headers) + except requests.exceptions.RequestException as e: + raise RuntimeError( + "Unable to reach the Meteomatics login service. Please try again later." + ) from e + + if response.status_code in (401, 403): + # Definitive authentication failure: do not retry. + raise RuntimeError( + f"Meteomatics rejected the credentials (HTTP {response.status_code}). " + "Check your username and password." + ) + if not response.ok: + raise RuntimeError( + f"Meteomatics login request failed (HTTP {response.status_code})." + ) + try: + token = response.json().get("access_token") + except requests.exceptions.JSONDecodeError as e: + raise RuntimeError( + "Meteomatics login service returned a malformed (non-JSON) response." + ) from e + if not token: + raise RuntimeError( + "Meteomatics login service did not return an access token. " + "Check your username and password." + ) + logger.info("Meteomatics access token generated successfully.") + return token + + +def _build_meteomatics_parameters( + min_altitude, max_altitude, wind_resolution, temperature_pressure_resolution +): + """Builds the list of Meteomatics height-level parameters to query. + + Wind components are sampled on a finer altitude grid than temperature and + pressure, since the wind profile is usually the most variable one. + + Parameters + ---------- + min_altitude : float + Lowest altitude above ground level (in meters) to query. + max_altitude : float + Highest altitude above ground level (in meters) to query. + wind_resolution : int + Number of altitude levels used for the wind components. + temperature_pressure_resolution : int + Number of altitude levels used for temperature and pressure. + + Returns + ------- + list of str + Parameter strings in the ``"_m:"`` format. + """ + # Round to integer meters and drop duplicates that rounding may introduce + # for narrow bands, so we never request (and pay for) the same height twice. + fine_levels = np.unique( + np.linspace(min_altitude, max_altitude, wind_resolution).round().astype(int) + ) + coarse_levels = np.unique( + np.linspace(min_altitude, max_altitude, temperature_pressure_resolution) + .round() + .astype(int) + ) + + wind_parameters = [ + f"{var}_{height}m:{unit}" + for height in fine_levels + for var, unit in [("wind_speed_u", "ms"), ("wind_speed_v", "ms")] + ] + temperature_pressure_parameters = [ + f"{var}_{height}m:{unit}" + for height in coarse_levels + for var, unit in [("t", "K"), ("pressure", "Pa")] + ] + return wind_parameters + temperature_pressure_parameters + + +def _extract_meteomatics_json(data): + """Extracts (parameter, value) pairs from a Meteomatics JSON response. + + Only the first coordinate and first date of each parameter are used, since + the query is always issued for a single location and a single instant. + + Parameters + ---------- + data : dict + The JSON payload returned by the Meteomatics data endpoint. + + Returns + ------- + list of tuple + A list of ``(parameter, value)`` tuples. + + Raises + ------ + RuntimeError + If the payload does not have the expected Meteomatics structure. + """ + try: + return [ + (entry["parameter"], entry["coordinates"][0]["dates"][0]["value"]) + for entry in data["data"] + ] + except (KeyError, IndexError, TypeError) as e: + raise RuntimeError( + "Unexpected Meteomatics response structure; could not extract the " + "requested data." + ) from e + + +def _fetch_meteomatics_group(base_url, query_params): + """Performs a single Meteomatics data request and returns the JSON body. + + Raises + ------ + RuntimeError + If the API cannot be reached, returns an error status, or returns a + malformed (non-JSON) body. Client-side (4xx) errors are not retried. + """ + try: + response = _meteomatics_get(base_url, params=query_params) + except requests.exceptions.RequestException as e: + raise RuntimeError( + "Unable to reach the Meteomatics data API. Please try again later." + ) from e + if not response.ok: + raise RuntimeError( + f"Meteomatics data request failed (HTTP {response.status_code}). " + f"{response.text[:300]}".strip() + ) + try: + return response.json() + except requests.exceptions.JSONDecodeError as e: + raise RuntimeError( + "Meteomatics data API returned a malformed (non-JSON) response." + ) from e + + +def fetch_atmospheric_data_from_meteomatics( # pylint: disable=too-many-arguments,too-many-locals + username, + password, + latitude, + longitude, + date, + model="mix", + min_altitude=10, + max_altitude=12000, + wind_resolution=20, + temperature_pressure_resolution=10, + query_limit=10, +): + """Fetches a vertical atmospheric profile from the Meteomatics API. + + The data is retrieved for a single location and instant, sampling + temperature, pressure and both wind components at several altitudes above + ground level. To respect the account's per-request parameter limit, the + parameters are split into groups that are queried separately. + + Parameters + ---------- + username : str + The Meteomatics account username. + password : str + The Meteomatics account password. + latitude : float + Latitude of the launch site, in degrees. + longitude : float + Longitude of the launch site, in degrees. + date : datetime.datetime + The instant to query. It is formatted according to the Meteomatics + date-time specification (``%Y-%m-%dT%H:%M:%SZ``). + model : str, optional + The Meteomatics weather model to use. Default is ``"mix"``. Your + account may not have access to every model. See + https://www.meteomatics.com/en/api/request/optional-parameters/data-source/ + min_altitude : float, optional + Lowest altitude above ground level (in meters) to query. Default is 10. + max_altitude : float, optional + Highest altitude above ground level (in meters) to query. Default is + 12000. The API returns an error if the requested altitude is outside + the range supported by the chosen model. + wind_resolution : int, optional + Number of altitude levels used for the wind components. Default is 20. + temperature_pressure_resolution : int, optional + Number of altitude levels used for temperature and pressure. Default is + 10. + query_limit : int, optional + Maximum number of parameters requested at once. Parameters are grouped + accordingly to work around the account's per-request limit. Default is + 10. See https://api.meteomatics.com/user_stats for your own limits. + + Returns + ------- + dict + A dictionary with the keys ``"temperature"``, ``"pressure"``, + ``"wind_u"`` and ``"wind_v"``. Each value is a dictionary mapping the + altitude above ground level (in meters) to the corresponding value, in + SI units (K, Pa, m/s and m/s respectively). + + Raises + ------ + RuntimeError + If authentication fails, the API cannot be reached, returns an error + status, or returns a malformed response. + ValueError + If the altitude range is invalid or the response contains an + unrecognized parameter. + """ + if min_altitude < 0: + raise ValueError( + "min_altitude must be non-negative (heights are above ground level)." + ) + if max_altitude <= min_altitude: + raise ValueError("max_altitude must be greater than min_altitude.") + + token = fetch_meteomatics_token(username, password) + + date_string = date.strftime("%Y-%m-%dT%H:%M:%SZ") + parameters = _build_meteomatics_parameters( + min_altitude, max_altitude, wind_resolution, temperature_pressure_resolution + ) + parameter_groups = [ + parameters[i : i + query_limit] for i in range(0, len(parameters), query_limit) + ] + + profiles = { + "temperature": {}, + "pressure": {}, + "wind_u": {}, + "wind_v": {}, + } + variable_to_profile = { + "t": "temperature", + "pressure": "pressure", + "wind_speed_u": "wind_u", + "wind_speed_v": "wind_v", + } + + for index, parameter_group in enumerate(parameter_groups): + logger.info( + "Fetching Meteomatics data for group %d/%d.", + index + 1, + len(parameter_groups), + ) + parameters_str = ",".join(parameter_group) + base_url = ( + f"{METEOMATICS_BASE_URL}/{date_string}/{parameters_str}/" + f"{latitude},{longitude}/json" + ) + query_params = {"model": model, "access_token": token} + data = _fetch_meteomatics_group(base_url, query_params) + + for parameter, value in _extract_meteomatics_json(data): + match = _METEOMATICS_PARAMETER_REGEX.match(parameter) + if match is None or match.group("var") not in variable_to_profile: + raise ValueError(f"Unrecognized Meteomatics parameter '{parameter}'.") + height = int(match.group("height")) + profiles[variable_to_profile[match.group("var")]][height] = value + + return profiles diff --git a/tests/unit/environment/test_environment.py b/tests/unit/environment/test_environment.py index bbb72573c..9a2728a0c 100644 --- a/tests/unit/environment/test_environment.py +++ b/tests/unit/environment/test_environment.py @@ -334,7 +334,8 @@ def test_environment_export_environment_exports_valid_environment_json( @pytest.mark.parametrize( - "atmospheric_model_type", ["windy", "forecast", "reanalysis", "ensemble"] + "atmospheric_model_type", + ["windy", "meteomatics", "forecast", "reanalysis", "ensemble"], ) def test_environment_to_dict_from_dict_round_trip_preserves_weather_metadata( example_plain_env, atmospheric_model_type @@ -428,6 +429,138 @@ def test_environment_to_dict_from_dict_round_trip_preserves_weather_metadata( assert restored_env.ensemble_member == env.ensemble_member == 1 +_METEOMATICS_FAKE_PROFILES = { + "temperature": {0: 288.15, 1000: 281.65, 5000: 255.65}, + "pressure": {0: 101325.0, 1000: 89876.0, 5000: 54048.0}, + "wind_u": {0: 1.0, 1000: 3.0, 5000: 8.0}, + "wind_v": {0: -1.0, 1000: -2.0, 5000: -4.0}, +} + + +def _patch_meteomatics_fetcher(monkeypatch, profiles=None, recorder=None): + """Replace the Meteomatics fetcher with an offline fake (no API calls).""" + profiles = _METEOMATICS_FAKE_PROFILES if profiles is None else profiles + + def fake_fetch(**kwargs): + if recorder is not None: + recorder.update(kwargs) + return profiles + + monkeypatch.setattr( + "rocketpy.environment.environment.fetch_atmospheric_data_from_meteomatics", + fake_fetch, + ) + + +def test_meteomatics_atmosphere_sets_profiles(example_euroc_env, monkeypatch): + """Build pressure, temperature and wind profiles from Meteomatics data. + + The fake profiles are indexed by height above ground level, so the + Environment elevation (100 m for the EuRoC fixture) must be added to obtain + heights above sea level. + """ + recorder = {} + _patch_meteomatics_fetcher(monkeypatch, recorder=recorder) + + example_euroc_env.set_atmospheric_model( + type="Meteomatics", file="mix", username="user", password="pass" + ) + + assert example_euroc_env.atmospheric_model_type == "Meteomatics" + # AGL 0 m -> ASL 100 m (the fixture elevation) + assert pytest.approx(101325.0, rel=1e-6) == example_euroc_env.pressure(100) + assert pytest.approx(288.15, rel=1e-6) == example_euroc_env.temperature(100) + assert pytest.approx(1.0) == example_euroc_env.wind_velocity_x(100) + assert pytest.approx(-1.0) == example_euroc_env.wind_velocity_y(100) + assert pytest.approx(np.sqrt(2.0)) == example_euroc_env.wind_speed(100) + assert example_euroc_env.max_expected_height == pytest.approx(5100.0) + # Credentials and model are forwarded to the fetcher. + assert recorder["username"] == "user" + assert recorder["password"] == "pass" + assert recorder["model"] == "mix" + + +def test_meteomatics_reads_credentials_from_environment(example_euroc_env, monkeypatch): + """Fall back to the METEOMATICS_* environment variables for credentials.""" + recorder = {} + _patch_meteomatics_fetcher(monkeypatch, recorder=recorder) + monkeypatch.setenv("METEOMATICS_USERNAME", "env-user") + monkeypatch.setenv("METEOMATICS_PASSWORD", "env-pass") + + example_euroc_env.set_atmospheric_model(type="Meteomatics") + + assert recorder["username"] == "env-user" + assert recorder["password"] == "env-pass" + assert recorder["model"] == "mix" # default model when file is omitted + assert pytest.approx(288.15, rel=1e-6) == example_euroc_env.temperature(100) + + +def test_meteomatics_missing_credentials_raises(example_euroc_env, monkeypatch): + """Raise a clear error when no credentials are available.""" + _patch_meteomatics_fetcher(monkeypatch) + monkeypatch.delenv("METEOMATICS_USERNAME", raising=False) + monkeypatch.delenv("METEOMATICS_PASSWORD", raising=False) + + with pytest.raises(ValueError, match="username and password"): + example_euroc_env.set_atmospheric_model(type="Meteomatics") + + +def test_meteomatics_missing_date_raises(example_plain_env, monkeypatch): + """Raise when the Environment has no launch date set.""" + _patch_meteomatics_fetcher(monkeypatch) + + with pytest.raises(ValueError, match="launch date"): + example_plain_env.set_atmospheric_model( + type="Meteomatics", username="user", password="pass" + ) + + +def test_meteomatics_drops_missing_values_and_intersects_wind_grid( + example_euroc_env, monkeypatch +): + """Drop ``None`` values and keep only wind levels present in both u and v. + + Temperature at 1000 m is ``None`` (dropped), and the wind grids disagree at + 5000 m (only ``wind_u`` has it), so the wind profile must keep only the + common, non-null levels {0, 1000} m AGL. + """ + profiles = { + "temperature": {0: 288.15, 1000: None, 5000: 255.65}, + "pressure": {0: 101325.0, 5000: 54048.0}, + "wind_u": {0: 1.0, 1000: 3.0, 5000: 8.0}, + "wind_v": {0: -1.0, 1000: -2.0}, # missing 5000 -> intersection drops it + } + _patch_meteomatics_fetcher(monkeypatch, profiles=profiles) + + example_euroc_env.set_atmospheric_model( + type="Meteomatics", username="user", password="pass" + ) + + # Wind kept only the two common non-null AGL levels {0, 1000} -> ASL {100, 1100}. + npt.assert_array_equal(example_euroc_env.height, [100.0, 1100.0]) + assert len(example_euroc_env.wind_us) == 2 + # Temperature dropped the None level: {0, 5000} AGL -> ASL {100, 5100}. + assert len(example_euroc_env.temperatures) == 2 + assert pytest.approx(255.65, rel=1e-6) == example_euroc_env.temperature(5100) + assert example_euroc_env.max_expected_height == pytest.approx(5100.0) + + +def test_meteomatics_no_usable_data_raises(example_euroc_env, monkeypatch): + """Raise a clear error when the API returns no usable wind data.""" + profiles = { + "temperature": {0: 288.15}, + "pressure": {0: 101325.0}, + "wind_u": {}, + "wind_v": {}, + } + _patch_meteomatics_fetcher(monkeypatch, profiles=profiles) + + with pytest.raises(ValueError, match="no usable atmospheric data"): + example_euroc_env.set_atmospheric_model( + type="Meteomatics", username="user", password="pass" + ) + + class _DummyDataset: """Small test double that mimics a netCDF dataset variables mapping.""" diff --git a/tests/unit/environment/test_fetchers.py b/tests/unit/environment/test_fetchers.py index eea06f977..97a463ef3 100644 --- a/tests/unit/environment/test_fetchers.py +++ b/tests/unit/environment/test_fetchers.py @@ -1,3 +1,5 @@ +from datetime import datetime, timezone + import pytest from rocketpy.environment import fetchers @@ -81,3 +83,221 @@ def always_fails(_): fetchers.fetch_rap_file_return_dataset(max_attempts=2, base_delay=2) assert sleep_calls == [2, 4] + + +class _FakeResponse: + """Minimal stand-in for a ``requests.Response`` used in Meteomatics tests.""" + + def __init__(self, payload, status_code=200, text=""): + self._payload = payload + self.status_code = status_code + self.text = text + + @property + def ok(self): + return self.status_code < 400 + + def raise_for_status(self): + if self.status_code >= 400: + raise fetchers.requests.exceptions.HTTPError(f"status {self.status_code}") + + def json(self): + return self._payload + + +def _meteomatics_value_for(parameter): + """Return a deterministic fake value for a Meteomatics parameter string.""" + if parameter.startswith("t_"): + return 288.0 + if parameter.startswith("pressure_"): + return 90000.0 + if parameter.startswith("wind_speed_u_"): + return 4.0 + if parameter.startswith("wind_speed_v_"): + return -2.0 + raise AssertionError(f"unexpected parameter requested: {parameter}") + + +def _make_fake_meteomatics_get(calls, extra_bad_parameter=False, data_status=200): + """Build a fake ``requests.get`` that mimics the Meteomatics endpoints.""" + + def fake_get(url, headers=None, params=None, timeout=None): + calls.append((url, params)) + if url == fetchers.METEOMATICS_LOGIN_URL: + assert headers is not None and "Authorization" in headers + return _FakeResponse({"access_token": "fake-token"}) + if data_status >= 400: + return _FakeResponse( + {}, status_code=data_status, text="validation error: altitude" + ) + # Data request: parameters are the 5th path segment. + parameters = url.split("/")[4].split(",") + data = [ + { + "parameter": parameter, + "coordinates": [ + {"dates": [{"value": _meteomatics_value_for(parameter)}]} + ], + } + for parameter in parameters + ] + if extra_bad_parameter: + data.append( + { + "parameter": "not_a_known_parameter:xx", + "coordinates": [{"dates": [{"value": 1.0}]}], + } + ) + return _FakeResponse({"data": data}) + + return fake_get + + +def test_fetch_meteomatics_token_success(monkeypatch): + """Return the access token when the login service responds with one.""" + monkeypatch.setattr( + fetchers.requests, "get", lambda *a, **k: _FakeResponse({"access_token": "tok"}) + ) + assert fetchers.fetch_meteomatics_token("user", "pass") == "tok" + + +def test_fetch_meteomatics_token_missing_token_raises(monkeypatch): + """Raise when the login service returns 200 but without a token.""" + monkeypatch.setattr(fetchers.requests, "get", lambda *a, **k: _FakeResponse({})) + with pytest.raises(RuntimeError, match="did not return an access token"): + fetchers.fetch_meteomatics_token("user", "pass") + + +def test_fetch_meteomatics_token_auth_failure_not_retried(monkeypatch): + """A 401/403 is a definitive auth failure: report clearly and do not retry.""" + calls = [] + + def fake_get(*args, **kwargs): + calls.append(args) + return _FakeResponse({}, status_code=401, text="unauthorized") + + # If a retry happened it would sleep; make that observable instead of slow. + monkeypatch.setattr( + fetchers.time, "sleep", lambda *_: (_ for _ in ()).throw(AssertionError()) + ) + monkeypatch.setattr(fetchers.requests, "get", fake_get) + + with pytest.raises(RuntimeError, match="rejected the credentials"): + fetchers.fetch_meteomatics_token("user", "pass") + assert len(calls) == 1 # no retries + + +def test_fetch_meteomatics_data_groups_and_parses(monkeypatch): + """Group parameters within the query limit and parse the profiles.""" + # Arrange + calls = [] + monkeypatch.setattr(fetchers.requests, "get", _make_fake_meteomatics_get(calls)) + + # Act: distinct wind (fine) and temperature/pressure (coarse) resolutions so + # a fine-vs-coarse grid swap would be detectable. + profiles = fetchers.fetch_atmospheric_data_from_meteomatics( + username="user", + password="pass", + latitude=39.0, + longitude=-8.0, + date=datetime(2024, 1, 1, 12, tzinfo=timezone.utc), + model="mix", + min_altitude=10, + max_altitude=1000, + wind_resolution=3, + temperature_pressure_resolution=2, + query_limit=3, + ) + + # Assert + # 6 wind params (u,v at 3 levels) + 4 temp/pressure params (t,p at 2 levels) + # = 10 params, grouped by 3 -> ceil(10/3) = 4 groups. + data_calls = [c for c in calls if c[0] != fetchers.METEOMATICS_LOGIN_URL] + assert len(calls) == 5 # 1 token + 4 data groups + assert len(data_calls) == 4 + assert all(call[1]["access_token"] == "fake-token" for call in data_calls) + assert all(call[1]["model"] == "mix" for call in data_calls) + + # Wind uses the fine grid (3 levels); temperature/pressure the coarse (2). + assert profiles["temperature"] == {10: 288.0, 1000: 288.0} + assert profiles["pressure"] == {10: 90000.0, 1000: 90000.0} + assert profiles["wind_u"] == {10: 4.0, 505: 4.0, 1000: 4.0} + assert profiles["wind_v"] == {10: -2.0, 505: -2.0, 1000: -2.0} + + +def test_fetch_meteomatics_data_unrecognized_parameter_raises(monkeypatch): + """Raise a ValueError when the response contains an unknown parameter.""" + calls = [] + monkeypatch.setattr( + fetchers.requests, + "get", + _make_fake_meteomatics_get(calls, extra_bad_parameter=True), + ) + with pytest.raises(ValueError, match="Unrecognized Meteomatics parameter"): + fetchers.fetch_atmospheric_data_from_meteomatics( + username="user", + password="pass", + latitude=39.0, + longitude=-8.0, + date=datetime(2024, 1, 1, 12, tzinfo=timezone.utc), + wind_resolution=2, + temperature_pressure_resolution=2, + ) + + +def test_fetch_meteomatics_data_client_error_not_retried(monkeypatch): + """A 4xx data response yields an actionable RuntimeError and is not retried.""" + calls = [] + monkeypatch.setattr( + fetchers.time, "sleep", lambda *_: (_ for _ in ()).throw(AssertionError()) + ) + monkeypatch.setattr( + fetchers.requests, "get", _make_fake_meteomatics_get(calls, data_status=400) + ) + + with pytest.raises(RuntimeError, match="data request failed"): + fetchers.fetch_atmospheric_data_from_meteomatics( + username="user", + password="pass", + latitude=39.0, + longitude=-8.0, + date=datetime(2024, 1, 1, 12, tzinfo=timezone.utc), + wind_resolution=2, + temperature_pressure_resolution=2, + ) + # 1 token call + exactly 1 data call (the 400 was not retried). + data_calls = [c for c in calls if c[0] != fetchers.METEOMATICS_LOGIN_URL] + assert len(data_calls) == 1 + + +@pytest.mark.parametrize( + "payload", + [ + {}, # missing "data" + {"data": [{"parameter": "t_10m:K", "coordinates": []}]}, # empty coordinates + ], +) +def test_extract_meteomatics_json_bad_structure_raises(payload): + """Turn an unexpected 200 payload into a clear RuntimeError, not KeyError.""" + with pytest.raises(RuntimeError, match="Unexpected Meteomatics response"): + fetchers._extract_meteomatics_json(payload) + + +@pytest.mark.parametrize( + "altitudes", + [ + {"min_altitude": -1, "max_altitude": 1000}, # negative floor + {"min_altitude": 10, "max_altitude": 5}, # max below min + ], +) +def test_fetch_meteomatics_data_invalid_altitude_range_raises(altitudes): + """Reject invalid altitude ranges before making any request.""" + with pytest.raises(ValueError, match="altitude"): + fetchers.fetch_atmospheric_data_from_meteomatics( + username="user", + password="pass", + latitude=39.0, + longitude=-8.0, + date=datetime(2024, 1, 1, 12, tzinfo=timezone.utc), + **altitudes, + )