1- # pylint: disable=too-many-public-methods, too-many-instance-attributes
1+ # pylint: disable=too-many-public-methods, too-many-instance-attributes, too-many-lines
22import bisect
33import json
44import logging
5+ import os
56import re
67import warnings
78from collections import namedtuple
1314
1415from rocketpy .environment .fetchers import (
1516 fetch_aigfs_file_return_dataset ,
17+ fetch_atmospheric_data_from_meteomatics ,
1618 fetch_atmospheric_data_from_windy ,
1719 fetch_gefs_ensemble ,
1820 fetch_gfs_file_return_dataset ,
@@ -145,8 +147,8 @@ class Environment:
145147 Environment.atmospheric_model_type : string
146148 Describes the atmospheric model which is being used. Can only assume the
147149 following values: ``standard_atmosphere``, ``custom_atmosphere``,
148- ``wyoming_sounding``, ``windy``, ``forecast ``, ``reanalysis ``,
149- ``ensemble``.
150+ ``wyoming_sounding``, ``windy``, ``meteomatics ``, ``forecast ``,
151+ ``reanalysis``, `` ensemble``.
150152 Environment.atmospheric_model_file : string
151153 Address of the file used for the atmospheric model being used. Only
152154 defined for ``wyoming_sounding``, ``windy``, ``forecast``,
@@ -1188,6 +1190,8 @@ def set_atmospheric_model( # pylint: disable=too-many-statements
11881190 wind_u = 0 ,
11891191 wind_v = 0 ,
11901192 pressure_conversion_factor = None ,
1193+ username = None ,
1194+ password = None ,
11911195 ):
11921196 """Define the atmospheric model for this Environment.
11931197
@@ -1196,15 +1200,18 @@ def set_atmospheric_model( # pylint: disable=too-many-statements
11961200 type : string
11971201 Atmospheric model selector (case-insensitive). Accepted values are
11981202 ``"standard_atmosphere"``, ``"wyoming_sounding"``, ``"windy"``,
1199- ``"forecast"``, ``"reanalysis"``, ``"ensemble"`` and
1200- ``"custom_atmosphere"``.
1203+ ``"forecast"``, ``"reanalysis"``, ``"ensemble"``,
1204+ ``"custom_atmosphere"`` and ``"meteomatics"`` .
12011205 file : string | netCDF4.Dataset, optional
12021206 Data source or model shortcut. Meaning depends on ``type``:
12031207
12041208 - ``"standard_atmosphere"`` and ``"custom_atmosphere"``: ignored.
12051209 - ``"wyoming_sounding"``: URL of the sounding text page.
12061210 - ``"windy"``: one of ``"ECMWF"``, ``"GFS"``, ``"ICON"`` or
12071211 ``"ICONEU"``.
1212+ - ``"meteomatics"``: the Meteomatics weather model to query, such
1213+ as ``"mix"`` (the default when omitted). See the Meteomatics
1214+ documentation for the models available to your account.
12081215 - ``"forecast"``: local path, OPeNDAP URL, open
12091216 ``netCDF4.Dataset``, or one of ``"AIGFS"``, ``"GFS"``,
12101217 ``"NAM"``, ``"RAP"``, ``"HRRR"`` or ``"HIRESW"`` for the
@@ -1290,6 +1297,14 @@ def set_atmospheric_model( # pylint: disable=too-many-statements
12901297 model name (e.g. ERA5/ECMWF/MERRA2 reanalysis files commonly use hPa,
12911298 while online GFS/NAM/RAP/HRRR forecast models use Pa) or, if
12921299 unavailable, by reading the pressure unit attribute from the file.
1300+ username : string, optional
1301+ Meteomatics account username. Only used when ``type`` is
1302+ ``"meteomatics"``. If None (the default), the value is read from the
1303+ ``METEOMATICS_USERNAME`` environment variable.
1304+ password : string, optional
1305+ Meteomatics account password. Only used when ``type`` is
1306+ ``"meteomatics"``. If None (the default), the value is read from the
1307+ ``METEOMATICS_PASSWORD`` environment variable.
12931308
12941309 Returns
12951310 -------
@@ -1338,6 +1353,10 @@ def set_atmospheric_model( # pylint: disable=too-many-statements
13381353 self .process_custom_atmosphere (pressure , temperature , wind_u , wind_v )
13391354 case "windy" :
13401355 self .process_windy_atmosphere (file )
1356+ case "meteomatics" :
1357+ self .process_meteomatics_atmosphere (
1358+ model = file , username = username , password = password
1359+ )
13411360 case "forecast" | "reanalysis" | "ensemble" :
13421361 # Capture the user-supplied names before __validate_dictionary
13431362 # converts them to dicts, so they can drive auto-detection.
@@ -1736,6 +1755,209 @@ def __parse_windy_file(self, response, time_index, pressure_levels):
17361755 wind_v_array ,
17371756 )
17381757
1758+ @staticmethod
1759+ def _validate_meteomatics_credentials_and_model (model , username , password ):
1760+ """Validates model and credentials for Meteomatics requests."""
1761+ if model is None :
1762+ model = "mix"
1763+ elif not isinstance (model , str ):
1764+ # Coercing silently would hide a mistake such as passing a Dataset
1765+ # or a file path as 'file', and would query the wrong model.
1766+ raise ValueError (
1767+ f"Invalid Meteomatics model { model !r} : expected the model name as "
1768+ "a string (e.g. 'mix'), or None to use the default."
1769+ )
1770+ username = username or os .environ .get ("METEOMATICS_USERNAME" )
1771+ password = password or os .environ .get ("METEOMATICS_PASSWORD" )
1772+ if not username or not password :
1773+ raise ValueError (
1774+ "Meteomatics requires a username and password. Provide them via "
1775+ "the 'username' and 'password' arguments of set_atmospheric_model, "
1776+ "or set the METEOMATICS_USERNAME and METEOMATICS_PASSWORD "
1777+ "environment variables."
1778+ )
1779+ return model , username , password
1780+
1781+ def _store_meteomatics_functions (
1782+ self , pressure_array , temperature_array , wind_array
1783+ ):
1784+ """Sets internal atmospheric functions for Meteomatics."""
1785+ wind_asl_heights = wind_array [:, 0 ]
1786+ wind_u_values = wind_array [:, 1 ]
1787+ wind_v_values = wind_array [:, 2 ]
1788+
1789+ wind_speed_array = calculate_wind_speed (wind_u_values , wind_v_values )
1790+ wind_heading_array = calculate_wind_heading (wind_u_values , wind_v_values )
1791+ wind_direction_array = convert_wind_heading_to_direction (wind_heading_array )
1792+
1793+ # Save atmospheric data
1794+ self .__set_pressure_function (pressure_array )
1795+ self .__set_barometric_height_function (pressure_array [:, (1 , 0 )])
1796+ self .__set_temperature_function (temperature_array )
1797+ self .__set_wind_velocity_x_function (wind_array [:, (0 , 1 )])
1798+ self .__set_wind_velocity_y_function (wind_array [:, (0 , 2 )])
1799+ self .__set_wind_heading_function (
1800+ np .column_stack ((wind_asl_heights , wind_heading_array ))
1801+ )
1802+ self .__set_wind_direction_function (
1803+ np .column_stack ((wind_asl_heights , wind_direction_array ))
1804+ )
1805+ self .__set_wind_speed_function (
1806+ np .column_stack ((wind_asl_heights , wind_speed_array ))
1807+ )
1808+
1809+ # Save maximum expected height
1810+ self ._max_expected_height = float (
1811+ max (pressure_array [- 1 , 0 ], temperature_array [- 1 , 0 ], wind_asl_heights [- 1 ])
1812+ )
1813+
1814+ def _store_meteomatics_metadata (
1815+ self , pressure_array , temperature_array , wind_array
1816+ ):
1817+ """Sets metadata attributes and debug data for Meteomatics."""
1818+ wind_asl_heights = wind_array [:, 0 ]
1819+ self .atmospheric_model_init_date = self .datetime_date
1820+ self .atmospheric_model_end_date = self .datetime_date
1821+ self .atmospheric_model_interval = 0
1822+ self .atmospheric_model_init_lat = self .latitude
1823+ self .atmospheric_model_end_lat = self .latitude
1824+ self .atmospheric_model_init_lon = self .longitude
1825+ self .atmospheric_model_end_lon = self .longitude
1826+
1827+ # Save debugging data
1828+ self .wind_us = wind_array [:, 1 ]
1829+ self .wind_vs = wind_array [:, 2 ]
1830+ self .temperatures = temperature_array [:, 1 ]
1831+ self .pressures = pressure_array [:, 1 ]
1832+ self .height = wind_asl_heights
1833+
1834+ def _process_meteomatics_profiles (self , profiles ):
1835+ """Converts retrieved height-AGL profiles to ASL arrays and configures
1836+ the Environment atmospheric functions."""
1837+
1838+ def to_profile_array (* names ):
1839+ common_heights = set .intersection (* (set (profiles [n ]) for n in names ))
1840+ heights = sorted (
1841+ h
1842+ for h in common_heights
1843+ if all (profiles [n ][h ] is not None for n in names )
1844+ )
1845+ return np .array (
1846+ [
1847+ (h + self .elevation , * (profiles [n ][h ] for n in names ))
1848+ for h in heights
1849+ ],
1850+ dtype = float ,
1851+ )
1852+
1853+ pressure_array = to_profile_array ("pressure" )
1854+ temperature_array = to_profile_array ("temperature" )
1855+ # Wind u and v share the same altitude grid; keep only common levels.
1856+ wind_array = to_profile_array ("wind_u" , "wind_v" )
1857+
1858+ # Each profile needs at least two levels: a single-point Function cannot
1859+ # be evaluated at its own node (it raises IndexError downstream), so a
1860+ # collapsed grid must fail here with an actionable message instead.
1861+ if min (len (pressure_array ), len (temperature_array ), len (wind_array )) < 2 :
1862+ raise ValueError (
1863+ "Meteomatics did not return enough usable atmospheric data: at "
1864+ "least two valid altitude levels are required for pressure, "
1865+ "temperature and wind. Check the requested model, the altitude "
1866+ "range (min_altitude and max_altitude must be far enough apart "
1867+ "that the sampled levels do not collapse to a single height), "
1868+ "and your account permissions."
1869+ )
1870+
1871+ self ._store_meteomatics_functions (pressure_array , temperature_array , wind_array )
1872+ self ._store_meteomatics_metadata (pressure_array , temperature_array , wind_array )
1873+
1874+ def process_meteomatics_atmosphere (
1875+ self ,
1876+ model = "mix" ,
1877+ username = None ,
1878+ password = None ,
1879+ min_altitude = 10 ,
1880+ max_altitude = 12000 ,
1881+ wind_resolution = 20 ,
1882+ temperature_pressure_resolution = 10 ,
1883+ query_limit = 10 ,
1884+ ):
1885+ """Process data from the Meteomatics API to retrieve a vertical
1886+ atmospheric profile at the launch site.
1887+
1888+ The Meteomatics API is queried for temperature, pressure and both wind
1889+ components at several altitudes above ground level, which are then
1890+ converted to profiles above sea level using the ``Environment``
1891+ elevation. Authentication uses a personal username and password; when
1892+ not provided, they are read from the ``METEOMATICS_USERNAME`` and
1893+ ``METEOMATICS_PASSWORD`` environment variables.
1894+
1895+ Parameters
1896+ ----------
1897+ model : str, optional
1898+ The Meteomatics weather model to query. Default is ``"mix"``. Your
1899+ account may not have access to every model.
1900+ username : str, optional
1901+ Meteomatics account username. Defaults to the
1902+ ``METEOMATICS_USERNAME`` environment variable.
1903+ password : str, optional
1904+ Meteomatics account password. Defaults to the
1905+ ``METEOMATICS_PASSWORD`` environment variable.
1906+ min_altitude : float, optional
1907+ Lowest altitude above ground level (in meters) to query. Default is
1908+ 10.
1909+ max_altitude : float, optional
1910+ Highest altitude above ground level (in meters) to query. Default
1911+ is 12000. The API errors if it lies outside the model's supported
1912+ range.
1913+ wind_resolution : int, optional
1914+ Number of altitude levels used for the wind components. Default is
1915+ 20.
1916+ temperature_pressure_resolution : int, optional
1917+ Number of altitude levels used for temperature and pressure.
1918+ Default is 10.
1919+ query_limit : int, optional
1920+ Maximum number of parameters requested at once. Parameters are
1921+ grouped accordingly to respect the account's per-request limit.
1922+ Default is 10.
1923+
1924+ Raises
1925+ ------
1926+ ValueError
1927+ If ``model`` is not a string, if credentials are missing, if no
1928+ launch date is set, or if the API returns no usable data.
1929+ """
1930+ model , username , password = self ._validate_meteomatics_credentials_and_model (
1931+ model , username , password
1932+ )
1933+ self .__validate_datetime ()
1934+
1935+ if self .elevation == 0 :
1936+ warnings .warn (
1937+ "The Environment elevation is 0 m (possibly unset), so Meteomatics "
1938+ "heights above ground level are being treated as heights above sea "
1939+ "level. Set the elevation before this call if the launch site is "
1940+ "not at sea level." ,
1941+ UserWarning ,
1942+ stacklevel = 2 ,
1943+ )
1944+
1945+ profiles = fetch_atmospheric_data_from_meteomatics (
1946+ username = username ,
1947+ password = password ,
1948+ latitude = self .latitude ,
1949+ longitude = self .longitude ,
1950+ date = self .datetime_date ,
1951+ model = model ,
1952+ min_altitude = min_altitude ,
1953+ max_altitude = max_altitude ,
1954+ wind_resolution = wind_resolution ,
1955+ temperature_pressure_resolution = temperature_pressure_resolution ,
1956+ query_limit = query_limit ,
1957+ )
1958+
1959+ self ._process_meteomatics_profiles (profiles )
1960+
17391961 def process_wyoming_sounding (self , file ): # pylint: disable=too-many-statements
17401962 """Import and process the upper air sounding data from `Wyoming
17411963 Upper Air Soundings` database given by the url in file. Sets
@@ -2987,8 +3209,11 @@ def from_dict(cls, data): # pylint: disable=too-many-statements
29873209 )
29883210 atmospheric_model = data ["atmospheric_model_type" ]
29893211 env .atmospheric_model_type = atmospheric_model
3212+ # set_atmospheric_model stores the type as the user spelled it (e.g.
3213+ # "Meteomatics"), so the dispatch below must be case-insensitive.
3214+ model_type = atmospheric_model .lower ()
29903215
2991- match atmospheric_model :
3216+ match model_type :
29923217 case "standard_atmosphere" :
29933218 env .set_atmospheric_model ("standard_atmosphere" )
29943219 case "custom_atmosphere" :
@@ -3010,7 +3235,7 @@ def from_dict(cls, data): # pylint: disable=too-many-statements
30103235 env .elevation = data ["elevation" ]
30113236 env .max_expected_height = data ["max_expected_height" ]
30123237
3013- if atmospheric_model in ("windy" , "forecast" , "reanalysis" , "ensemble" ):
3238+ if model_type in ("windy" , "meteomatics " , "forecast" , "reanalysis" , "ensemble" ):
30143239 env .atmospheric_model_init_date = data ["atmospheric_model_init_date" ]
30153240 env .atmospheric_model_end_date = data ["atmospheric_model_end_date" ]
30163241 env .atmospheric_model_interval = data ["atmospheric_model_interval" ]
@@ -3019,7 +3244,7 @@ def from_dict(cls, data): # pylint: disable=too-many-statements
30193244 env .atmospheric_model_init_lon = data ["atmospheric_model_init_lon" ]
30203245 env .atmospheric_model_end_lon = data ["atmospheric_model_end_lon" ]
30213246
3022- if atmospheric_model == "ensemble" :
3247+ if model_type == "ensemble" :
30233248 env .level_ensemble = data ["level_ensemble" ]
30243249 env .height_ensemble = data ["height_ensemble" ]
30253250 env .temperature_ensemble = data ["temperature_ensemble" ]
0 commit comments