From d59bc2f67e7ace0d08937047d06651013710e484 Mon Sep 17 00:00:00 2001 From: Gui-FernandesBR Date: Sun, 9 Aug 2026 23:38:51 -0300 Subject: [PATCH 1/7] ENH: add Open-Meteo API fetchers for forecast, history and ensemble Wraps the three Open-Meteo endpoints RocketPy needs to build atmospheric profiles: the forecast API, the historical-forecast API (for past launch dates) and the ensemble API. All of them serve pressure-level data as plain JSON over HTTPS, with no API key and no netCDF/OPeNDAP dependency. Note that Open-Meteo's ERA5 archive endpoint is deliberately not used: it serves surface variables only and answers with nulls at every pressure level, so the historical-forecast API (available from 2021 onwards) is the only archive that can feed a vertical profile. Ensemble models are restricted to the ones that actually publish pressure-level data (gfs05, ecmwf_ifs025, gem_global); the others return HTTP 200 with null values, which would otherwise surface as an opaque failure much later in the parsing step. Co-Authored-By: Claude Opus 5 (1M context) --- rocketpy/environment/fetchers/__init__.py | 22 ++ .../fetchers/open_meteo_fetcher.py | 303 ++++++++++++++++++ 2 files changed, 325 insertions(+) create mode 100644 rocketpy/environment/fetchers/open_meteo_fetcher.py diff --git a/rocketpy/environment/fetchers/__init__.py b/rocketpy/environment/fetchers/__init__.py index 12adc57b3..fb5707c07 100644 --- a/rocketpy/environment/fetchers/__init__.py +++ b/rocketpy/environment/fetchers/__init__.py @@ -20,6 +20,18 @@ fetch_atmospheric_data_from_meteomatics, fetch_meteomatics_token, ) +from rocketpy.environment.fetchers.open_meteo_fetcher import ( + OPEN_METEO_ENSEMBLE_MODELS, + OPEN_METEO_ENSEMBLE_URL, + OPEN_METEO_FORECAST_URL, + OPEN_METEO_HISTORICAL_START_YEAR, + OPEN_METEO_HISTORICAL_URL, + OPEN_METEO_PRESSURE_LEVELS, + OPEN_METEO_TIMEOUT_SECONDS, + build_hourly_variables, + fetch_open_meteo_ensemble, + fetch_open_meteo_forecast, +) from rocketpy.environment.fetchers.opendap_fetchers import ( fetch_aigfs_file_return_dataset, fetch_cmc_ensemble, @@ -42,7 +54,15 @@ "METEOMATICS_BASE_URL", "METEOMATICS_LOGIN_URL", "METEOMATICS_TIMEOUT_SECONDS", + "OPEN_METEO_ENSEMBLE_MODELS", + "OPEN_METEO_ENSEMBLE_URL", + "OPEN_METEO_FORECAST_URL", + "OPEN_METEO_HISTORICAL_START_YEAR", + "OPEN_METEO_HISTORICAL_URL", + "OPEN_METEO_PRESSURE_LEVELS", + "OPEN_METEO_TIMEOUT_SECONDS", "MeteomaticsFetcher", + "build_hourly_variables", "fetch_aigfs_file_return_dataset", "fetch_atmospheric_data_from_meteomatics", "fetch_atmospheric_data_from_windy", @@ -54,6 +74,8 @@ "fetch_meteomatics_token", "fetch_nam_file_return_dataset", "fetch_open_elevation", + "fetch_open_meteo_ensemble", + "fetch_open_meteo_forecast", "fetch_rap_file_return_dataset", "fetch_wyoming_sounding", "logger", diff --git a/rocketpy/environment/fetchers/open_meteo_fetcher.py b/rocketpy/environment/fetchers/open_meteo_fetcher.py new file mode 100644 index 000000000..d7ee9d432 --- /dev/null +++ b/rocketpy/environment/fetchers/open_meteo_fetcher.py @@ -0,0 +1,303 @@ +"""Fetch weather data from the Open-Meteo API. + +Open-Meteo (https://open-meteo.com/) serves pressure-level forecasts, past +forecasts and ensemble forecasts as plain JSON over HTTPS, with no API key and +no heavy NetCDF/OPeNDAP dependency. This module wraps the three endpoints +RocketPy needs and returns their raw JSON bodies. +""" + +from datetime import datetime, timedelta, timezone + +import requests + +from rocketpy.environment.fetchers.base import logger +from rocketpy.tools import exponential_backoff + +OPEN_METEO_FORECAST_URL = "https://api.open-meteo.com/v1/forecast" +OPEN_METEO_HISTORICAL_URL = "https://historical-forecast-api.open-meteo.com/v1/forecast" +OPEN_METEO_ENSEMBLE_URL = "https://ensemble-api.open-meteo.com/v1/ensemble" +OPEN_METEO_TIMEOUT_SECONDS = 60 + +# Pressure levels (hPa) that Open-Meteo publishes for its pressure-level +# variables. Not every model resolves every level; levels that come back empty +# are dropped while parsing rather than requested conditionally, because the +# per-model coverage is not advertised by the API. +OPEN_METEO_PRESSURE_LEVELS = ( + 1000, + 975, + 950, + 925, + 900, + 850, + 800, + 700, + 600, + 500, + 400, + 300, + 250, + 200, + 150, + 100, + 70, + 50, + 30, +) + +# Per-level variables requested for every query. Open-Meteo reports wind as +# speed/direction rather than the u/v components RocketPy uses internally, so +# the conversion happens in the Environment parsing step. +OPEN_METEO_LEVEL_VARIABLES = ( + "temperature", + "geopotential_height", + "wind_speed", + "wind_direction", +) + +# Ensemble models known to publish pressure-level data. Other ensemble models +# (e.g. gfs025, icon_global) answer with HTTP 200 but null values at every +# level, which would otherwise surface as an opaque "no data" failure. +OPEN_METEO_ENSEMBLE_MODELS = ("gfs05", "ecmwf_ifs025", "gem_global") + +# The historical-forecast archive starts in 2021; earlier dates return nulls at +# every pressure level. Note that Open-Meteo's ERA5 archive endpoint +# (archive-api.open-meteo.com) is *not* used here because it serves surface +# variables only, with no pressure-level data at all. +OPEN_METEO_HISTORICAL_START_YEAR = 2021 + + +def build_hourly_variables(levels=OPEN_METEO_PRESSURE_LEVELS): + """Builds the comma-separated ``hourly`` query parameter for Open-Meteo. + + Parameters + ---------- + levels : sequence of int, optional + Pressure levels, in hPa, to request. Defaults to + :data:`OPEN_METEO_PRESSURE_LEVELS`. + + Returns + ------- + str + The value to pass as the ``hourly`` query parameter, e.g. + ``"temperature_1000hPa,geopotential_height_1000hPa,..."``. + + Examples + -------- + >>> from rocketpy.environment.fetchers.open_meteo_fetcher import ( + ... build_hourly_variables, + ... ) + >>> build_hourly_variables(levels=[500]) + 'temperature_500hPa,geopotential_height_500hPa,wind_speed_500hPa,wind_direction_500hPa' + """ + return ",".join( + f"{variable}_{level}hPa" + for level in levels + for variable in OPEN_METEO_LEVEL_VARIABLES + ) + + +@exponential_backoff(max_attempts=3, base_delay=1, max_delay=60) +def _get_json(url, params): + """Performs a single Open-Meteo GET request and returns its parsed body. + + Connection errors and server-side 5xx responses raise so the decorator + retries them. Client-side 4xx responses are definitive (a malformed query + or an out-of-range date) and are turned into an actionable error by the + caller instead of being retried. + """ + response = requests.get(url, params=params, timeout=OPEN_METEO_TIMEOUT_SECONDS) + if response.status_code >= 500: # pragma: no cover + response.raise_for_status() + return response + + +def _request(url, params, endpoint): + """Queries an Open-Meteo endpoint and returns its parsed JSON body. + + Parameters + ---------- + url : str + The endpoint address to query. + params : dict + Query parameters to send with the request. + endpoint : str + Human-readable endpoint name (e.g. ``"forecast"``), used in error + messages. + + Returns + ------- + dict + The parsed JSON body of the response. + + Raises + ------ + RuntimeError + If the endpoint cannot be reached, rejects the query, or returns a + malformed (non-JSON) body. + """ + try: + response = _get_json(url, params) + except requests.exceptions.RequestException as e: + raise RuntimeError( + f"Unable to reach the Open-Meteo {endpoint} API. Please try again later." + ) from e + + try: + payload = response.json() + except ValueError as e: + raise RuntimeError( + f"The Open-Meteo {endpoint} API returned a malformed (non-JSON) " + "response. Please try again later." + ) from e + + # Open-Meteo reports query errors as {"error": true, "reason": "..."}, + # which is far more specific than the bare status code. + if isinstance(payload, dict) and payload.get("error"): + raise RuntimeError( + f"The Open-Meteo {endpoint} API rejected the request: " + f"{payload.get('reason', 'no reason given')}" + ) + if not response.ok: # pragma: no cover + raise RuntimeError( + f"The Open-Meteo {endpoint} API request failed with status " + f"{response.status_code}." + ) + if "hourly" not in payload: + raise RuntimeError( + f"The Open-Meteo {endpoint} API response did not contain any hourly " + "data. Please try again later." + ) + + return payload + + +def fetch_open_meteo_forecast(latitude, longitude, model="best_match", date=None): + """Fetches a pressure-level forecast from the Open-Meteo API. + + Requests are routed to the historical-forecast endpoint when ``date`` lies + in the past, and to the regular forecast endpoint otherwise. + + Parameters + ---------- + latitude : float + The latitude of the location, in degrees. + longitude : float + The longitude of the location, in degrees. + model : str, optional + The Open-Meteo weather model to query, such as ``"best_match"`` (the + default), ``"gfs_seamless"``, ``"ecmwf_ifs025"`` or ``"icon_seamless"``. + See https://open-meteo.com/en/docs for the full list. + date : datetime.datetime, optional + The launch date and time. Used to pick the endpoint and, for past + dates, to bound the queried period. When None, the regular forecast + endpoint is queried. + + Returns + ------- + dict + The parsed JSON body returned by the API. + + Raises + ------ + RuntimeError + If the API cannot be reached or returns no usable data. + """ + params = { + "latitude": latitude, + "longitude": longitude, + "hourly": build_hourly_variables(), + "models": model, + "wind_speed_unit": "ms", + "timeformat": "unixtime", + "timezone": "UTC", + "cell_selection": "nearest", + } + + if _is_past_date(date): + # The archive is indexed by calendar day, so a one-day pad on each side + # guarantees the launch hour is inside the returned range regardless of + # the local-time offset. + params["start_date"] = (date - timedelta(days=1)).strftime("%Y-%m-%d") + params["end_date"] = (date + timedelta(days=1)).strftime("%Y-%m-%d") + logger.info( + "Launch date %s is in the past; querying the Open-Meteo " + "historical-forecast API.", + date, + ) + return _request(OPEN_METEO_HISTORICAL_URL, params, "historical forecast") + + return _request(OPEN_METEO_FORECAST_URL, params, "forecast") + + +def fetch_open_meteo_ensemble(latitude, longitude, model="gfs05", date=None): + """Fetches a pressure-level ensemble forecast from the Open-Meteo API. + + Parameters + ---------- + latitude : float + The latitude of the location, in degrees. + longitude : float + The longitude of the location, in degrees. + model : str, optional + The Open-Meteo ensemble model to query. Default is ``"gfs05"``. Only + the models in :data:`OPEN_METEO_ENSEMBLE_MODELS` publish + pressure-level data. + date : datetime.datetime, optional + The launch date and time. Past dates are queried against the + historical-forecast window of the ensemble endpoint. + + Returns + ------- + dict + The parsed JSON body returned by the API. + + Raises + ------ + ValueError + If ``model`` is not known to publish pressure-level data. + RuntimeError + If the API cannot be reached or returns no usable data. + """ + if model not in OPEN_METEO_ENSEMBLE_MODELS: + raise ValueError( + f"Invalid Open-Meteo ensemble model '{model}'. Only " + f"{', '.join(OPEN_METEO_ENSEMBLE_MODELS)} publish pressure-level " + "data, which RocketPy requires to build an atmospheric profile." + ) + + params = { + "latitude": latitude, + "longitude": longitude, + "hourly": build_hourly_variables(), + "models": model, + "wind_speed_unit": "ms", + "timeformat": "unixtime", + "timezone": "UTC", + "cell_selection": "nearest", + } + + if _is_past_date(date): + params["start_date"] = (date - timedelta(days=1)).strftime("%Y-%m-%d") + params["end_date"] = (date + timedelta(days=1)).strftime("%Y-%m-%d") + + return _request(OPEN_METEO_ENSEMBLE_URL, params, "ensemble") + + +def _is_past_date(date): + """Returns True when ``date`` is far enough in the past that the regular + forecast endpoint would no longer cover it. + + Open-Meteo's forecast endpoint keeps a couple of past days available, so + only dates before that window need the historical-forecast archive. + """ + if date is None: + return False + reference = date if date.tzinfo is not None else date.replace(tzinfo=timezone.utc) + now = _utc_now() + return reference < now - timedelta(days=1) + + +def _utc_now(): + """Returns the current UTC time. Wrapped in a helper so tests can patch it.""" + + return datetime.now(timezone.utc) From ce078441a435bc0eaaf93b204a79c134f25d5519 Mon Sep 17 00:00:00 2001 From: Gui-FernandesBR Date: Sun, 9 Aug 2026 23:45:30 -0300 Subject: [PATCH 2/7] ENH: support Open-Meteo atmospheric models in the Environment class Adds two new atmospheric model types to set_atmospheric_model: env.set_atmospheric_model("open_meteo") # best_match env.set_atmospheric_model("open_meteo", file="ecmwf_ifs025") env.set_atmospheric_model("open_meteo_ensemble", file="gfs05") Both build the usual pressure, temperature and wind profiles from Open-Meteo pressure-level data, so no external files and no netCDF/OPeNDAP libraries are involved. When the launch date is in the past, "open_meteo" transparently queries Open-Meteo's historical-forecast archive instead of the live forecast, which is what makes past-launch reconstruction work without downloading reanalysis files by hand. The ensemble processor stores every member, so select_ensemble_member() and plots.ensemble_member_comparison() work exactly as they do for GEFS. The unsuffixed control run is kept as member 0, matching the documented convention that member 0 is the unperturbed control. Open-Meteo reports wind as speed/direction rather than u/v components, so convert_wind_speed_direction_to_components is added to environment.tools; it converts the meteorological blows-from convention into RocketPy's East/North components. Temperatures are converted from Celsius to Kelvin and pressure levels from hPa to Pa. The model-type gates in the prints and plots classes were comparing capitalised literals ("Ensemble"), which never matched a lower-case type even though set_atmospheric_model documents the argument as case-insensitive. They now compare case-insensitively, so both the new Open-Meteo types and a lower-case "ensemble" report their time period and member count. Co-Authored-By: Claude Opus 5 (1M context) --- rocketpy/environment/environment.py | 362 +++++++++++++++++++++++++- rocketpy/environment/tools.py | 57 ++++ rocketpy/plots/environment_plots.py | 5 +- rocketpy/prints/environment_prints.py | 14 +- 4 files changed, 430 insertions(+), 8 deletions(-) diff --git a/rocketpy/environment/environment.py b/rocketpy/environment/environment.py index bc8330c8c..e9237f12c 100644 --- a/rocketpy/environment/environment.py +++ b/rocketpy/environment/environment.py @@ -22,13 +22,19 @@ fetch_hrrr_file_return_dataset, fetch_nam_file_return_dataset, fetch_open_elevation, + fetch_open_meteo_ensemble, + fetch_open_meteo_forecast, fetch_rap_file_return_dataset, fetch_wyoming_sounding, ) +from rocketpy.environment.fetchers.open_meteo_fetcher import ( + OPEN_METEO_PRESSURE_LEVELS, +) from rocketpy.environment.tools import ( calculate_wind_heading, calculate_wind_speed, convert_wind_heading_to_direction, + convert_wind_speed_direction_to_components, find_latitude_index, find_longitude_index, find_time_index, @@ -1200,8 +1206,9 @@ 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"``, - ``"custom_atmosphere"`` and ``"meteomatics"``. + ``"open_meteo"``, ``"open_meteo_ensemble"``, ``"forecast"``, + ``"reanalysis"``, ``"ensemble"``, ``"custom_atmosphere"`` and + ``"meteomatics"``. file : string | netCDF4.Dataset, optional Data source or model shortcut. Meaning depends on ``type``: @@ -1209,6 +1216,12 @@ 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"``. + - ``"open_meteo"``: the Open-Meteo model to query, such as + ``"best_match"`` (the default when omitted), ``"gfs_seamless"``, + ``"ecmwf_ifs025"`` or ``"icon_seamless"``. See the Open-Meteo + documentation for the full list. + - ``"open_meteo_ensemble"``: one of ``"gfs05"`` (the default when + omitted), ``"ecmwf_ifs025"`` or ``"gem_global"``. - ``"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. @@ -1353,6 +1366,14 @@ 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 "open_meteo": + self.process_open_meteo_atmosphere( + **({} if file is None else {"model": file}) + ) + case "open_meteo_ensemble": + self.process_open_meteo_ensemble( + **({} if file is None else {"model": file}) + ) case "meteomatics": self.process_meteomatics_atmosphere( model=file, username=username, password=password @@ -1461,7 +1482,7 @@ def set_atmospheric_model( # pylint: disable=too-many-statements case _: # pragma: no cover raise ValueError(f"Unknown model type '{type}'.") - if type not in ["ensemble"]: + if type not in ["ensemble", "open_meteo_ensemble"]: # Ensemble already computed these values self.calculate_density_profile() self.calculate_speed_of_sound_profile() @@ -1755,6 +1776,329 @@ def __parse_windy_file(self, response, time_index, pressure_levels): wind_v_array, ) + def __parse_open_meteo_levels(self, hourly, time_index, member_suffix=""): + """Extracts one vertical profile from an Open-Meteo ``hourly`` payload. + + Levels whose variables are missing (either absent from the response or + ``None`` at the requested hour) are skipped, since Open-Meteo publishes + the same set of level keys for every model but only fills the ones the + model actually resolves. + + Parameters + ---------- + hourly : dict + The ``hourly`` section of the Open-Meteo JSON response. + time_index : int + Index of the hour to extract. + member_suffix : str, optional + Suffix identifying an ensemble member (e.g. ``"_member01"``). Empty + for deterministic forecasts. + + Returns + ------- + tuple of numpy.ndarray + The pressure levels (hPa), geopotential heights (m), temperatures + (K), wind-u and wind-v components (m/s), all sorted by ascending + altitude. + """ + levels = [] + geopotential_heights = [] + temperatures = [] + wind_speeds = [] + wind_directions = [] + + for level in OPEN_METEO_PRESSURE_LEVELS: + keys = { + name: f"{name}_{level}hPa{member_suffix}" + for name in ( + "temperature", + "geopotential_height", + "wind_speed", + "wind_direction", + ) + } + if any(key not in hourly for key in keys.values()): + continue + values = {name: hourly[key][time_index] for name, key in keys.items()} + if any(value is None for value in values.values()): + continue + + levels.append(level) + geopotential_heights.append(values["geopotential_height"]) + temperatures.append(values["temperature"]) + wind_speeds.append(values["wind_speed"]) + wind_directions.append(values["wind_direction"]) + + if len(levels) < 2: + raise ValueError( + "Open-Meteo returned fewer than two usable pressure levels for " + "this location and time, which is not enough to build an " + "atmospheric profile. Check the requested model: not every " + "Open-Meteo model publishes pressure-level data." + ) + + levels = np.array(levels, dtype=float) + geopotential_heights = np.array(geopotential_heights, dtype=float) + # Temperatures come in degrees Celsius; RocketPy works in Kelvin. + temperatures = np.array(temperatures, dtype=float) + 273.15 + wind_u, wind_v = convert_wind_speed_direction_to_components( + np.array(wind_speeds, dtype=float), + np.array(wind_directions, dtype=float), + ) + + # Open-Meteo lists levels from the ground up (1000 hPa first), but sort + # explicitly so the profile is monotonic in altitude even if a model + # reports levels out of order. + order = np.argsort(geopotential_heights) + + return ( + levels[order], + geopotential_heights[order], + temperatures[order], + wind_u[order], + wind_v[order], + ) + + def __find_open_meteo_time_index(self, hourly): + """Returns the index of the hour closest to the launch date.""" + # 'timeformat=unixtime' is requested, so times are seconds since epoch. + time_array = np.array(hourly["time"], dtype=float) + launch_time = self.datetime_date.timestamp() + return int(np.abs(time_array - launch_time).argmin()), time_array + + def __store_open_meteo_metadata(self, response, time_array): + """Sets the metadata attributes shared by both Open-Meteo processors.""" + time_units = "seconds since 1970-01-01 00:00:00" + self.atmospheric_model_init_date = get_initial_date_from_time_array( + time_array, time_units + ) + self.atmospheric_model_end_date = get_final_date_from_time_array( + time_array, time_units + ) + self.atmospheric_model_interval = get_interval_date_from_time_array( + time_array, time_units + ) + # Open-Meteo answers for the single grid cell nearest the request. + self.atmospheric_model_init_lat = float(response["latitude"]) + self.atmospheric_model_end_lat = float(response["latitude"]) + self.atmospheric_model_init_lon = float(response["longitude"]) + self.atmospheric_model_end_lon = float(response["longitude"]) + self.time_array = time_array + + if response.get("elevation") is not None: + self.elevation = float(response["elevation"]) + + def process_open_meteo_atmosphere(self, model="best_match"): + """Process data from the Open-Meteo API to retrieve atmospheric forecast + data. + + Open-Meteo serves pressure-level data as plain JSON over HTTPS, without + an API key and without requiring netCDF/OPeNDAP libraries. When the + launch date lies in the past, the request is routed to Open-Meteo's + historical-forecast archive instead of the live forecast. + + Parameters + ---------- + model : str, optional + The Open-Meteo weather model to query. Default is ``"best_match"``, + which lets Open-Meteo pick the highest-resolution model available + for the location. Other useful values are ``"gfs_seamless"``, + ``"ecmwf_ifs025"``, ``"icon_seamless"`` and + ``"meteofrance_seamless"``. See https://open-meteo.com/en/docs for + the full list. + + Raises + ------ + ValueError + If no launch date is set, or if the API returns fewer than two + usable pressure levels. + RuntimeError + If the Open-Meteo API cannot be reached or returns no usable data. + + Notes + ----- + Open-Meteo's historical data comes from its own past forecast runs and + is available from 2021 onwards. Its ERA5 archive endpoint is not used + because it serves surface variables only, with no pressure-level data. + """ + self.__validate_datetime() + + response = fetch_open_meteo_forecast( + self.latitude, self.longitude, model=model, date=self.datetime_date + ) + hourly = response["hourly"] + time_index, time_array = self.__find_open_meteo_time_index(hourly) + + ( + pressure_levels, + geopotential_height_array, + temperature_array, + wind_u_array, + wind_v_array, + ) = self.__parse_open_meteo_levels(hourly, time_index) + + altitude_array = geopotential_height_to_geometric_height( + geopotential_height_array, self.earth_radius + ) + + wind_speed_array = calculate_wind_speed(wind_u_array, wind_v_array) + wind_heading_array = calculate_wind_heading(wind_u_array, wind_v_array) + wind_direction_array = convert_wind_heading_to_direction(wind_heading_array) + + data_array = mask_and_clean_dataset( + 100 * pressure_levels, # Convert hPa to Pa + altitude_array, + temperature_array, + wind_u_array, + wind_v_array, + wind_heading_array, + wind_direction_array, + wind_speed_array, + ) + + # Save atmospheric data + self.__set_pressure_function(data_array[:, (1, 0)]) + self.__set_barometric_height_function(data_array[:, (0, 1)]) + self.__set_temperature_function(data_array[:, (1, 2)]) + self.__set_wind_velocity_x_function(data_array[:, (1, 3)]) + self.__set_wind_velocity_y_function(data_array[:, (1, 4)]) + self.__set_wind_heading_function(data_array[:, (1, 5)]) + self.__set_wind_direction_function(data_array[:, (1, 6)]) + self.__set_wind_speed_function(data_array[:, (1, 7)]) + + # Save maximum expected height + self._max_expected_height = float(max(altitude_array[0], altitude_array[-1])) + + self.__store_open_meteo_metadata(response, time_array) + + # Save debugging data + self.geopotentials = geopotential_height_array + self.wind_us = wind_u_array + self.wind_vs = wind_v_array + self.levels = pressure_levels + self.temperatures = temperature_array + self.height = altitude_array + + def process_open_meteo_ensemble(self, model="gfs05"): + """Process ensemble forecast data from the Open-Meteo API. + + Every ensemble member is stored so that + :meth:`Environment.select_ensemble_member` can switch between them, in + the same way as the netCDF-based ensemble models. + + Parameters + ---------- + model : str, optional + The Open-Meteo ensemble model to query. Default is ``"gfs05"`` (30 + members). Also available are ``"ecmwf_ifs025"`` (50 members) and + ``"gem_global"`` (20 members). These are the only Open-Meteo + ensemble models that publish pressure-level data. + + Raises + ------ + ValueError + If ``model`` does not publish pressure-level data, if no launch + date is set, or if the API returns fewer than two usable pressure + levels. + RuntimeError + If the Open-Meteo API cannot be reached or returns no usable data. + """ + self.__validate_datetime() + + response = fetch_open_meteo_ensemble( + self.latitude, self.longitude, model=model, date=self.datetime_date + ) + hourly = response["hourly"] + time_index, time_array = self.__find_open_meteo_time_index(hourly) + + member_suffixes = self.__find_open_meteo_members(hourly) + + levels = None + heights = [] + temperatures = [] + wind_us = [] + wind_vs = [] + + for suffix in member_suffixes: + ( + member_levels, + geopotential_heights, + member_temperatures, + member_wind_u, + member_wind_v, + ) = self.__parse_open_meteo_levels(hourly, time_index, suffix) + + # Members may resolve different level counts; keep only the levels + # common to every member so the ensemble stays a regular array. + if levels is None or len(member_levels) < len(levels): + levels = member_levels + heights.append( + geopotential_height_to_geometric_height( + geopotential_heights, self.earth_radius + ) + ) + temperatures.append(member_temperatures) + wind_us.append(member_wind_u) + wind_vs.append(member_wind_v) + + profile_length = min(len(levels), *(len(h) for h in heights)) + levels = levels[:profile_length] + height = np.array([h[:profile_length] for h in heights]) + temperature = np.array([t[:profile_length] for t in temperatures]) + wind_u = np.array([u[:profile_length] for u in wind_us]) + wind_v = np.array([v[:profile_length] for v in wind_vs]) + + wind_speed = calculate_wind_speed(wind_u, wind_v) + wind_heading = calculate_wind_heading(wind_u, wind_v) + wind_direction = convert_wind_heading_to_direction(wind_heading) + + # Save ensemble data + self.level_ensemble = 100 * levels # Convert hPa to Pa + self.height_ensemble = height + self.temperature_ensemble = temperature + self.wind_u_ensemble = wind_u + self.wind_v_ensemble = wind_v + self.wind_heading_ensemble = wind_heading + self.wind_direction_ensemble = wind_direction + self.wind_speed_ensemble = wind_speed + self.num_ensemble_members = len(member_suffixes) + + # Activate default ensemble + self.select_ensemble_member() + + self.__store_open_meteo_metadata(response, time_array) + + # Save debugging data + self.levels = self.level_ensemble + self.geopotentials = height + self.wind_us = wind_u + self.wind_vs = wind_v + self.temperatures = temperature + self.height = height + + @staticmethod + def __find_open_meteo_members(hourly): + """Returns the sorted member suffixes present in an ensemble payload. + + Open-Meteo names ensemble members ``_memberNN``, alongside an + unsuffixed control run. The control run is kept as the first member so + that ``select_ensemble_member(0)`` selects it, matching the behaviour + documented for the netCDF-based ensembles. + """ + suffixes = sorted( + { + match.group(1) + for key in hourly + if (match := re.search(r"(_member\d+)$", key)) + } + ) + if not suffixes: + raise ValueError( + "The Open-Meteo ensemble response did not contain any ensemble " + "members. Please try again later or choose another model." + ) + return [""] + suffixes + @staticmethod def _validate_meteomatics_credentials_and_model(model, username, password): """Validates model and credentials for Meteomatics requests.""" @@ -3235,7 +3579,15 @@ def from_dict(cls, data): # pylint: disable=too-many-statements env.elevation = data["elevation"] env.max_expected_height = data["max_expected_height"] - if model_type in ("windy", "meteomatics", "forecast", "reanalysis", "ensemble"): + if model_type in ( + "windy", + "meteomatics", + "open_meteo", + "open_meteo_ensemble", + "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"] @@ -3244,7 +3596,7 @@ def from_dict(cls, data): # pylint: disable=too-many-statements env.atmospheric_model_init_lon = data["atmospheric_model_init_lon"] env.atmospheric_model_end_lon = data["atmospheric_model_end_lon"] - if model_type == "ensemble": + if model_type in ("ensemble", "open_meteo_ensemble"): env.level_ensemble = data["level_ensemble"] env.height_ensemble = data["height_ensemble"] env.temperature_ensemble = data["temperature_ensemble"] diff --git a/rocketpy/environment/tools.py b/rocketpy/environment/tools.py index 37425b41c..cb0f4d5ad 100644 --- a/rocketpy/environment/tools.py +++ b/rocketpy/environment/tools.py @@ -130,6 +130,63 @@ def calculate_wind_speed(u, v, w=0.0): return np.sqrt(u**2 + v**2 + w**2) +def convert_wind_speed_direction_to_components(wind_speed, wind_direction): + """Converts meteorological wind speed and direction to u and v components. + + Meteorological wind direction is the direction the wind blows *from*, + measured clockwise from true north, which is the convention used by most + weather APIs. The returned components follow the RocketPy convention: u + points East and v points North, both describing where the wind blows *to*. + + Parameters + ---------- + wind_speed : float, numpy.ndarray + The wind speed in m/s. + wind_direction : float, numpy.ndarray + The direction the wind is coming from, in degrees clockwise from true + north (0 to 360). + + Returns + ------- + tuple of (float, float) or (numpy.ndarray, numpy.ndarray) + The u (East) and v (North) components of the wind, in m/s. + + Examples + -------- + >>> import numpy as np + >>> from rocketpy.environment.tools import ( + ... convert_wind_speed_direction_to_components, + ... ) + + A wind coming from the north blows towards the south, so v is negative: + + >>> u, v = convert_wind_speed_direction_to_components(10, 0) + >>> float(np.round(u, 6) + 0.0), float(np.round(v, 6)) + (0.0, -10.0) + + A wind coming from the west blows towards the east, so u is positive: + + >>> u, v = convert_wind_speed_direction_to_components(10, 270) + >>> float(np.round(u, 6)), float(np.round(v, 6) + 0.0) + (10.0, 0.0) + + The conversion round-trips with :func:`calculate_wind_heading` and + :func:`convert_wind_heading_to_direction`: + + >>> u, v = convert_wind_speed_direction_to_components(7.5, 135) + >>> float(np.round(calculate_wind_speed(u, v), 6)) + 7.5 + >>> float(np.round(convert_wind_heading_to_direction( + ... calculate_wind_heading(u, v)), 6)) + 135.0 + """ + direction_rad = np.radians(wind_direction) + return ( + -wind_speed * np.sin(direction_rad), + -wind_speed * np.cos(direction_rad), + ) + + def geodesic_to_lambert_conformal(lat, lon, projection_variable, x_units="m"): """Convert geodesic coordinates to Lambert conformal projected coordinates. diff --git a/rocketpy/plots/environment_plots.py b/rocketpy/plots/environment_plots.py index add5e4efb..5dbda8b2a 100644 --- a/rocketpy/plots/environment_plots.py +++ b/rocketpy/plots/environment_plots.py @@ -434,6 +434,9 @@ def all(self): self.atmospheric_model() # Plot ensemble member comparison - if self.environment.atmospheric_model_type == "Ensemble": + if self.environment.atmospheric_model_type.lower() in ( + "ensemble", + "open_meteo_ensemble", + ): print("\n\nEnsemble Members Comparison") self.ensemble_member_comparison() diff --git a/rocketpy/prints/environment_prints.py b/rocketpy/prints/environment_prints.py index ba01d7d82..8ab847ac2 100644 --- a/rocketpy/prints/environment_prints.py +++ b/rocketpy/prints/environment_prints.py @@ -102,7 +102,17 @@ def atmospheric_model_details(self): f"{model_type} Maximum Height: " f"{self.environment.max_expected_height / 1000:.3f} km" ) - if model_type in ["Forecast", "Reanalysis", "Ensemble"]: + # set_atmospheric_model accepts the type case-insensitively and stores it + # as the user spelled it, so compare in lower case while still printing + # the original spelling above. + normalized_type = model_type.lower() + if normalized_type in [ + "forecast", + "reanalysis", + "ensemble", + "open_meteo", + "open_meteo_ensemble", + ]: # Determine time period init_date = self.environment.atmospheric_model_init_date end_date = self.environment.atmospheric_model_end_date @@ -116,7 +126,7 @@ def atmospheric_model_details(self): end_lon = self.environment.atmospheric_model_end_lon print(f"{model_type} Latitude Range: From {init_lat}° to {end_lat}°") print(f"{model_type} Longitude Range: From {init_lon}° to {end_lon}°") - if model_type == "Ensemble": + if normalized_type in ["ensemble", "open_meteo_ensemble"]: print( f"Number of Ensemble Members: {self.environment.num_ensemble_members}" ) From 077528987d7663b981c38eb7f3ed047ca14d427b Mon Sep 17 00:00:00 2001 From: Gui-FernandesBR Date: Sun, 9 Aug 2026 23:50:52 -0300 Subject: [PATCH 3/7] TST: cover the Open-Meteo atmospheric models Adds 44 offline unit tests (tests/unit/environment/test_open_meteo.py) and 6 live integration tests marked slow. The unit tests patch the fetchers, so the whole module runs without network access: verified by re-running the suite with socket.connect blocked, where all 44 still pass. They cover the unit conversions (hPa to Pa, Celsius to Kelvin, speed/direction to u/v), the nearest-hour selection, skipping levels a model does not resolve, altitude sorting, the ensemble member layout with the control run as member 0, the endpoint routing for past versus future dates, error payload handling, and to_dict/from_dict round trips. The wind-component conversion is tested against the four cardinal directions and round-tripped through calculate_wind_heading, since getting that convention wrong would silently flip the wind by 180 degrees. Co-Authored-By: Claude Opus 5 (1M context) --- .../environment/test_environment.py | 79 +++ tests/unit/environment/test_open_meteo.py | 641 ++++++++++++++++++ 2 files changed, 720 insertions(+) create mode 100644 tests/unit/environment/test_open_meteo.py diff --git a/tests/integration/environment/test_environment.py b/tests/integration/environment/test_environment.py index d51551397..3f3d89746 100644 --- a/tests/integration/environment/test_environment.py +++ b/tests/integration/environment/test_environment.py @@ -194,6 +194,85 @@ def test_windy_atmosphere(example_euroc_env, model_name): assert abs(example_euroc_env.wind_velocity_y(100)) < 20.0 +@pytest.mark.slow +@pytest.mark.parametrize( + "model_name", + [ + "best_match", + "gfs_seamless", + "ecmwf_ifs025", + "icon_seamless", + ], +) +def test_open_meteo_atmosphere(example_euroc_env, model_name): + """Tests the Open-Meteo forecast model against the live API. + + The tolerances are loose because the actual weather is unknown at test + time; the point is to check that the profiles are built and that the values + are physically plausible. + + Parameters + ---------- + example_euroc_env : rocketpy.Environment + Example environment object to be tested. + model_name : str + The Open-Meteo model to be passed to set_atmospheric_model() as the + "file" parameter. + """ + example_euroc_env.set_atmospheric_model(type="open_meteo", file=model_name) + + assert pytest.approx(100000.0, rel=0.1) == example_euroc_env.pressure(100) + assert 0 + 273 < example_euroc_env.temperature(100) < 40 + 273 + assert abs(example_euroc_env.wind_velocity_x(100)) < 30.0 + assert abs(example_euroc_env.wind_velocity_y(100)) < 30.0 + # Pressure must fall monotonically with altitude. + assert example_euroc_env.pressure(5000) < example_euroc_env.pressure(1000) + # Air density at sea level is around 1.2 kg/m^3. + assert 0.9 < example_euroc_env.density(100) < 1.4 + + +@pytest.mark.slow +def test_open_meteo_historical_atmosphere(example_euroc_env): + """Tests that a past launch date reaches Open-Meteo's historical archive. + + This is the workflow that removes the need to download reanalysis files by + hand: setting a past date and reading the profile straight from the API. + """ + example_euroc_env.set_date(datetime(2024, 1, 10, 12, tzinfo=timezone.utc)) + example_euroc_env.set_atmospheric_model(type="open_meteo") + + assert pytest.approx(100000.0, rel=0.1) == example_euroc_env.pressure(100) + assert 0 + 273 < example_euroc_env.temperature(100) < 40 + 273 + # The returned window must bracket the requested launch date. + assert example_euroc_env.atmospheric_model_init_date <= datetime(2024, 1, 10, 12) + assert example_euroc_env.atmospheric_model_end_date >= datetime(2024, 1, 10, 12) + + +@pytest.mark.slow +@patch("matplotlib.pyplot.show") +def test_open_meteo_ensemble_atmosphere(mock_show, example_euroc_env): # pylint: disable=unused-argument + """Tests the Open-Meteo ensemble model against the live API. + + Parameters + ---------- + mock_show : mock + Mock object to replace matplotlib.pyplot.show() method. + example_euroc_env : rocketpy.Environment + Example environment object to be tested. + """ + example_euroc_env.set_atmospheric_model(type="open_meteo_ensemble", file="gfs05") + + # gfs05 publishes 30 perturbed members plus the control run. + assert example_euroc_env.num_ensemble_members == 31 + assert pytest.approx(100000.0, rel=0.1) == example_euroc_env.pressure(100) + + example_euroc_env.select_ensemble_member(10) + assert example_euroc_env.ensemble_member == 10 + assert pytest.approx(100000.0, rel=0.1) == example_euroc_env.pressure(100) + + assert example_euroc_env.all_info() is None + + @pytest.mark.slow @patch("matplotlib.pyplot.show") def test_gfs_atmosphere(mock_show, example_spaceport_env): # pylint: disable=unused-argument diff --git a/tests/unit/environment/test_open_meteo.py b/tests/unit/environment/test_open_meteo.py new file mode 100644 index 000000000..d8aaca8a5 --- /dev/null +++ b/tests/unit/environment/test_open_meteo.py @@ -0,0 +1,641 @@ +"""Offline unit tests for the Open-Meteo atmospheric models. + +Every test here patches the fetchers, so no network requests are made. The live +API is exercised by the ``@pytest.mark.slow`` tests in +``tests/integration/environment/test_environment.py``. +""" + +from datetime import datetime, timedelta, timezone + +import numpy as np +import pytest + +from rocketpy import Environment +from rocketpy.environment.fetchers import open_meteo_fetcher +from rocketpy.environment.tools import ( + calculate_wind_heading, + calculate_wind_speed, + convert_wind_heading_to_direction, + convert_wind_speed_direction_to_components, +) + +# Three pressure levels are enough to build a profile and to check the +# hPa -> Pa, Celsius -> Kelvin and speed/direction -> u/v conversions. +FAKE_LEVELS = { + 1000: { + "temperature": 15.0, + "geopotential_height": 100.0, + "wind_speed": 10.0, + "wind_direction": 270.0, # from the west -> blows east -> u > 0 + }, + 850: { + "temperature": 5.0, + "geopotential_height": 1500.0, + "wind_speed": 20.0, + "wind_direction": 0.0, # from the north -> blows south -> v < 0 + }, + 500: { + "temperature": -20.0, + "geopotential_height": 5500.0, + "wind_speed": 30.0, + "wind_direction": 90.0, # from the east -> blows west -> u < 0 + }, +} + +# Two hourly steps, one hour apart, both in the future relative to the fixtures. +FAKE_TIMES = [1_700_000_000, 1_700_003_600] + + +def _build_hourly(levels=None, member_suffixes=("",), times=None, offset=0.0): + """Builds a fake Open-Meteo ``hourly`` payload. + + Parameters + ---------- + levels : dict, optional + Mapping of pressure level (hPa) to its variables. Defaults to + :data:`FAKE_LEVELS`. + member_suffixes : tuple of str, optional + Member suffixes to emit (``""`` for the deterministic/control run). + times : list of int, optional + Unix timestamps for the hourly steps. + offset : float, optional + Value added to every member's temperature and wind speed, multiplied by + the member index, so members differ from one another. + """ + levels = FAKE_LEVELS if levels is None else levels + times = FAKE_TIMES if times is None else times + hourly = {"time": list(times)} + + for member_index, suffix in enumerate(member_suffixes): + shift = offset * member_index + for level, variables in levels.items(): + for name, value in variables.items(): + if value is None: + values = [None] * len(times) + elif name in ("temperature", "wind_speed"): + values = [value + shift] * len(times) + else: + values = [value] * len(times) + hourly[f"{name}_{level}hPa{suffix}"] = values + + return hourly + + +def _build_response(hourly=None, elevation=100.0): + """Builds a fake Open-Meteo JSON response around ``hourly``.""" + return { + "latitude": 39.4, + "longitude": -8.3, + "elevation": elevation, + "hourly": _build_hourly() if hourly is None else hourly, + } + + +def _patch_forecast(monkeypatch, response=None, recorder=None): + """Replaces the Open-Meteo forecast fetcher with an offline fake.""" + payload = _build_response() if response is None else response + + def fake_fetch(latitude, longitude, model="best_match", date=None): + if recorder is not None: + recorder.update( + { + "latitude": latitude, + "longitude": longitude, + "model": model, + "date": date, + } + ) + return payload + + monkeypatch.setattr( + "rocketpy.environment.environment.fetch_open_meteo_forecast", fake_fetch + ) + + +def _patch_ensemble(monkeypatch, response=None, recorder=None): + """Replaces the Open-Meteo ensemble fetcher with an offline fake.""" + payload = _build_response() if response is None else response + + def fake_fetch(latitude, longitude, model="gfs05", date=None): + if recorder is not None: + recorder.update({"model": model, "date": date}) + return payload + + monkeypatch.setattr( + "rocketpy.environment.environment.fetch_open_meteo_ensemble", fake_fetch + ) + + +class TestWindComponentConversion: + """Tests for convert_wind_speed_direction_to_components.""" + + @pytest.mark.parametrize( + ("direction", "expected_u", "expected_v"), + [ + (0.0, 0.0, -10.0), # from north -> blows south + (90.0, -10.0, 0.0), # from east -> blows west + (180.0, 0.0, 10.0), # from south -> blows north + (270.0, 10.0, 0.0), # from west -> blows east + ], + ) + def test_cardinal_directions(self, direction, expected_u, expected_v): + """Convert the four cardinal wind directions to u/v components.""" + u, v = convert_wind_speed_direction_to_components(10.0, direction) + + assert u == pytest.approx(expected_u, abs=1e-9) + assert v == pytest.approx(expected_v, abs=1e-9) + + @pytest.mark.parametrize("direction", [0.0, 37.0, 135.0, 212.5, 359.0]) + def test_round_trips_back_to_direction(self, direction): + """Recover the original speed and direction from the components.""" + u, v = convert_wind_speed_direction_to_components(12.5, direction) + + assert calculate_wind_speed(u, v) == pytest.approx(12.5) + recovered = convert_wind_heading_to_direction(calculate_wind_heading(u, v)) + assert recovered == pytest.approx(direction, abs=1e-9) + + def test_accepts_arrays(self): + """Convert whole profiles at once, elementwise.""" + speeds = np.array([10.0, 20.0]) + directions = np.array([270.0, 90.0]) + + u, v = convert_wind_speed_direction_to_components(speeds, directions) + + assert u == pytest.approx([10.0, -20.0], abs=1e-9) + assert v == pytest.approx([0.0, 0.0], abs=1e-9) + + +class TestOpenMeteoForecast: + """Tests for the ``open_meteo`` atmospheric model.""" + + def test_builds_profiles_with_unit_conversions(self, example_euroc_env): + """Build pressure, temperature and wind profiles from Open-Meteo data. + + Pressure levels arrive in hPa and temperatures in Celsius, so the + profiles must expose Pa and Kelvin. Heights are geopotential and are + converted to geometric altitude, which is a sub-metre correction at + these levels. + """ + example_euroc_env.set_atmospheric_model(type="open_meteo") + + assert example_euroc_env.atmospheric_model_type == "open_meteo" + # 1000 hPa -> 100 000 Pa, at ~100 m geometric altitude + assert example_euroc_env.pressure(100.0) == pytest.approx(100_000.0, rel=1e-3) + # 15 degC -> 288.15 K + assert example_euroc_env.temperature(100.0) == pytest.approx(288.15, rel=1e-3) + # 500 hPa level, at ~5500 m + assert example_euroc_env.pressure(5500.0) == pytest.approx(50_000.0, rel=1e-3) + assert example_euroc_env.temperature(5500.0) == pytest.approx(253.15, rel=1e-3) + + def test_converts_wind_direction_to_components(self, example_euroc_env): + """Turn the reported speed/direction into RocketPy's u/v components.""" + example_euroc_env.set_atmospheric_model(type="open_meteo") + + # 1000 hPa: 10 m/s from the west -> blows east -> u = +10, v = 0 + assert example_euroc_env.wind_velocity_x(100.0) == pytest.approx(10.0, abs=1e-6) + assert example_euroc_env.wind_velocity_y(100.0) == pytest.approx(0.0, abs=1e-6) + assert example_euroc_env.wind_speed(100.0) == pytest.approx(10.0, abs=1e-6) + # The direction is preserved (the wind still comes from the west). + assert example_euroc_env.wind_direction(100.0) == pytest.approx(270.0, abs=1e-6) + # And the heading points where the wind blows to. + assert example_euroc_env.wind_heading(100.0) == pytest.approx(90.0, abs=1e-6) + + def test_forwards_model_and_date_to_fetcher(self, example_euroc_env, monkeypatch): + """Forward the requested model and the launch date to the fetcher.""" + recorder = {} + _patch_forecast(monkeypatch, recorder=recorder) + + example_euroc_env.set_atmospheric_model(type="open_meteo", file="ecmwf_ifs025") + + assert recorder["model"] == "ecmwf_ifs025" + assert recorder["date"] == example_euroc_env.datetime_date + assert recorder["latitude"] == example_euroc_env.latitude + assert recorder["longitude"] == example_euroc_env.longitude + + def test_defaults_to_best_match_model(self, example_euroc_env, monkeypatch): + """Query the ``best_match`` model when none is given.""" + recorder = {} + _patch_forecast(monkeypatch, recorder=recorder) + + example_euroc_env.set_atmospheric_model(type="open_meteo") + + assert recorder["model"] == "best_match" + + def test_reads_elevation_and_metadata(self, example_euroc_env): + """Take the launch-site elevation and the period from the response.""" + example_euroc_env.set_atmospheric_model(type="open_meteo") + + assert example_euroc_env.elevation == pytest.approx(100.0) + assert example_euroc_env.atmospheric_model_init_lat == pytest.approx(39.4) + assert example_euroc_env.atmospheric_model_init_lon == pytest.approx(-8.3) + # Two steps one hour apart. + assert example_euroc_env.atmospheric_model_interval == pytest.approx(1) + assert example_euroc_env.max_expected_height == pytest.approx(5500.0, rel=1e-3) + + def test_selects_hour_closest_to_launch(self, example_euroc_env, monkeypatch): + """Pick the hourly step nearest to the launch time. + + The launch date is set 40 minutes past the first step, so the first step + is the closest one and its values must be the ones used. + """ + launch = datetime(2024, 6, 1, 12, 40, tzinfo=timezone.utc) + first = datetime(2024, 6, 1, 12, tzinfo=timezone.utc) + second = datetime(2024, 6, 1, 14, tzinfo=timezone.utc) + + levels = { + 1000: { + "temperature": 15.0, + "geopotential_height": 100.0, + "wind_speed": 10.0, + "wind_direction": 270.0, + }, + 500: { + "temperature": -20.0, + "geopotential_height": 5500.0, + "wind_speed": 30.0, + "wind_direction": 90.0, + }, + } + hourly = _build_hourly( + levels=levels, times=[first.timestamp(), second.timestamp()] + ) + # Make the second step unmistakably different from the first one. + hourly["temperature_1000hPa"] = [15.0, 99.0] + _patch_forecast(monkeypatch, response=_build_response(hourly=hourly)) + + example_euroc_env.set_date(launch, timezone="UTC") + example_euroc_env.set_atmospheric_model(type="open_meteo") + + assert example_euroc_env.temperature(100.0) == pytest.approx(288.15, rel=1e-3) + + def test_skips_levels_without_data(self, example_euroc_env, monkeypatch): + """Drop pressure levels the model does not resolve. + + Open-Meteo returns the same set of level keys for every model but fills + only the levels the model actually resolves, so a ``None`` level must be + skipped rather than poison the profile. + """ + levels = { + 1000: FAKE_LEVELS[1000], + 850: {**FAKE_LEVELS[850], "temperature": None}, + 500: FAKE_LEVELS[500], + } + _patch_forecast( + monkeypatch, response=_build_response(hourly=_build_hourly(levels=levels)) + ) + + example_euroc_env.set_atmospheric_model(type="open_meteo") + + # Only the 1000 and 500 hPa levels survive. + assert len(example_euroc_env.levels) == 2 + assert example_euroc_env.levels == pytest.approx([1000.0, 500.0]) + + def test_sorts_levels_by_altitude(self, example_euroc_env, monkeypatch): + """Return a profile monotonic in altitude even if levels arrive unsorted.""" + levels = { + 500: FAKE_LEVELS[500], + 1000: FAKE_LEVELS[1000], + 850: FAKE_LEVELS[850], + } + _patch_forecast( + monkeypatch, response=_build_response(hourly=_build_hourly(levels=levels)) + ) + + example_euroc_env.set_atmospheric_model(type="open_meteo") + + assert np.all(np.diff(example_euroc_env.height) > 0) + # Pressure must decrease as altitude increases. + assert np.all(np.diff(example_euroc_env.pressure.get_source()[:, 1]) < 0) + + def test_single_usable_level_raises(self, example_euroc_env, monkeypatch): + """Refuse a collapsed profile instead of failing later during a flight.""" + levels = { + 1000: FAKE_LEVELS[1000], + 850: {key: None for key in FAKE_LEVELS[850]}, + 500: {key: None for key in FAKE_LEVELS[500]}, + } + _patch_forecast( + monkeypatch, response=_build_response(hourly=_build_hourly(levels=levels)) + ) + + with pytest.raises(ValueError, match="fewer than two usable pressure levels"): + example_euroc_env.set_atmospheric_model(type="open_meteo") + + def test_missing_date_raises(self, example_plain_env, monkeypatch): + """Require a launch date, since the profile is time-dependent.""" + _patch_forecast(monkeypatch) + + with pytest.raises(ValueError, match="specify the launch date"): + example_plain_env.set_atmospheric_model(type="open_meteo") + + def test_computes_derived_profiles(self, example_euroc_env): + """Compute density, speed of sound and viscosity from the new profiles.""" + example_euroc_env.set_atmospheric_model(type="open_meteo") + + # rho = p / (R * T) with R = 287.05 J/(kg K) + expected_density = 100_000.0 / (287.05 * 288.15) + assert example_euroc_env.density(100.0) == pytest.approx( + expected_density, rel=1e-2 + ) + assert example_euroc_env.speed_of_sound(100.0) == pytest.approx(340.3, rel=1e-2) + assert example_euroc_env.dynamic_viscosity(100.0) > 0 + + +class TestOpenMeteoEnsemble: + """Tests for the ``open_meteo_ensemble`` atmospheric model.""" + + @staticmethod + def _ensemble_response(num_members=3, offset=1.0): + """Builds a fake ensemble payload with a control run plus members.""" + suffixes = [""] + [f"_member{index + 1:02d}" for index in range(num_members)] + return _build_response( + hourly=_build_hourly(member_suffixes=suffixes, offset=offset) + ) + + def test_stores_every_member(self, example_euroc_env, monkeypatch): + """Expose the control run plus every perturbed member.""" + _patch_ensemble(monkeypatch, response=self._ensemble_response(num_members=3)) + + example_euroc_env.set_atmospheric_model(type="open_meteo_ensemble") + + # 3 perturbed members plus the unsuffixed control run. + assert example_euroc_env.num_ensemble_members == 4 + assert example_euroc_env.height_ensemble.shape == (4, 3) + assert example_euroc_env.temperature_ensemble.shape == (4, 3) + + def test_control_run_is_member_zero(self, example_euroc_env, monkeypatch): + """Activate the unperturbed control run by default. + + The documented convention is that member 0 is the control member, so the + unsuffixed series must come first. + """ + _patch_ensemble(monkeypatch, response=self._ensemble_response(offset=5.0)) + + example_euroc_env.set_atmospheric_model(type="open_meteo_ensemble") + + assert example_euroc_env.ensemble_member == 0 + # The control run keeps the unshifted temperature (15 degC -> 288.15 K). + assert example_euroc_env.temperature(100.0) == pytest.approx(288.15, rel=1e-3) + + def test_select_ensemble_member_switches_profiles( + self, example_euroc_env, monkeypatch + ): + """Switch the active profile when another member is selected.""" + _patch_ensemble(monkeypatch, response=self._ensemble_response(offset=5.0)) + + example_euroc_env.set_atmospheric_model(type="open_meteo_ensemble") + control_temperature = example_euroc_env.temperature(100.0) + + example_euroc_env.select_ensemble_member(2) + + assert example_euroc_env.ensemble_member == 2 + # Member 2 is shifted by 2 * 5 degC relative to the control run. + assert example_euroc_env.temperature(100.0) == pytest.approx( + control_temperature + 10.0, rel=1e-3 + ) + + def test_out_of_range_member_raises(self, example_euroc_env, monkeypatch): + """Reject a member index beyond the number of members available.""" + _patch_ensemble(monkeypatch, response=self._ensemble_response(num_members=3)) + + example_euroc_env.set_atmospheric_model(type="open_meteo_ensemble") + + with pytest.raises(ValueError, match="Please choose member from 0 to 3"): + example_euroc_env.select_ensemble_member(4) + + def test_levels_are_converted_to_pascal(self, example_euroc_env, monkeypatch): + """Store ensemble pressure levels in Pa, like the netCDF ensembles.""" + _patch_ensemble(monkeypatch, response=self._ensemble_response()) + + example_euroc_env.set_atmospheric_model(type="open_meteo_ensemble") + + assert example_euroc_env.level_ensemble == pytest.approx( + [100_000.0, 85_000.0, 50_000.0] + ) + + def test_payload_without_members_raises(self, example_euroc_env, monkeypatch): + """Fail clearly when the response carries no ensemble members at all.""" + _patch_ensemble(monkeypatch, response=_build_response()) + + with pytest.raises(ValueError, match="did not contain any ensemble members"): + example_euroc_env.set_atmospheric_model(type="open_meteo_ensemble") + + def test_forwards_model_to_fetcher(self, example_euroc_env, monkeypatch): + """Forward the requested ensemble model, defaulting to gfs05.""" + recorder = {} + _patch_ensemble( + monkeypatch, response=self._ensemble_response(), recorder=recorder + ) + + example_euroc_env.set_atmospheric_model( + type="open_meteo_ensemble", file="gem_global" + ) + + assert recorder["model"] == "gem_global" + + def test_missing_date_raises(self, example_plain_env, monkeypatch): + """Require a launch date for the ensemble model as well.""" + _patch_ensemble(monkeypatch, response=self._ensemble_response()) + + with pytest.raises(ValueError, match="specify the launch date"): + example_plain_env.set_atmospheric_model(type="open_meteo_ensemble") + + +class TestOpenMeteoFetchers: + """Tests for the Open-Meteo fetcher helpers (no network access).""" + + def test_build_hourly_variables_covers_every_level(self): + """Request all four variables for every pressure level.""" + hourly = open_meteo_fetcher.build_hourly_variables() + + variables = hourly.split(",") + expected_count = 4 * len(open_meteo_fetcher.OPEN_METEO_PRESSURE_LEVELS) + assert len(variables) == expected_count + assert "temperature_1000hPa" in variables + assert "wind_direction_500hPa" in variables + assert "geopotential_height_30hPa" in variables + + def test_rejects_ensemble_models_without_pressure_levels(self): + """Reject ensemble models that answer with nulls at every level. + + ``gfs025`` and ``icon_global`` return HTTP 200 but no pressure-level + data, so failing up front is far clearer than a later parsing error. + """ + with pytest.raises(ValueError, match="Invalid Open-Meteo ensemble model"): + open_meteo_fetcher.fetch_open_meteo_ensemble(0.0, 0.0, model="gfs025") + + @pytest.mark.parametrize( + ("delta", "expected"), + [ + (timedelta(days=-30), True), + (timedelta(days=-2), True), + (timedelta(hours=-1), False), + (timedelta(days=2), False), + ], + ) + def test_past_date_detection(self, delta, expected): + """Route only genuinely past dates to the historical archive. + + The forecast endpoint keeps a couple of past days available, so a launch + date a few hours ago must stay on the forecast endpoint. + """ + date = datetime.now(timezone.utc) + delta + + assert open_meteo_fetcher._is_past_date(date) is expected + + def test_naive_dates_are_treated_as_utc(self): + """Assume UTC for naive datetimes instead of raising.""" + naive_past = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=5) + + assert open_meteo_fetcher._is_past_date(naive_past) is True + + def test_no_date_uses_forecast_endpoint(self): + """Treat a missing date as a plain forecast request.""" + assert open_meteo_fetcher._is_past_date(None) is False + + def test_past_date_queries_historical_endpoint(self, monkeypatch): + """Send past launch dates to the historical-forecast API. + + Open-Meteo's ERA5 archive endpoint serves surface variables only, so the + historical-forecast API is the only archive that can feed a vertical + profile. + """ + recorder = {} + + def fake_request(url, params, endpoint): + recorder.update({"url": url, "params": params, "endpoint": endpoint}) + return {"hourly": {}} + + monkeypatch.setattr(open_meteo_fetcher, "_request", fake_request) + + open_meteo_fetcher.fetch_open_meteo_forecast( + 39.4, -8.3, date=datetime(2024, 1, 10, 12, tzinfo=timezone.utc) + ) + + assert recorder["url"] == open_meteo_fetcher.OPEN_METEO_HISTORICAL_URL + # A one-day pad on each side keeps the launch hour inside the window. + assert recorder["params"]["start_date"] == "2024-01-09" + assert recorder["params"]["end_date"] == "2024-01-11" + + def test_future_date_queries_forecast_endpoint(self, monkeypatch): + """Send future launch dates to the regular forecast API.""" + recorder = {} + + def fake_request(url, params, endpoint): + recorder.update({"url": url, "params": params}) + return {"hourly": {}} + + monkeypatch.setattr(open_meteo_fetcher, "_request", fake_request) + + open_meteo_fetcher.fetch_open_meteo_forecast( + 39.4, -8.3, date=datetime.now(timezone.utc) + timedelta(days=2) + ) + + assert recorder["url"] == open_meteo_fetcher.OPEN_METEO_FORECAST_URL + assert "start_date" not in recorder["params"] + + def test_requests_wind_in_metres_per_second(self, monkeypatch): + """Ask for m/s so no unit conversion is needed downstream. + + Open-Meteo defaults to km/h, which would silently inflate wind speeds by + 3.6x if the parameter were dropped. + """ + recorder = {} + + def fake_request(url, params, endpoint): + recorder.update(params) + return {"hourly": {}} + + monkeypatch.setattr(open_meteo_fetcher, "_request", fake_request) + + open_meteo_fetcher.fetch_open_meteo_forecast(39.4, -8.3) + + assert recorder["wind_speed_unit"] == "ms" + assert recorder["timeformat"] == "unixtime" + assert recorder["timezone"] == "UTC" + + def test_api_error_payload_raises_runtime_error(self, monkeypatch): + """Surface Open-Meteo's own error message instead of a bare status code.""" + + class FakeResponse: + ok = False + status_code = 400 + + @staticmethod + def json(): + return {"error": True, "reason": "Invalid time interval"} + + monkeypatch.setattr( + open_meteo_fetcher, "_get_json", lambda url, params: FakeResponse() + ) + + with pytest.raises(RuntimeError, match="Invalid time interval"): + open_meteo_fetcher.fetch_open_meteo_forecast(39.4, -8.3) + + def test_response_without_hourly_raises_runtime_error(self, monkeypatch): + """Fail clearly when the response carries no hourly block.""" + + class FakeResponse: + ok = True + status_code = 200 + + @staticmethod + def json(): + return {"latitude": 39.4, "longitude": -8.3} + + monkeypatch.setattr( + open_meteo_fetcher, "_get_json", lambda url, params: FakeResponse() + ) + + with pytest.raises(RuntimeError, match="did not contain any hourly data"): + open_meteo_fetcher.fetch_open_meteo_forecast(39.4, -8.3) + + +class TestOpenMeteoSerialization: + """Tests that Open-Meteo environments survive a to_dict/from_dict cycle.""" + + def test_forecast_round_trip(self, example_euroc_env): + """Preserve profiles and metadata for the deterministic model.""" + example_euroc_env.set_atmospheric_model(type="open_meteo") + + restored = Environment.from_dict(example_euroc_env.to_dict()) + + assert restored.atmospheric_model_type == "open_meteo" + assert restored.pressure(1000.0) == pytest.approx( + example_euroc_env.pressure(1000.0) + ) + assert restored.wind_direction(1000.0) == pytest.approx( + example_euroc_env.wind_direction(1000.0) + ) + assert restored.atmospheric_model_init_lat == pytest.approx( + example_euroc_env.atmospheric_model_init_lat + ) + + def test_ensemble_round_trip(self, example_euroc_env, monkeypatch): + """Preserve every member so selection still works after reloading.""" + _patch_ensemble( + monkeypatch, + response=TestOpenMeteoEnsemble._ensemble_response( + num_members=3, offset=5.0 + ), + ) + example_euroc_env.set_atmospheric_model(type="open_meteo_ensemble") + + restored = Environment.from_dict(example_euroc_env.to_dict()) + + assert restored.num_ensemble_members == 4 + restored.select_ensemble_member(2) + example_euroc_env.select_ensemble_member(2) + assert restored.temperature(100.0) == pytest.approx( + example_euroc_env.temperature(100.0) + ) + + +@pytest.fixture(autouse=True) +def _patch_forecast_fetcher_by_default(monkeypatch): + """Patches the forecast fetcher for every test in this module. + + Keeps the whole module offline: tests that need a custom payload patch the + fetcher again, which simply overrides this one. + """ + _patch_forecast(monkeypatch) From cb680c8d368ec62ad58cdb9be3def43ed4017b15 Mon Sep 17 00:00:00 2001 From: Gui-FernandesBR Date: Mon, 10 Aug 2026 00:04:47 -0300 Subject: [PATCH 4/7] FIX: drop gem_global from the usable Open-Meteo ensemble models Verifying the ensemble models against the live API showed gem_global cannot feed a RocketPy profile: it publishes temperature and geopotential height at pressure levels but no pressure-level winds at all (168/168 hours null for wind_speed and wind_direction at every level, at three different launch sites). The earlier check only probed temperature, which is why it looked usable. Accepting it meant "Open-Meteo returned fewer than two usable pressure levels" at profile-build time instead of an actionable message naming the model, so it is now rejected up front alongside gfs025, icon_global and bom_access_global_ensemble. gfs05 (31 members) and ecmwf_ifs025 (51 members) are the two that publish the full set; both member counts are confirmed against the API. Co-Authored-By: Claude Opus 5 (1M context) --- rocketpy/environment/environment.py | 20 ++++++----- .../fetchers/open_meteo_fetcher.py | 20 ++++++----- tests/unit/environment/test_open_meteo.py | 34 +++++++++++++++---- 3 files changed, 50 insertions(+), 24 deletions(-) diff --git a/rocketpy/environment/environment.py b/rocketpy/environment/environment.py index e9237f12c..53df1e6ad 100644 --- a/rocketpy/environment/environment.py +++ b/rocketpy/environment/environment.py @@ -1220,8 +1220,8 @@ def set_atmospheric_model( # pylint: disable=too-many-statements ``"best_match"`` (the default when omitted), ``"gfs_seamless"``, ``"ecmwf_ifs025"`` or ``"icon_seamless"``. See the Open-Meteo documentation for the full list. - - ``"open_meteo_ensemble"``: one of ``"gfs05"`` (the default when - omitted), ``"ecmwf_ifs025"`` or ``"gem_global"``. + - ``"open_meteo_ensemble"``: either ``"gfs05"`` (the default when + omitted) or ``"ecmwf_ifs025"``. - ``"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. @@ -1989,17 +1989,19 @@ def process_open_meteo_ensemble(self, model="gfs05"): Parameters ---------- model : str, optional - The Open-Meteo ensemble model to query. Default is ``"gfs05"`` (30 - members). Also available are ``"ecmwf_ifs025"`` (50 members) and - ``"gem_global"`` (20 members). These are the only Open-Meteo - ensemble models that publish pressure-level data. + The Open-Meteo ensemble model to query. Default is ``"gfs05"`` + (31 members, counting the control run). Also available is + ``"ecmwf_ifs025"`` (51 members). These are the only Open-Meteo + ensemble models that publish the complete set of pressure-level + variables RocketPy needs; the others either return nulls at every + level or omit the winds entirely. Raises ------ ValueError - If ``model`` does not publish pressure-level data, if no launch - date is set, or if the API returns fewer than two usable pressure - levels. + If ``model`` does not publish complete pressure-level data, if no + launch date is set, or if the API returns fewer than two usable + pressure levels. RuntimeError If the Open-Meteo API cannot be reached or returns no usable data. """ diff --git a/rocketpy/environment/fetchers/open_meteo_fetcher.py b/rocketpy/environment/fetchers/open_meteo_fetcher.py index d7ee9d432..95fa097b7 100644 --- a/rocketpy/environment/fetchers/open_meteo_fetcher.py +++ b/rocketpy/environment/fetchers/open_meteo_fetcher.py @@ -54,10 +54,13 @@ "wind_direction", ) -# Ensemble models known to publish pressure-level data. Other ensemble models -# (e.g. gfs025, icon_global) answer with HTTP 200 but null values at every -# level, which would otherwise surface as an opaque "no data" failure. -OPEN_METEO_ENSEMBLE_MODELS = ("gfs05", "ecmwf_ifs025", "gem_global") +# Ensemble models known to publish the full set of pressure-level variables +# RocketPy needs (temperature, geopotential height and both wind fields). The +# other ensemble models answer with HTTP 200 but are unusable: gfs025, +# icon_global and bom_access_global_ensemble return nulls at every level, while +# gem_global serves temperature and geopotential height but no winds at all. +# Rejecting them up front avoids an opaque "no data" failure later on. +OPEN_METEO_ENSEMBLE_MODELS = ("gfs05", "ecmwf_ifs025") # The historical-forecast archive starts in 2021; earlier dates return nulls at # every pressure level. Note that Open-Meteo's ERA5 archive endpoint @@ -240,7 +243,7 @@ def fetch_open_meteo_ensemble(latitude, longitude, model="gfs05", date=None): The longitude of the location, in degrees. model : str, optional The Open-Meteo ensemble model to query. Default is ``"gfs05"``. Only - the models in :data:`OPEN_METEO_ENSEMBLE_MODELS` publish + the models in :data:`OPEN_METEO_ENSEMBLE_MODELS` publish complete pressure-level data. date : datetime.datetime, optional The launch date and time. Past dates are queried against the @@ -254,15 +257,16 @@ def fetch_open_meteo_ensemble(latitude, longitude, model="gfs05", date=None): Raises ------ ValueError - If ``model`` is not known to publish pressure-level data. + If ``model`` is not known to publish complete pressure-level data. RuntimeError If the API cannot be reached or returns no usable data. """ if model not in OPEN_METEO_ENSEMBLE_MODELS: raise ValueError( f"Invalid Open-Meteo ensemble model '{model}'. Only " - f"{', '.join(OPEN_METEO_ENSEMBLE_MODELS)} publish pressure-level " - "data, which RocketPy requires to build an atmospheric profile." + f"{' and '.join(OPEN_METEO_ENSEMBLE_MODELS)} publish the complete " + "set of pressure-level variables (temperature, geopotential height " + "and winds) that RocketPy requires to build an atmospheric profile." ) params = { diff --git a/tests/unit/environment/test_open_meteo.py b/tests/unit/environment/test_open_meteo.py index d8aaca8a5..37ede53eb 100644 --- a/tests/unit/environment/test_open_meteo.py +++ b/tests/unit/environment/test_open_meteo.py @@ -428,10 +428,10 @@ def test_forwards_model_to_fetcher(self, example_euroc_env, monkeypatch): ) example_euroc_env.set_atmospheric_model( - type="open_meteo_ensemble", file="gem_global" + type="open_meteo_ensemble", file="ecmwf_ifs025" ) - assert recorder["model"] == "gem_global" + assert recorder["model"] == "ecmwf_ifs025" def test_missing_date_raises(self, example_plain_env, monkeypatch): """Require a launch date for the ensemble model as well.""" @@ -455,14 +455,34 @@ def test_build_hourly_variables_covers_every_level(self): assert "wind_direction_500hPa" in variables assert "geopotential_height_30hPa" in variables - def test_rejects_ensemble_models_without_pressure_levels(self): - """Reject ensemble models that answer with nulls at every level. + @pytest.mark.parametrize( + "model", + [ + "gfs025", # HTTP 200 but nulls at every pressure level + "icon_global", # same + "bom_access_global_ensemble", # same + "gem_global", # temperature and heights, but no winds at all + ], + ) + def test_rejects_ensemble_models_without_complete_data(self, model): + """Reject ensemble models that cannot produce a full profile. - ``gfs025`` and ``icon_global`` return HTTP 200 but no pressure-level - data, so failing up front is far clearer than a later parsing error. + These models all answer with HTTP 200, so without an up-front check the + failure would only surface as an opaque parsing error much later. Note + that ``gem_global`` is the subtle one: it publishes temperature and + geopotential height but no pressure-level winds. """ with pytest.raises(ValueError, match="Invalid Open-Meteo ensemble model"): - open_meteo_fetcher.fetch_open_meteo_ensemble(0.0, 0.0, model="gfs025") + open_meteo_fetcher.fetch_open_meteo_ensemble(0.0, 0.0, model=model) + + def test_accepts_the_supported_ensemble_models(self, monkeypatch): + """Accept the two ensemble models that do publish complete data.""" + monkeypatch.setattr( + open_meteo_fetcher, "_request", lambda url, params, endpoint: {"hourly": {}} + ) + + for model in ("gfs05", "ecmwf_ifs025"): + open_meteo_fetcher.fetch_open_meteo_ensemble(0.0, 0.0, model=model) @pytest.mark.parametrize( ("delta", "expected"), From adda44be52b68503b1efeff743ca2ab0e940b207 Mon Sep 17 00:00:00 2001 From: Gui-FernandesBR Date: Mon, 10 Aug 2026 00:04:58 -0300 Subject: [PATCH 5/7] DOC: document the Open-Meteo atmospheric models Adds docs/user/environment/1-atm-models/open_meteo.rst, covering the forecast, past-launch and ensemble workflows, the model tables, and the caveats worth knowing: coverage of pressure levels varies per model, the historical archive only reaches back to 2021, and Open-Meteo's ERA5 endpoint cannot be used because it serves no pressure-level data. Cross-references were added from the forecast, reanalysis and ensemble pages, since Open-Meteo is the lighter alternative in each of those cases -- notably for ensembles, where the GEFS shortcut is currently unavailable. Every code block in the new page runs as part of the docs build; all five were executed against the live API to confirm they work. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 2 + .../environment/1-atm-models/ensemble.rst | 7 + .../environment/1-atm-models/forecast.rst | 7 + docs/user/environment/1-atm-models/index.rst | 1 + .../environment/1-atm-models/open_meteo.rst | 217 ++++++++++++++++++ .../environment/1-atm-models/reanalysis.rst | 7 + 6 files changed, 241 insertions(+) create mode 100644 docs/user/environment/1-atm-models/open_meteo.rst diff --git a/CHANGELOG.md b/CHANGELOG.md index 38c9028b4..11c041042 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ Attention: The newest changes should be on top --> ### Added +- ENH: Support for Open Meteo API in the `Environment` class, adding the `open_meteo` and `open_meteo_ensemble` atmospheric models. Pressure-level forecasts, past forecasts (from 2021 onwards) and ensembles are read straight from a keyless JSON API, with no external files and no netCDF/OPeNDAP dependency. [#520](https://github.com/RocketPy-Team/RocketPy/issues/520) - ENH: Add simplified opening shock force estimation [#1092](https://github.com/RocketPy-Team/RocketPy/pull/1092) - ENH: Add Qodo PR-Agent workflow using Google Gemini [#1089](https://github.com/RocketPy-Team/RocketPy/pull/1089) - ENH: Support for Meteomatics API in the `Environment` class [#1079](https://github.com/RocketPy-Team/RocketPy/pull/1079) @@ -46,6 +47,7 @@ Attention: The newest changes should be on top --> ### Fixed +- BUG: Report the atmospheric model time period and ensemble member count for lower-case model types. `set_atmospheric_model` documents `type` as case-insensitive, but `Environment.info()` and `all_info()` compared against capitalised literals, so `type="ensemble"` printed no time period and no member count, and skipped the ensemble comparison plot. [#520](https://github.com/RocketPy-Team/RocketPy/issues/520) - BUG: Give each `CustomSampler` input its own deterministic stream, and seed samplers sharing one generator once as a group. Existing fixed-seed `CustomSampler` baselines change, and samplers built on the legacy `RandomState` must move to `default_rng` because seeds now carry the full 128 bits. [#1102](https://github.com/RocketPy-Team/RocketPy/pull/1102) - BUG: Accept the callable parachute triggers `StochasticParachute` documents, and reject the ones it cannot mean. An invalid string, an empty list or a boolean now fails during validation instead of reaching `Parachute` or becoming a one-metre height trigger. [#1103](https://github.com/RocketPy-Team/RocketPy/pull/1103) - BUG: rocket with a late-starting thrust curve never leaves the rail [#1085](https://github.com/RocketPy-Team/RocketPy/pull/1085) diff --git a/docs/user/environment/1-atm-models/ensemble.rst b/docs/user/environment/1-atm-models/ensemble.rst index 8dffaac00..a2c75b118 100644 --- a/docs/user/environment/1-atm-models/ensemble.rst +++ b/docs/user/environment/1-atm-models/ensemble.rst @@ -34,6 +34,13 @@ Global Ensemble Forecast System (GEFS) provider (or a local copy), you can still load it explicitly by passing the dataset path/URL in ``file`` and a compatible mapping in ``dictionary``. +.. tip:: + + While the ``GEFS`` shortcut is unavailable, Open-Meteo offers the same GEFS + ensemble (plus the ECMWF one) over a plain JSON API, and works with + :meth:`rocketpy.Environment.select_ensemble_member` in exactly the same way. + See :ref:`open_meteo`. + The ``GEFS`` model is a global ensemble forecast system useful for uncertainty analysis, but RocketPy's automatic ``file="GEFS"`` shortcut is temporarily diff --git a/docs/user/environment/1-atm-models/forecast.rst b/docs/user/environment/1-atm-models/forecast.rst index ea81c356a..347fed281 100644 --- a/docs/user/environment/1-atm-models/forecast.rst +++ b/docs/user/environment/1-atm-models/forecast.rst @@ -16,6 +16,13 @@ Other generic forecasts can also be imported. If you want to simulate your rocket launch using past data, you should use \ :ref:`reanalysis` or :ref:`soundings`. +.. tip:: + + The models on this page are fetched over OPeNDAP, which requires the + ``netCDF4`` library and can be slow. For a lighter alternative that serves + the same kind of pressure-level forecast as plain JSON, see + :ref:`open_meteo`. + .. _global-forecast-system: diff --git a/docs/user/environment/1-atm-models/index.rst b/docs/user/environment/1-atm-models/index.rst index ba9940585..12214e8c3 100644 --- a/docs/user/environment/1-atm-models/index.rst +++ b/docs/user/environment/1-atm-models/index.rst @@ -12,6 +12,7 @@ environment in the :class:`rocketpy.Environment` class. Standard Atmosphere Custom Atmosphere + Open-Meteo Forecasts Soundings Reanalysis diff --git a/docs/user/environment/1-atm-models/open_meteo.rst b/docs/user/environment/1-atm-models/open_meteo.rst new file mode 100644 index 000000000..87bcc0dd0 --- /dev/null +++ b/docs/user/environment/1-atm-models/open_meteo.rst @@ -0,0 +1,217 @@ +.. _open_meteo: + +Open-Meteo +========== + +`Open-Meteo `_ is a weather API that serves +pressure-level forecasts, past forecasts and ensemble forecasts as plain JSON +over HTTPS. + +It is often the most convenient weather source in RocketPy, because: + +- **No API key** is required for non-commercial use. +- **No heavy dependencies**: unlike the :ref:`forecast` and :ref:`reanalysis` + models, no ``netCDF4``/OPeNDAP download is involved, so requests are quick. +- **No external files**: past launches can be reconstructed straight from the + API, without downloading reanalysis files by hand. +- **Many models in one place**: GFS, ECMWF, ICON, MET Norway, Météo-France, JMA, + GEM and UKMO are all reachable through the same interface. + +.. note:: + + Open-Meteo is free for non-commercial use, with a limit on the number of + daily requests. Please read + `their terms `_ before using it, and + consider their paid plans for heavier or commercial workloads. + + +Forecasts +--------- + +Set the atmospheric model to ``open_meteo``. The launch date must be set, +because the vertical profile is taken at the hour closest to it. + +.. jupyter-execute:: + + from datetime import datetime, timedelta + from rocketpy import Environment + + tomorrow = datetime.now() + timedelta(days=1) + + env = Environment( + date=tomorrow, + latitude=39.3897, + longitude=-8.28896388889, + ) + + env.set_atmospheric_model(type="open_meteo") + + env.plots.atmospheric_model() + +Note that ``elevation`` was never specified above: Open-Meteo reports the +elevation of the grid cell it answered for, and RocketPy uses it to set the +launch site elevation automatically. + + +Selecting a weather model +^^^^^^^^^^^^^^^^^^^^^^^^^ + +By default RocketPy asks for ``"best_match"``, which lets Open-Meteo pick the +highest-resolution model available for the requested location. A specific model +can be requested through the ``file`` argument: + +.. jupyter-execute:: + + env_ecmwf = Environment( + date=tomorrow, + latitude=39.3897, + longitude=-8.28896388889, + ) + env_ecmwf.set_atmospheric_model(type="open_meteo", file="ecmwf_ifs025") + env_ecmwf.plots.atmospheric_model() + +Frequently useful models are: + +.. list-table:: + :header-rows: 1 + :widths: 30 70 + + * - Model + - Description + * - ``best_match`` + - Open-Meteo picks the best available model for the location (default). + * - ``gfs_seamless`` + - NOAA GFS, global coverage. + * - ``ecmwf_ifs025`` + - ECMWF IFS at 0.25°, global coverage. + * - ``icon_seamless`` + - DWD ICON, global coverage with a higher-resolution European nest. + * - ``meteofrance_seamless`` + - Météo-France ARPEGE/AROME. + * - ``gem_seamless`` + - Environment Canada GEM. + * - ``ukmo_seamless`` + - UK Met Office. + +.. seealso:: + + The `Open-Meteo documentation `_ lists every + model available, along with its resolution and update frequency. + +.. important:: + + Not every model resolves every pressure level, and coverage varies with + location. RocketPy silently drops the levels a model does not provide, so + the resulting profile may reach a lower altitude for some models (for + instance, ``ecmwf_ifs025`` tops out at 50 hPa while ``gfs_seamless`` + reaches 30 hPa). Check ``env.max_expected_height`` if the ceiling matters + for your simulation. + + +Past launches +------------- + +When the launch date is in the past, ``open_meteo`` transparently queries +Open-Meteo's historical-forecast archive instead of the live forecast. No extra +argument is needed: + +.. jupyter-execute:: + + env_past = Environment( + date=datetime(2024, 1, 10, 12), + latitude=39.3897, + longitude=-8.28896388889, + ) + env_past.set_atmospheric_model(type="open_meteo") + env_past.plots.atmospheric_model() + +This is the quickest way to reconstruct the atmosphere of a past flight, since +it needs neither an external file nor a sounding station nearby. For +comparison, see :ref:`reanalysis` and :ref:`soundings`. + +.. important:: + + Open-Meteo's historical data is built from its own archived forecast runs + and is available **from 2021 onwards**. For earlier dates, use + :ref:`reanalysis` or :ref:`soundings` instead. + +.. note:: + + Open-Meteo also offers an ERA5 archive endpoint, but it serves surface + variables only and provides no pressure-level data, so RocketPy does not + use it: it cannot produce a vertical profile. + + +Ensemble forecasts +------------------ + +Open-Meteo also exposes ensemble forecasts, where each member represents a +slightly different evolution of the atmosphere. They are used exactly like the +other :ref:`ensemble_atmosphere` models: + +.. jupyter-execute:: + + env_ensemble = Environment( + date=tomorrow, + latitude=39.3897, + longitude=-8.28896388889, + ) + env_ensemble.set_atmospheric_model(type="open_meteo_ensemble", file="gfs05") + + print(f"Number of members: {env_ensemble.num_ensemble_members}") + + env_ensemble.plots.ensemble_member_comparison() + +Individual members are activated with +:meth:`rocketpy.Environment.select_ensemble_member`: + +.. jupyter-execute:: + + env_ensemble.select_ensemble_member(10) + print(f"Wind speed at 1 km: {env_ensemble.wind_speed(1000):.2f} m/s") + +Member ``0`` is the unperturbed control run and is the one selected by default. + +Two ensemble models publish the complete set of pressure-level variables that +RocketPy needs: + +.. list-table:: + :header-rows: 1 + :widths: 30 20 50 + + * - Model + - Members + - Description + * - ``gfs05`` + - 31 + - NOAA GEFS at 0.5° (default). + * - ``ecmwf_ifs025`` + - 51 + - ECMWF ensemble at 0.25°. + +The member counts above include the control run, which RocketPy exposes as +member ``0``. + +.. important:: + + The remaining Open-Meteo ensemble models cannot be used to build a vertical + profile, so RocketPy rejects them with an explanatory error rather than + failing later on. ``gfs025``, ``icon_global`` and + ``bom_access_global_ensemble`` answer successfully but return no + pressure-level values at all, and ``gem_global`` provides temperature and + geopotential height but no pressure-level winds. + + +Further considerations +---------------------- + +Requests may fail if the API is unreachable or if the daily free-tier limit is +exceeded. RocketPy retries transient failures automatically and raises a +``RuntimeError`` with the reason reported by Open-Meteo when the request cannot +be satisfied. + +.. seealso:: + + - :ref:`forecast` for OPeNDAP-based forecasts (GFS, NAM, RAP, HRRR). + - :ref:`reanalysis` for ERA5 and MERRA-2 reanalysis files. + - :ref:`ensemble_atmosphere` for the ensemble workflow in general. diff --git a/docs/user/environment/1-atm-models/reanalysis.rst b/docs/user/environment/1-atm-models/reanalysis.rst index c24bec458..3e441ea5a 100644 --- a/docs/user/environment/1-atm-models/reanalysis.rst +++ b/docs/user/environment/1-atm-models/reanalysis.rst @@ -13,6 +13,13 @@ intervals Reanalysis data can be used to set up the environment in RocketPy. One common reanalysis dataset is the ERA5. +.. tip:: + + Reanalysis datasets must be downloaded as files before RocketPy can read + them. If you only need the atmospheric conditions of a past launch from + 2021 onwards, :ref:`open_meteo` retrieves them straight from an API, with + no file to download. + ERA5 ---- From 2b70008ba5bdd80b48aecca1c7942480d16b1f61 Mon Sep 17 00:00:00 2001 From: Gui-FernandesBR Date: Mon, 10 Aug 2026 00:13:33 -0300 Subject: [PATCH 6/7] ENH: warn when a launch date predates the Open-Meteo archive Open-Meteo answers historical requests for unsupported dates with HTTP 200 and null values at every pressure level, so a pre-archive launch date used to surface only as a generic "fewer than two usable pressure levels" error, with no hint that the date itself was the problem. Such dates now raise a warning naming the archive start and pointing at the reanalysis and sounding models. The cutoff was probed against the live API rather than assumed: 2021-03-15 comes back empty while 2021-03-23 is complete, so the archive starts in March 2021 and not in January as the previous constant implied. The constant is now a date instead of a year, and the docs state March 2021. Co-Authored-By: Claude Opus 5 (1M context) --- .../environment/1-atm-models/open_meteo.rst | 9 ++-- rocketpy/environment/environment.py | 6 ++- rocketpy/environment/fetchers/__init__.py | 4 +- .../fetchers/open_meteo_fetcher.py | 48 +++++++++++++++---- tests/unit/environment/test_open_meteo.py | 28 +++++++++++ 5 files changed, 79 insertions(+), 16 deletions(-) diff --git a/docs/user/environment/1-atm-models/open_meteo.rst b/docs/user/environment/1-atm-models/open_meteo.rst index 87bcc0dd0..3064d842c 100644 --- a/docs/user/environment/1-atm-models/open_meteo.rst +++ b/docs/user/environment/1-atm-models/open_meteo.rst @@ -12,8 +12,8 @@ It is often the most convenient weather source in RocketPy, because: - **No API key** is required for non-commercial use. - **No heavy dependencies**: unlike the :ref:`forecast` and :ref:`reanalysis` models, no ``netCDF4``/OPeNDAP download is involved, so requests are quick. -- **No external files**: past launches can be reconstructed straight from the - API, without downloading reanalysis files by hand. +- **No external files**: recent past launches can be reconstructed straight from + the API, without downloading reanalysis files by hand. - **Many models in one place**: GFS, ECMWF, ICON, MET Norway, Météo-France, JMA, GEM and UKMO are all reachable through the same interface. @@ -132,8 +132,9 @@ comparison, see :ref:`reanalysis` and :ref:`soundings`. .. important:: Open-Meteo's historical data is built from its own archived forecast runs - and is available **from 2021 onwards**. For earlier dates, use - :ref:`reanalysis` or :ref:`soundings` instead. + and only covers pressure levels **from around March 2021 onwards**. Earlier + dates return no data, and RocketPy warns you when it detects one; use + :ref:`reanalysis` or :ref:`soundings` for those instead. .. note:: diff --git a/rocketpy/environment/environment.py b/rocketpy/environment/environment.py index 53df1e6ad..20a9278ce 100644 --- a/rocketpy/environment/environment.py +++ b/rocketpy/environment/environment.py @@ -1918,8 +1918,10 @@ def process_open_meteo_atmosphere(self, model="best_match"): Notes ----- Open-Meteo's historical data comes from its own past forecast runs and - is available from 2021 onwards. Its ERA5 archive endpoint is not used - because it serves surface variables only, with no pressure-level data. + only covers pressure levels from around March 2021 onwards; a warning is + issued for earlier dates, which the API answers with no data. Its ERA5 + archive endpoint is not used because it serves surface variables only, + with no pressure-level data. """ self.__validate_datetime() diff --git a/rocketpy/environment/fetchers/__init__.py b/rocketpy/environment/fetchers/__init__.py index fb5707c07..d74ea55a9 100644 --- a/rocketpy/environment/fetchers/__init__.py +++ b/rocketpy/environment/fetchers/__init__.py @@ -24,7 +24,7 @@ OPEN_METEO_ENSEMBLE_MODELS, OPEN_METEO_ENSEMBLE_URL, OPEN_METEO_FORECAST_URL, - OPEN_METEO_HISTORICAL_START_YEAR, + OPEN_METEO_HISTORICAL_START_DATE, OPEN_METEO_HISTORICAL_URL, OPEN_METEO_PRESSURE_LEVELS, OPEN_METEO_TIMEOUT_SECONDS, @@ -57,7 +57,7 @@ "OPEN_METEO_ENSEMBLE_MODELS", "OPEN_METEO_ENSEMBLE_URL", "OPEN_METEO_FORECAST_URL", - "OPEN_METEO_HISTORICAL_START_YEAR", + "OPEN_METEO_HISTORICAL_START_DATE", "OPEN_METEO_HISTORICAL_URL", "OPEN_METEO_PRESSURE_LEVELS", "OPEN_METEO_TIMEOUT_SECONDS", diff --git a/rocketpy/environment/fetchers/open_meteo_fetcher.py b/rocketpy/environment/fetchers/open_meteo_fetcher.py index 95fa097b7..52cd4ea42 100644 --- a/rocketpy/environment/fetchers/open_meteo_fetcher.py +++ b/rocketpy/environment/fetchers/open_meteo_fetcher.py @@ -6,6 +6,7 @@ RocketPy needs and returns their raw JSON bodies. """ +import warnings from datetime import datetime, timedelta, timezone import requests @@ -62,11 +63,16 @@ # Rejecting them up front avoids an opaque "no data" failure later on. OPEN_METEO_ENSEMBLE_MODELS = ("gfs05", "ecmwf_ifs025") -# The historical-forecast archive starts in 2021; earlier dates return nulls at -# every pressure level. Note that Open-Meteo's ERA5 archive endpoint -# (archive-api.open-meteo.com) is *not* used here because it serves surface -# variables only, with no pressure-level data at all. -OPEN_METEO_HISTORICAL_START_YEAR = 2021 +# Earliest date the historical-forecast archive covers at pressure levels. +# Earlier dates still answer with HTTP 200, but every value is null, so warning +# is the only way for the user to tell an unsupported date from bad weather +# data. Probed against the live API: 2021-03-15 comes back empty while +# 2021-03-23 is complete, so the cutoff sits between them. +# +# Note that Open-Meteo's ERA5 archive endpoint (archive-api.open-meteo.com) is +# *not* used here: it serves surface variables only, with no pressure-level +# data at all, so it cannot produce a vertical profile. +OPEN_METEO_HISTORICAL_START_DATE = datetime(2021, 4, 1, tzinfo=timezone.utc) def build_hourly_variables(levels=OPEN_METEO_PRESSURE_LEVELS): @@ -217,6 +223,7 @@ def fetch_open_meteo_forecast(latitude, longitude, model="best_match", date=None } if _is_past_date(date): + _warn_if_before_archive_start(date) # The archive is indexed by calendar day, so a one-day pad on each side # guarantees the launch hour is inside the returned range regardless of # the local-time offset. @@ -296,9 +303,34 @@ def _is_past_date(date): """ if date is None: return False - reference = date if date.tzinfo is not None else date.replace(tzinfo=timezone.utc) - now = _utc_now() - return reference < now - timedelta(days=1) + return _as_utc(date) < _utc_now() - timedelta(days=1) + + +def _as_utc(date): + """Returns ``date`` as an aware UTC datetime, assuming UTC when naive.""" + return date if date.tzinfo is not None else date.replace(tzinfo=timezone.utc) + + +def _warn_if_before_archive_start(date): + """Warns when ``date`` precedes the historical archive coverage. + + Open-Meteo answers such requests with HTTP 200 and null values at every + pressure level, so without this warning the user would only see a generic + "not enough usable pressure levels" error and no hint that the date itself + is the problem. + """ + if _as_utc(date) >= OPEN_METEO_HISTORICAL_START_DATE: + return + + warnings.warn( + f"The requested launch date ({date:%Y-%m-%d}) precedes Open-Meteo's " + "historical-forecast archive, which starts around " + f"{OPEN_METEO_HISTORICAL_START_DATE:%B %Y}. The API will most likely " + "return no pressure-level data for it. Consider using the 'reanalysis' " + "or 'wyoming_sounding' atmospheric models for earlier dates.", + UserWarning, + stacklevel=3, + ) def _utc_now(): diff --git a/tests/unit/environment/test_open_meteo.py b/tests/unit/environment/test_open_meteo.py index 37ede53eb..72f90ec90 100644 --- a/tests/unit/environment/test_open_meteo.py +++ b/tests/unit/environment/test_open_meteo.py @@ -537,6 +537,34 @@ def fake_request(url, params, endpoint): assert recorder["params"]["start_date"] == "2024-01-09" assert recorder["params"]["end_date"] == "2024-01-11" + def test_warns_for_dates_before_the_archive_starts(self, monkeypatch): + """Warn when the launch date predates Open-Meteo's archive. + + Such requests answer with HTTP 200 and nulls at every level, so without + a warning the user would only see a generic "not enough pressure levels" + error with no hint that the date is the problem. + """ + monkeypatch.setattr( + open_meteo_fetcher, "_request", lambda url, params, endpoint: {"hourly": {}} + ) + + with pytest.warns(UserWarning, match="precedes Open-Meteo's"): + open_meteo_fetcher.fetch_open_meteo_forecast( + 39.4, -8.3, date=datetime(2019, 6, 15, tzinfo=timezone.utc) + ) + + def test_does_not_warn_for_supported_past_dates(self, monkeypatch, recwarn): + """Stay silent for past dates the archive does cover.""" + monkeypatch.setattr( + open_meteo_fetcher, "_request", lambda url, params, endpoint: {"hourly": {}} + ) + + open_meteo_fetcher.fetch_open_meteo_forecast( + 39.4, -8.3, date=datetime(2024, 1, 10, tzinfo=timezone.utc) + ) + + assert not [w for w in recwarn if "precedes Open-Meteo" in str(w.message)] + def test_future_date_queries_forecast_endpoint(self, monkeypatch): """Send future launch dates to the regular forecast API.""" recorder = {} From c1bd678532f324f4f44c33d75cc4f01b4e74eeaa Mon Sep 17 00:00:00 2001 From: Gui-FernandesBR Date: Mon, 10 Aug 2026 12:16:01 -0300 Subject: [PATCH 7/7] MNT: satisfy pylint on the Open-Meteo code paths The CI lint job runs pylint, not just ruff, and flagged the new code: - process_open_meteo_atmosphere and process_open_meteo_ensemble exceeded the statement limit. Rather than suppress it, the profile-storing and member-stacking blocks were extracted into helpers, mirroring the existing _store_meteomatics_* pattern. The two processors now read as a sequence of named steps. - set_atmospheric_model exceeded the branch limit, since the two new model cases added to an already long match. The self-contained pressure_conversion_factor validation moved to a private validator next to the other validators, which also flattens its nested ifs. - Unused-argument and missing-docstring warnings in the new tests, from fakes that deliberately accept the real signature. Behaviour is unchanged: the pressure_conversion_factor error messages and the Open-Meteo profiles were re-verified against the original, and the full unit suite still passes (1954 passed, 16 skipped). Co-Authored-By: Claude Opus 5 (1M context) --- rocketpy/environment/environment.py | 249 +++++++++++++++------- tests/unit/environment/test_open_meteo.py | 10 +- 2 files changed, 182 insertions(+), 77 deletions(-) diff --git a/rocketpy/environment/environment.py b/rocketpy/environment/environment.py index 20a9278ce..edf3a342c 100644 --- a/rocketpy/environment/environment.py +++ b/rocketpy/environment/environment.py @@ -729,6 +729,41 @@ def __validate_dictionary(self, file, dictionary): return dictionary + @staticmethod + def __validate_pressure_conversion_factor(pressure_conversion_factor): + """Validates a user-supplied pressure conversion factor. + + Does nothing when the value is None, in which case the factor is + auto-detected later from the dataset or the model name. + + Raises + ------ + ValueError + If the value is neither a strictly positive number nor a standard + pressure unit ('mbar', 'hPa', 'Pa'). + """ + if pressure_conversion_factor is None: + return + + if not isinstance(pressure_conversion_factor, (float, int, str)): + raise ValueError( + "Argument 'pressure_conversion_factor' must be numeric or a standard pressure unit ('mbar', 'hPa', 'Pa')!" + ) + if ( + isinstance(pressure_conversion_factor, (float, int)) + and pressure_conversion_factor <= 0 + ): + raise ValueError( + "Argument 'pressure_conversion_factor' must be strictly positive!" + ) + if ( + isinstance(pressure_conversion_factor, str) + and pressure_unit_to_factor(pressure_conversion_factor) is None + ): + raise ValueError( + "Argument 'pressure_conversion_factor' unit must be a standard pressure unit ('mbar', 'hPa', 'Pa')!" + ) + def __validate_datetime(self): if self.datetime_date is None: raise ValueError( @@ -1388,21 +1423,7 @@ def set_atmospheric_model( # pylint: disable=too-many-statements # Validate format of user-supplied value (if any). # When None, auto-detection runs after dictionary resolution. - if pressure_conversion_factor is not None: - if not isinstance(pressure_conversion_factor, (float, int, str)): - raise ValueError( - "Argument 'pressure_conversion_factor' must be numeric or a standard pressure unit ('mbar', 'hPa', 'Pa')!" - ) - if isinstance(pressure_conversion_factor, (float, int)): - if pressure_conversion_factor <= 0: - raise ValueError( - "Argument 'pressure_conversion_factor' must be strictly positive!" - ) - if isinstance(pressure_conversion_factor, str): - if pressure_unit_to_factor(pressure_conversion_factor) is None: - raise ValueError( - "Argument 'pressure_conversion_factor' unit must be a standard pressure unit ('mbar', 'hPa', 'Pa')!" - ) + self.__validate_pressure_conversion_factor(pressure_conversion_factor) if isinstance(file, str): shortcut_map = self.__atm_type_file_to_function_map.get(type, {}) @@ -1859,6 +1880,50 @@ def __parse_open_meteo_levels(self, hourly, time_index, member_suffix=""): wind_v[order], ) + def __store_open_meteo_functions( + self, pressure_levels, altitude_array, temperature_array, wind_u, wind_v + ): + """Sets the atmospheric functions from a single Open-Meteo profile. + + Parameters + ---------- + pressure_levels : numpy.ndarray + The pressure levels, in hPa. + altitude_array : numpy.ndarray + Geometric altitudes above sea level, in m. + temperature_array : numpy.ndarray + Temperatures, in K. + wind_u, wind_v : numpy.ndarray + The East and North wind components, in m/s. + """ + wind_speed_array = calculate_wind_speed(wind_u, wind_v) + wind_heading_array = calculate_wind_heading(wind_u, wind_v) + wind_direction_array = convert_wind_heading_to_direction(wind_heading_array) + + data_array = mask_and_clean_dataset( + 100 * pressure_levels, # Convert hPa to Pa + altitude_array, + temperature_array, + wind_u, + wind_v, + wind_heading_array, + wind_direction_array, + wind_speed_array, + ) + + # Save atmospheric data + self.__set_pressure_function(data_array[:, (1, 0)]) + self.__set_barometric_height_function(data_array[:, (0, 1)]) + self.__set_temperature_function(data_array[:, (1, 2)]) + self.__set_wind_velocity_x_function(data_array[:, (1, 3)]) + self.__set_wind_velocity_y_function(data_array[:, (1, 4)]) + self.__set_wind_heading_function(data_array[:, (1, 5)]) + self.__set_wind_direction_function(data_array[:, (1, 6)]) + self.__set_wind_speed_function(data_array[:, (1, 7)]) + + # Save maximum expected height + self._max_expected_height = float(max(altitude_array[0], altitude_array[-1])) + def __find_open_meteo_time_index(self, hourly): """Returns the index of the hour closest to the launch date.""" # 'timeformat=unixtime' is requested, so times are seconds since epoch. @@ -1943,34 +2008,14 @@ def process_open_meteo_atmosphere(self, model="best_match"): geopotential_height_array, self.earth_radius ) - wind_speed_array = calculate_wind_speed(wind_u_array, wind_v_array) - wind_heading_array = calculate_wind_heading(wind_u_array, wind_v_array) - wind_direction_array = convert_wind_heading_to_direction(wind_heading_array) - - data_array = mask_and_clean_dataset( - 100 * pressure_levels, # Convert hPa to Pa + self.__store_open_meteo_functions( + pressure_levels, altitude_array, temperature_array, wind_u_array, wind_v_array, - wind_heading_array, - wind_direction_array, - wind_speed_array, ) - # Save atmospheric data - self.__set_pressure_function(data_array[:, (1, 0)]) - self.__set_barometric_height_function(data_array[:, (0, 1)]) - self.__set_temperature_function(data_array[:, (1, 2)]) - self.__set_wind_velocity_x_function(data_array[:, (1, 3)]) - self.__set_wind_velocity_y_function(data_array[:, (1, 4)]) - self.__set_wind_heading_function(data_array[:, (1, 5)]) - self.__set_wind_direction_function(data_array[:, (1, 6)]) - self.__set_wind_speed_function(data_array[:, (1, 7)]) - - # Save maximum expected height - self._max_expected_height = float(max(altitude_array[0], altitude_array[-1])) - self.__store_open_meteo_metadata(response, time_array) # Save debugging data @@ -1981,6 +2026,65 @@ def process_open_meteo_atmosphere(self, model="best_match"): self.temperatures = temperature_array self.height = altitude_array + def __stack_open_meteo_members(self, hourly, time_index, member_suffixes): + """Stacks each ensemble member's profile into regular 2D arrays. + + Members may resolve a different number of pressure levels, so every + profile is truncated to the shortest one; otherwise the stacked arrays + would be ragged and could not be indexed by member. + + Parameters + ---------- + hourly : dict + The ``hourly`` section of the Open-Meteo JSON response. + time_index : int + Index of the hour to extract. + member_suffixes : list of str + Member suffixes to stack, in the order they should be exposed. + + Returns + ------- + tuple + The pressure levels (hPa) plus the geometric heights, temperatures + and wind components, each as an array of shape + ``(members, levels)``. + """ + levels = None + heights = [] + temperatures = [] + wind_us = [] + wind_vs = [] + + for suffix in member_suffixes: + ( + member_levels, + geopotential_heights, + member_temperatures, + member_wind_u, + member_wind_v, + ) = self.__parse_open_meteo_levels(hourly, time_index, suffix) + + if levels is None or len(member_levels) < len(levels): + levels = member_levels + heights.append( + geopotential_height_to_geometric_height( + geopotential_heights, self.earth_radius + ) + ) + temperatures.append(member_temperatures) + wind_us.append(member_wind_u) + wind_vs.append(member_wind_v) + + profile_length = min(len(levels), *(len(h) for h in heights)) + + return ( + levels[:profile_length], + np.array([h[:profile_length] for h in heights]), + np.array([t[:profile_length] for t in temperatures]), + np.array([u[:profile_length] for u in wind_us]), + np.array([v[:profile_length] for v in wind_vs]), + ) + def process_open_meteo_ensemble(self, model="gfs05"): """Process ensemble forecast data from the Open-Meteo API. @@ -2017,41 +2121,43 @@ def process_open_meteo_ensemble(self, model="gfs05"): member_suffixes = self.__find_open_meteo_members(hourly) - levels = None - heights = [] - temperatures = [] - wind_us = [] - wind_vs = [] + ( + levels, + height, + temperature, + wind_u, + wind_v, + ) = self.__stack_open_meteo_members(hourly, time_index, member_suffixes) - for suffix in member_suffixes: - ( - member_levels, - geopotential_heights, - member_temperatures, - member_wind_u, - member_wind_v, - ) = self.__parse_open_meteo_levels(hourly, time_index, suffix) + self.__store_open_meteo_ensemble_data( + levels, height, temperature, wind_u, wind_v, len(member_suffixes) + ) - # Members may resolve different level counts; keep only the levels - # common to every member so the ensemble stays a regular array. - if levels is None or len(member_levels) < len(levels): - levels = member_levels - heights.append( - geopotential_height_to_geometric_height( - geopotential_heights, self.earth_radius - ) - ) - temperatures.append(member_temperatures) - wind_us.append(member_wind_u) - wind_vs.append(member_wind_v) + # Activate default ensemble + self.select_ensemble_member() - profile_length = min(len(levels), *(len(h) for h in heights)) - levels = levels[:profile_length] - height = np.array([h[:profile_length] for h in heights]) - temperature = np.array([t[:profile_length] for t in temperatures]) - wind_u = np.array([u[:profile_length] for u in wind_us]) - wind_v = np.array([v[:profile_length] for v in wind_vs]) + self.__store_open_meteo_metadata(response, time_array) + + def __store_open_meteo_ensemble_data( + self, levels, height, temperature, wind_u, wind_v, num_members + ): + """Stores every ensemble member so members can be selected later. + Parameters + ---------- + levels : numpy.ndarray + The pressure levels, in hPa. + height : numpy.ndarray + Geometric altitudes above sea level, in m, shaped + ``(members, levels)``. + temperature : numpy.ndarray + Temperatures, in K, shaped ``(members, levels)``. + wind_u, wind_v : numpy.ndarray + The East and North wind components, in m/s, shaped + ``(members, levels)``. + num_members : int + Number of members stored, including the control run. + """ wind_speed = calculate_wind_speed(wind_u, wind_v) wind_heading = calculate_wind_heading(wind_u, wind_v) wind_direction = convert_wind_heading_to_direction(wind_heading) @@ -2065,12 +2171,7 @@ def process_open_meteo_ensemble(self, model="gfs05"): self.wind_heading_ensemble = wind_heading self.wind_direction_ensemble = wind_direction self.wind_speed_ensemble = wind_speed - self.num_ensemble_members = len(member_suffixes) - - # Activate default ensemble - self.select_ensemble_member() - - self.__store_open_meteo_metadata(response, time_array) + self.num_ensemble_members = num_members # Save debugging data self.levels = self.level_ensemble diff --git a/tests/unit/environment/test_open_meteo.py b/tests/unit/environment/test_open_meteo.py index 72f90ec90..822aa0c85 100644 --- a/tests/unit/environment/test_open_meteo.py +++ b/tests/unit/environment/test_open_meteo.py @@ -116,7 +116,7 @@ def _patch_ensemble(monkeypatch, response=None, recorder=None): """Replaces the Open-Meteo ensemble fetcher with an offline fake.""" payload = _build_response() if response is None else response - def fake_fetch(latitude, longitude, model="gfs05", date=None): + def fake_fetch(latitude, longitude, model="gfs05", date=None): # pylint: disable=unused-argument if recorder is not None: recorder.update({"model": model, "date": date}) return payload @@ -569,7 +569,7 @@ def test_future_date_queries_forecast_endpoint(self, monkeypatch): """Send future launch dates to the regular forecast API.""" recorder = {} - def fake_request(url, params, endpoint): + def fake_request(url, params, endpoint): # pylint: disable=unused-argument recorder.update({"url": url, "params": params}) return {"hourly": {}} @@ -590,7 +590,7 @@ def test_requests_wind_in_metres_per_second(self, monkeypatch): """ recorder = {} - def fake_request(url, params, endpoint): + def fake_request(url, params, endpoint): # pylint: disable=unused-argument recorder.update(params) return {"hourly": {}} @@ -606,6 +606,8 @@ def test_api_error_payload_raises_runtime_error(self, monkeypatch): """Surface Open-Meteo's own error message instead of a bare status code.""" class FakeResponse: + """Stands in for an Open-Meteo error response.""" + ok = False status_code = 400 @@ -624,6 +626,8 @@ def test_response_without_hourly_raises_runtime_error(self, monkeypatch): """Fail clearly when the response carries no hourly block.""" class FakeResponse: + """Stands in for a successful response missing its hourly block.""" + ok = True status_code = 200