Skip to content

Commit 77a28e6

Browse files
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 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 725041c commit 77a28e6

6 files changed

Lines changed: 955 additions & 9 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: 206 additions & 6 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,181 @@ def __parse_windy_file(self, response, time_index, pressure_levels):
17361755
wind_v_array,
17371756
)
17381757

1758+
def process_meteomatics_atmosphere( # pylint: disable=too-many-statements
1759+
self,
1760+
model="mix",
1761+
username=None,
1762+
password=None,
1763+
min_altitude=10,
1764+
max_altitude=12000,
1765+
wind_resolution=20,
1766+
temperature_pressure_resolution=10,
1767+
query_limit=10,
1768+
):
1769+
"""Process data from the Meteomatics API to retrieve a vertical
1770+
atmospheric profile at the launch site.
1771+
1772+
The Meteomatics API is queried for temperature, pressure and both wind
1773+
components at several altitudes above ground level, which are then
1774+
converted to profiles above sea level using the ``Environment``
1775+
elevation. Authentication uses a personal username and password; when
1776+
not provided, they are read from the ``METEOMATICS_USERNAME`` and
1777+
``METEOMATICS_PASSWORD`` environment variables.
1778+
1779+
Parameters
1780+
----------
1781+
model : str, optional
1782+
The Meteomatics weather model to query. Default is ``"mix"``. Your
1783+
account may not have access to every model.
1784+
username : str, optional
1785+
Meteomatics account username. Defaults to the
1786+
``METEOMATICS_USERNAME`` environment variable.
1787+
password : str, optional
1788+
Meteomatics account password. Defaults to the
1789+
``METEOMATICS_PASSWORD`` environment variable.
1790+
min_altitude : float, optional
1791+
Lowest altitude above ground level (in meters) to query. Default is
1792+
10.
1793+
max_altitude : float, optional
1794+
Highest altitude above ground level (in meters) to query. Default
1795+
is 12000. The API errors if it lies outside the model's supported
1796+
range.
1797+
wind_resolution : int, optional
1798+
Number of altitude levels used for the wind components. Default is
1799+
20.
1800+
temperature_pressure_resolution : int, optional
1801+
Number of altitude levels used for temperature and pressure.
1802+
Default is 10.
1803+
query_limit : int, optional
1804+
Maximum number of parameters requested at once. Parameters are
1805+
grouped accordingly to respect the account's per-request limit.
1806+
Default is 10.
1807+
1808+
Raises
1809+
------
1810+
ValueError
1811+
If credentials are missing, if no launch date is set, or if the API
1812+
returns no usable data.
1813+
"""
1814+
model = model if isinstance(model, str) else "mix"
1815+
username = username or os.environ.get("METEOMATICS_USERNAME")
1816+
password = password or os.environ.get("METEOMATICS_PASSWORD")
1817+
if not username or not password:
1818+
raise ValueError(
1819+
"Meteomatics requires a username and password. Provide them via "
1820+
"the 'username' and 'password' arguments of set_atmospheric_model, "
1821+
"or set the METEOMATICS_USERNAME and METEOMATICS_PASSWORD "
1822+
"environment variables."
1823+
)
1824+
if getattr(self, "datetime_date", None) is None:
1825+
raise ValueError(
1826+
"A launch date is required to use the Meteomatics atmospheric "
1827+
"model. Provide it when creating the Environment or via set_date()."
1828+
)
1829+
1830+
profiles = fetch_atmospheric_data_from_meteomatics(
1831+
username=username,
1832+
password=password,
1833+
latitude=self.latitude,
1834+
longitude=self.longitude,
1835+
date=self.datetime_date,
1836+
model=model,
1837+
min_altitude=min_altitude,
1838+
max_altitude=max_altitude,
1839+
wind_resolution=wind_resolution,
1840+
temperature_pressure_resolution=temperature_pressure_resolution,
1841+
query_limit=query_limit,
1842+
)
1843+
1844+
if self.elevation == 0:
1845+
warnings.warn(
1846+
"The Environment elevation is 0 m (possibly unset), so "
1847+
"Meteomatics heights above ground level are being treated as "
1848+
"heights above sea level. If the launch site is not at sea "
1849+
"level, set the elevation (e.g. Environment(elevation=...) or "
1850+
"set_elevation('Open-Elevation')) before this call for an "
1851+
"accurate profile.",
1852+
UserWarning,
1853+
stacklevel=2,
1854+
)
1855+
1856+
def to_profile_array(profile):
1857+
"""Convert an {AGL height: value} mapping into a sorted
1858+
(ASL height, value) array, dropping any missing value."""
1859+
heights = sorted(h for h, value in profile.items() if value is not None)
1860+
return np.array(
1861+
[(h + self.elevation, profile[h]) for h in heights], dtype=float
1862+
)
1863+
1864+
pressure_array = to_profile_array(profiles["pressure"])
1865+
temperature_array = to_profile_array(profiles["temperature"])
1866+
1867+
# Wind u and v share the same altitude grid; keep only common levels.
1868+
wind_heights = sorted(
1869+
h
1870+
for h in set(profiles["wind_u"]) & set(profiles["wind_v"])
1871+
if profiles["wind_u"][h] is not None and profiles["wind_v"][h] is not None
1872+
)
1873+
# Each profile needs at least two levels: a single-point Function cannot
1874+
# be evaluated at its own node (it raises IndexError downstream), so a
1875+
# collapsed grid must fail here with an actionable message instead.
1876+
if min(len(pressure_array), len(temperature_array), len(wind_heights)) < 2:
1877+
raise ValueError(
1878+
"Meteomatics did not return enough usable atmospheric data: at "
1879+
"least two valid altitude levels are required for pressure, "
1880+
"temperature and wind. Check the requested model, the altitude "
1881+
"range (min_altitude and max_altitude must be far enough apart "
1882+
"that the sampled levels do not collapse to a single height), "
1883+
"and your account permissions."
1884+
)
1885+
1886+
wind_u_values = np.array([profiles["wind_u"][h] for h in wind_heights])
1887+
wind_v_values = np.array([profiles["wind_v"][h] for h in wind_heights])
1888+
wind_asl_heights = np.array(wind_heights, dtype=float) + self.elevation
1889+
wind_u_array = np.column_stack((wind_asl_heights, wind_u_values))
1890+
wind_v_array = np.column_stack((wind_asl_heights, wind_v_values))
1891+
1892+
wind_speed_array = calculate_wind_speed(wind_u_values, wind_v_values)
1893+
wind_heading_array = calculate_wind_heading(wind_u_values, wind_v_values)
1894+
wind_direction_array = convert_wind_heading_to_direction(wind_heading_array)
1895+
1896+
# Save atmospheric data
1897+
self.__set_pressure_function(pressure_array)
1898+
self.__set_barometric_height_function(pressure_array[:, (1, 0)])
1899+
self.__set_temperature_function(temperature_array)
1900+
self.__set_wind_velocity_x_function(wind_u_array)
1901+
self.__set_wind_velocity_y_function(wind_v_array)
1902+
self.__set_wind_heading_function(
1903+
np.column_stack((wind_asl_heights, wind_heading_array))
1904+
)
1905+
self.__set_wind_direction_function(
1906+
np.column_stack((wind_asl_heights, wind_direction_array))
1907+
)
1908+
self.__set_wind_speed_function(
1909+
np.column_stack((wind_asl_heights, wind_speed_array))
1910+
)
1911+
1912+
# Save maximum expected height
1913+
self._max_expected_height = float(
1914+
max(pressure_array[-1, 0], temperature_array[-1, 0], wind_asl_heights[-1])
1915+
)
1916+
1917+
# Save model info metadata (single point in space and time)
1918+
self.atmospheric_model_init_date = self.datetime_date
1919+
self.atmospheric_model_end_date = self.datetime_date
1920+
self.atmospheric_model_interval = 0
1921+
self.atmospheric_model_init_lat = self.latitude
1922+
self.atmospheric_model_end_lat = self.latitude
1923+
self.atmospheric_model_init_lon = self.longitude
1924+
self.atmospheric_model_end_lon = self.longitude
1925+
1926+
# Save debugging data
1927+
self.wind_us = wind_u_values
1928+
self.wind_vs = wind_v_values
1929+
self.temperatures = temperature_array[:, 1]
1930+
self.pressures = pressure_array[:, 1]
1931+
self.height = wind_asl_heights
1932+
17391933
def process_wyoming_sounding(self, file): # pylint: disable=too-many-statements
17401934
"""Import and process the upper air sounding data from `Wyoming
17411935
Upper Air Soundings` database given by the url in file. Sets
@@ -3004,7 +3198,13 @@ def from_dict(cls, data): # pylint: disable=too-many-statements
30043198
env.elevation = data["elevation"]
30053199
env.max_expected_height = data["max_expected_height"]
30063200

3007-
if atmospheric_model in ("windy", "forecast", "reanalysis", "ensemble"):
3201+
if isinstance(atmospheric_model, str) and atmospheric_model.lower() in (
3202+
"windy",
3203+
"meteomatics",
3204+
"forecast",
3205+
"reanalysis",
3206+
"ensemble",
3207+
):
30083208
env.atmospheric_model_init_date = data["atmospheric_model_init_date"]
30093209
env.atmospheric_model_end_date = data["atmospheric_model_end_date"]
30103210
env.atmospheric_model_interval = data["atmospheric_model_interval"]

0 commit comments

Comments
 (0)