Skip to content

Commit 1691119

Browse files
ENH: Add native Meteomatics API support to the Environment class (#1079)
* ENH: Add native Meteomatics API support to the Environment class Adds a new "meteomatics" atmospheric model to Environment.set_atmospheric_model, porting and generalizing the implementation from the EuRoC-Dev repository. - fetchers.py: fetch_meteomatics_token + fetch_atmospheric_data_from_meteomatics authenticate with username/password (short-lived token), query temperature, pressure and wind components by height above ground level, grouping the parameters to respect the account's per-request limit. - environment.py: process_meteomatics_atmosphere converts the height-AGL data to above-sea-level profiles using the Environment elevation; set_atmospheric_model gains username/password kwargs (falling back to METEOMATICS_USERNAME / METEOMATICS_PASSWORD env vars); save/load handles the new model type. - Network requests use timeouts, do not retry deterministic 4xx failures, and surface actionable RuntimeError messages. - Tests fully mock the API (no real requests, no charges); docs and changelog updated. Closes #545 * MNT: simplify Meteomatics fetcher and profile assembly Follow-up cleanups from the code review of the Meteomatics support: - Collapse the duplicated request/error ladder of the login and data endpoints into a single `_meteomatics_request_json` helper. - Have `_build_meteomatics_parameters` return a {parameter: (profile, height)} mapping so the response parser no longer regex-parses back the strings this module just built. Drops the regex constant and the variable-to-profile table. - Generalize `to_profile_array` to several profiles at once, so the wind u/v grid intersection reuses it instead of repeating it inline. - Reuse the existing `__validate_datetime` helper for the launch-date check, and simplify the model default to `model or "mix"`. - Normalize `atmospheric_model_type` once in `from_dict`. The type is stored as the user spelled it, so the previously case-sensitive `match` and `== "ensemble"` branches silently dropped the ensemble arrays for an Environment built with `type="Ensemble"`. - Trim the elevation warning and raise it before the API call, so it is shown even when the request later fails. * MNT: address Meteomatics review comments Two points raised in the PR review: - `fetch_atmospheric_data_from_meteomatics` stamped the instant with a trailing "Z" without normalizing the timezone, so an aware datetime in a non-UTC zone was sent as the wrong instant. Aware datetimes are now converted to UTC; naive ones are documented as assumed UTC. - Degenerate sampling arguments (`query_limit=0`, resolutions below 2) reached `range()`/`linspace()` and failed with an opaque low-level error after the login had already been paid for. They are now validated up front by `_validate_meteomatics_sampling`. - `process_meteomatics_atmosphere` silently coerced any non-string model to "mix", hiding a mistake such as passing a Dataset or a path as `file` and querying the wrong model. It now accepts None (default) or a string, and raises otherwise. * ENH: Refactor Meteomatics integration into MeteomaticsFetcher class and clean up environment method * ENH: Decompose fetchers.py into rocketpy/environment/fetchers package with dedicated submodules
1 parent 8b01b34 commit 1691119

12 files changed

Lines changed: 1350 additions & 151 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ Attention: The newest changes should be on top -->
3232

3333
### Added
3434

35+
- ENH: Support for Meteomatics API in the `Environment` class [#1079](https://github.com/RocketPy-Team/RocketPy/pull/1079)
3536
- ENH: update master with develop [#1081](https://github.com/RocketPy-Team/RocketPy/pull/1081)
3637

3738
### Changed

docs/user/environment/3-further/other_apis.rst

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,14 +159,63 @@ For custom dictionaries, the canonical structure is:
159159
simulation workflow.
160160

161161

162+
Meteomatics API
163+
---------------
164+
165+
RocketPy can build an ``Environment`` directly from the
166+
`Meteomatics <https://www.meteomatics.com/en/weather-api/>`_ weather API.
167+
Meteomatics authenticates with a personal **username** and **password** (a
168+
short-lived access token is generated automatically under the hood), so you
169+
need a Meteomatics account to use this feature.
170+
171+
The API is queried for temperature, pressure and both wind components at
172+
several altitudes above ground level around the launch site, which are then
173+
converted to profiles above sea level using the ``Environment`` elevation.
174+
Because of that, make sure a launch ``date`` and a reasonable ``elevation`` are
175+
set before calling the method.
176+
177+
.. code-block:: python
178+
179+
from datetime import datetime, timedelta
180+
from rocketpy import Environment
181+
182+
env = Environment(
183+
latitude=39.3897,
184+
longitude=-8.28896,
185+
elevation=113,
186+
date=datetime.now() + timedelta(days=1), # forecast instant
187+
)
188+
189+
env.set_atmospheric_model(
190+
type="Meteomatics",
191+
file="mix", # Meteomatics weather model
192+
username="your_username",
193+
password="your_password",
194+
)
195+
196+
env.info()
197+
198+
If you prefer not to hardcode the credentials, omit the ``username`` and
199+
``password`` arguments and RocketPy will read them from the
200+
``METEOMATICS_USERNAME`` and ``METEOMATICS_PASSWORD`` environment variables.
201+
202+
.. note::
203+
204+
The altitude range and sampling resolution can be tuned by calling
205+
:meth:`rocketpy.Environment.process_meteomatics_atmosphere` directly (for
206+
example, to change ``min_altitude``, ``max_altitude`` or the number of
207+
levels). The API returns an error if the requested altitude is outside the
208+
range supported by the chosen model, and your account may not have access
209+
to every model.
210+
211+
162212
Without OPeNDAP protocol
163213
-------------------------
164214

165215
On the other hand, one can also load data from APIs that do not support the OPeNDAP protocol.
166216
In these cases, what we recommend is to download the data and then load it as a custom atmosphere.
167217

168218
There are some efforts to natively support other APIs in RocketPy's
169-
Environment class, for example:
219+
Environment class, for example:
170220

171-
- `Meteomatics <https://www.meteomatics.com/en/weather-api/>`_: `#545 <https://github.com/RocketPy-Team/RocketPy/issues/545>`_
172221
- `Open-Meteo <https://open-meteo.com/>`_: `#520 <https://github.com/RocketPy-Team/RocketPy/issues/520>`_

rocketpy/environment/environment.py

Lines changed: 233 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
# pylint: disable=too-many-public-methods, too-many-instance-attributes
1+
# pylint: disable=too-many-public-methods, too-many-instance-attributes, too-many-lines
22
import bisect
33
import json
44
import logging
5+
import os
56
import re
67
import warnings
78
from collections import namedtuple
@@ -13,6 +14,7 @@
1314

1415
from rocketpy.environment.fetchers import (
1516
fetch_aigfs_file_return_dataset,
17+
fetch_atmospheric_data_from_meteomatics,
1618
fetch_atmospheric_data_from_windy,
1719
fetch_gefs_ensemble,
1820
fetch_gfs_file_return_dataset,
@@ -145,8 +147,8 @@ class Environment:
145147
Environment.atmospheric_model_type : string
146148
Describes the atmospheric model which is being used. Can only assume the
147149
following values: ``standard_atmosphere``, ``custom_atmosphere``,
148-
``wyoming_sounding``, ``windy``, ``forecast``, ``reanalysis``,
149-
``ensemble``.
150+
``wyoming_sounding``, ``windy``, ``meteomatics``, ``forecast``,
151+
``reanalysis``, ``ensemble``.
150152
Environment.atmospheric_model_file : string
151153
Address of the file used for the atmospheric model being used. Only
152154
defined for ``wyoming_sounding``, ``windy``, ``forecast``,
@@ -1188,6 +1190,8 @@ def set_atmospheric_model( # pylint: disable=too-many-statements
11881190
wind_u=0,
11891191
wind_v=0,
11901192
pressure_conversion_factor=None,
1193+
username=None,
1194+
password=None,
11911195
):
11921196
"""Define the atmospheric model for this Environment.
11931197
@@ -1196,15 +1200,18 @@ def set_atmospheric_model( # pylint: disable=too-many-statements
11961200
type : string
11971201
Atmospheric model selector (case-insensitive). Accepted values are
11981202
``"standard_atmosphere"``, ``"wyoming_sounding"``, ``"windy"``,
1199-
``"forecast"``, ``"reanalysis"``, ``"ensemble"`` and
1200-
``"custom_atmosphere"``.
1203+
``"forecast"``, ``"reanalysis"``, ``"ensemble"``,
1204+
``"custom_atmosphere"`` and ``"meteomatics"``.
12011205
file : string | netCDF4.Dataset, optional
12021206
Data source or model shortcut. Meaning depends on ``type``:
12031207
12041208
- ``"standard_atmosphere"`` and ``"custom_atmosphere"``: ignored.
12051209
- ``"wyoming_sounding"``: URL of the sounding text page.
12061210
- ``"windy"``: one of ``"ECMWF"``, ``"GFS"``, ``"ICON"`` or
12071211
``"ICONEU"``.
1212+
- ``"meteomatics"``: the Meteomatics weather model to query, such
1213+
as ``"mix"`` (the default when omitted). See the Meteomatics
1214+
documentation for the models available to your account.
12081215
- ``"forecast"``: local path, OPeNDAP URL, open
12091216
``netCDF4.Dataset``, or one of ``"AIGFS"``, ``"GFS"``,
12101217
``"NAM"``, ``"RAP"``, ``"HRRR"`` or ``"HIRESW"`` for the
@@ -1290,6 +1297,14 @@ def set_atmospheric_model( # pylint: disable=too-many-statements
12901297
model name (e.g. ERA5/ECMWF/MERRA2 reanalysis files commonly use hPa,
12911298
while online GFS/NAM/RAP/HRRR forecast models use Pa) or, if
12921299
unavailable, by reading the pressure unit attribute from the file.
1300+
username : string, optional
1301+
Meteomatics account username. Only used when ``type`` is
1302+
``"meteomatics"``. If None (the default), the value is read from the
1303+
``METEOMATICS_USERNAME`` environment variable.
1304+
password : string, optional
1305+
Meteomatics account password. Only used when ``type`` is
1306+
``"meteomatics"``. If None (the default), the value is read from the
1307+
``METEOMATICS_PASSWORD`` environment variable.
12931308
12941309
Returns
12951310
-------
@@ -1338,6 +1353,10 @@ def set_atmospheric_model( # pylint: disable=too-many-statements
13381353
self.process_custom_atmosphere(pressure, temperature, wind_u, wind_v)
13391354
case "windy":
13401355
self.process_windy_atmosphere(file)
1356+
case "meteomatics":
1357+
self.process_meteomatics_atmosphere(
1358+
model=file, username=username, password=password
1359+
)
13411360
case "forecast" | "reanalysis" | "ensemble":
13421361
# Capture the user-supplied names before __validate_dictionary
13431362
# converts them to dicts, so they can drive auto-detection.
@@ -1736,6 +1755,209 @@ def __parse_windy_file(self, response, time_index, pressure_levels):
17361755
wind_v_array,
17371756
)
17381757

1758+
@staticmethod
1759+
def _validate_meteomatics_credentials_and_model(model, username, password):
1760+
"""Validates model and credentials for Meteomatics requests."""
1761+
if model is None:
1762+
model = "mix"
1763+
elif not isinstance(model, str):
1764+
# Coercing silently would hide a mistake such as passing a Dataset
1765+
# or a file path as 'file', and would query the wrong model.
1766+
raise ValueError(
1767+
f"Invalid Meteomatics model {model!r}: expected the model name as "
1768+
"a string (e.g. 'mix'), or None to use the default."
1769+
)
1770+
username = username or os.environ.get("METEOMATICS_USERNAME")
1771+
password = password or os.environ.get("METEOMATICS_PASSWORD")
1772+
if not username or not password:
1773+
raise ValueError(
1774+
"Meteomatics requires a username and password. Provide them via "
1775+
"the 'username' and 'password' arguments of set_atmospheric_model, "
1776+
"or set the METEOMATICS_USERNAME and METEOMATICS_PASSWORD "
1777+
"environment variables."
1778+
)
1779+
return model, username, password
1780+
1781+
def _store_meteomatics_functions(
1782+
self, pressure_array, temperature_array, wind_array
1783+
):
1784+
"""Sets internal atmospheric functions for Meteomatics."""
1785+
wind_asl_heights = wind_array[:, 0]
1786+
wind_u_values = wind_array[:, 1]
1787+
wind_v_values = wind_array[:, 2]
1788+
1789+
wind_speed_array = calculate_wind_speed(wind_u_values, wind_v_values)
1790+
wind_heading_array = calculate_wind_heading(wind_u_values, wind_v_values)
1791+
wind_direction_array = convert_wind_heading_to_direction(wind_heading_array)
1792+
1793+
# Save atmospheric data
1794+
self.__set_pressure_function(pressure_array)
1795+
self.__set_barometric_height_function(pressure_array[:, (1, 0)])
1796+
self.__set_temperature_function(temperature_array)
1797+
self.__set_wind_velocity_x_function(wind_array[:, (0, 1)])
1798+
self.__set_wind_velocity_y_function(wind_array[:, (0, 2)])
1799+
self.__set_wind_heading_function(
1800+
np.column_stack((wind_asl_heights, wind_heading_array))
1801+
)
1802+
self.__set_wind_direction_function(
1803+
np.column_stack((wind_asl_heights, wind_direction_array))
1804+
)
1805+
self.__set_wind_speed_function(
1806+
np.column_stack((wind_asl_heights, wind_speed_array))
1807+
)
1808+
1809+
# Save maximum expected height
1810+
self._max_expected_height = float(
1811+
max(pressure_array[-1, 0], temperature_array[-1, 0], wind_asl_heights[-1])
1812+
)
1813+
1814+
def _store_meteomatics_metadata(
1815+
self, pressure_array, temperature_array, wind_array
1816+
):
1817+
"""Sets metadata attributes and debug data for Meteomatics."""
1818+
wind_asl_heights = wind_array[:, 0]
1819+
self.atmospheric_model_init_date = self.datetime_date
1820+
self.atmospheric_model_end_date = self.datetime_date
1821+
self.atmospheric_model_interval = 0
1822+
self.atmospheric_model_init_lat = self.latitude
1823+
self.atmospheric_model_end_lat = self.latitude
1824+
self.atmospheric_model_init_lon = self.longitude
1825+
self.atmospheric_model_end_lon = self.longitude
1826+
1827+
# Save debugging data
1828+
self.wind_us = wind_array[:, 1]
1829+
self.wind_vs = wind_array[:, 2]
1830+
self.temperatures = temperature_array[:, 1]
1831+
self.pressures = pressure_array[:, 1]
1832+
self.height = wind_asl_heights
1833+
1834+
def _process_meteomatics_profiles(self, profiles):
1835+
"""Converts retrieved height-AGL profiles to ASL arrays and configures
1836+
the Environment atmospheric functions."""
1837+
1838+
def to_profile_array(*names):
1839+
common_heights = set.intersection(*(set(profiles[n]) for n in names))
1840+
heights = sorted(
1841+
h
1842+
for h in common_heights
1843+
if all(profiles[n][h] is not None for n in names)
1844+
)
1845+
return np.array(
1846+
[
1847+
(h + self.elevation, *(profiles[n][h] for n in names))
1848+
for h in heights
1849+
],
1850+
dtype=float,
1851+
)
1852+
1853+
pressure_array = to_profile_array("pressure")
1854+
temperature_array = to_profile_array("temperature")
1855+
# Wind u and v share the same altitude grid; keep only common levels.
1856+
wind_array = to_profile_array("wind_u", "wind_v")
1857+
1858+
# Each profile needs at least two levels: a single-point Function cannot
1859+
# be evaluated at its own node (it raises IndexError downstream), so a
1860+
# collapsed grid must fail here with an actionable message instead.
1861+
if min(len(pressure_array), len(temperature_array), len(wind_array)) < 2:
1862+
raise ValueError(
1863+
"Meteomatics did not return enough usable atmospheric data: at "
1864+
"least two valid altitude levels are required for pressure, "
1865+
"temperature and wind. Check the requested model, the altitude "
1866+
"range (min_altitude and max_altitude must be far enough apart "
1867+
"that the sampled levels do not collapse to a single height), "
1868+
"and your account permissions."
1869+
)
1870+
1871+
self._store_meteomatics_functions(pressure_array, temperature_array, wind_array)
1872+
self._store_meteomatics_metadata(pressure_array, temperature_array, wind_array)
1873+
1874+
def process_meteomatics_atmosphere(
1875+
self,
1876+
model="mix",
1877+
username=None,
1878+
password=None,
1879+
min_altitude=10,
1880+
max_altitude=12000,
1881+
wind_resolution=20,
1882+
temperature_pressure_resolution=10,
1883+
query_limit=10,
1884+
):
1885+
"""Process data from the Meteomatics API to retrieve a vertical
1886+
atmospheric profile at the launch site.
1887+
1888+
The Meteomatics API is queried for temperature, pressure and both wind
1889+
components at several altitudes above ground level, which are then
1890+
converted to profiles above sea level using the ``Environment``
1891+
elevation. Authentication uses a personal username and password; when
1892+
not provided, they are read from the ``METEOMATICS_USERNAME`` and
1893+
``METEOMATICS_PASSWORD`` environment variables.
1894+
1895+
Parameters
1896+
----------
1897+
model : str, optional
1898+
The Meteomatics weather model to query. Default is ``"mix"``. Your
1899+
account may not have access to every model.
1900+
username : str, optional
1901+
Meteomatics account username. Defaults to the
1902+
``METEOMATICS_USERNAME`` environment variable.
1903+
password : str, optional
1904+
Meteomatics account password. Defaults to the
1905+
``METEOMATICS_PASSWORD`` environment variable.
1906+
min_altitude : float, optional
1907+
Lowest altitude above ground level (in meters) to query. Default is
1908+
10.
1909+
max_altitude : float, optional
1910+
Highest altitude above ground level (in meters) to query. Default
1911+
is 12000. The API errors if it lies outside the model's supported
1912+
range.
1913+
wind_resolution : int, optional
1914+
Number of altitude levels used for the wind components. Default is
1915+
20.
1916+
temperature_pressure_resolution : int, optional
1917+
Number of altitude levels used for temperature and pressure.
1918+
Default is 10.
1919+
query_limit : int, optional
1920+
Maximum number of parameters requested at once. Parameters are
1921+
grouped accordingly to respect the account's per-request limit.
1922+
Default is 10.
1923+
1924+
Raises
1925+
------
1926+
ValueError
1927+
If ``model`` is not a string, if credentials are missing, if no
1928+
launch date is set, or if the API returns no usable data.
1929+
"""
1930+
model, username, password = self._validate_meteomatics_credentials_and_model(
1931+
model, username, password
1932+
)
1933+
self.__validate_datetime()
1934+
1935+
if self.elevation == 0:
1936+
warnings.warn(
1937+
"The Environment elevation is 0 m (possibly unset), so Meteomatics "
1938+
"heights above ground level are being treated as heights above sea "
1939+
"level. Set the elevation before this call if the launch site is "
1940+
"not at sea level.",
1941+
UserWarning,
1942+
stacklevel=2,
1943+
)
1944+
1945+
profiles = fetch_atmospheric_data_from_meteomatics(
1946+
username=username,
1947+
password=password,
1948+
latitude=self.latitude,
1949+
longitude=self.longitude,
1950+
date=self.datetime_date,
1951+
model=model,
1952+
min_altitude=min_altitude,
1953+
max_altitude=max_altitude,
1954+
wind_resolution=wind_resolution,
1955+
temperature_pressure_resolution=temperature_pressure_resolution,
1956+
query_limit=query_limit,
1957+
)
1958+
1959+
self._process_meteomatics_profiles(profiles)
1960+
17391961
def process_wyoming_sounding(self, file): # pylint: disable=too-many-statements
17401962
"""Import and process the upper air sounding data from `Wyoming
17411963
Upper Air Soundings` database given by the url in file. Sets
@@ -2987,8 +3209,11 @@ def from_dict(cls, data): # pylint: disable=too-many-statements
29873209
)
29883210
atmospheric_model = data["atmospheric_model_type"]
29893211
env.atmospheric_model_type = atmospheric_model
3212+
# set_atmospheric_model stores the type as the user spelled it (e.g.
3213+
# "Meteomatics"), so the dispatch below must be case-insensitive.
3214+
model_type = atmospheric_model.lower()
29903215

2991-
match atmospheric_model:
3216+
match model_type:
29923217
case "standard_atmosphere":
29933218
env.set_atmospheric_model("standard_atmosphere")
29943219
case "custom_atmosphere":
@@ -3010,7 +3235,7 @@ def from_dict(cls, data): # pylint: disable=too-many-statements
30103235
env.elevation = data["elevation"]
30113236
env.max_expected_height = data["max_expected_height"]
30123237

3013-
if atmospheric_model in ("windy", "forecast", "reanalysis", "ensemble"):
3238+
if model_type in ("windy", "meteomatics", "forecast", "reanalysis", "ensemble"):
30143239
env.atmospheric_model_init_date = data["atmospheric_model_init_date"]
30153240
env.atmospheric_model_end_date = data["atmospheric_model_end_date"]
30163241
env.atmospheric_model_interval = data["atmospheric_model_interval"]
@@ -3019,7 +3244,7 @@ def from_dict(cls, data): # pylint: disable=too-many-statements
30193244
env.atmospheric_model_init_lon = data["atmospheric_model_init_lon"]
30203245
env.atmospheric_model_end_lon = data["atmospheric_model_end_lon"]
30213246

3022-
if atmospheric_model == "ensemble":
3247+
if model_type == "ensemble":
30233248
env.level_ensemble = data["level_ensemble"]
30243249
env.height_ensemble = data["height_ensemble"]
30253250
env.temperature_ensemble = data["temperature_ensemble"]

0 commit comments

Comments
 (0)