diff --git a/docs/user/environment/1-atm-models/ensemble.rst b/docs/user/environment/1-atm-models/ensemble.rst index 504cbfe60..8dffaac00 100644 --- a/docs/user/environment/1-atm-models/ensemble.rst +++ b/docs/user/environment/1-atm-models/ensemble.rst @@ -25,8 +25,8 @@ Global Ensemble Forecast System (GEFS) .. danger:: - **GEFS shortcut unavailable**: ``file="GEFS"`` is currently disabled in - RocketPy because NOMADS OPeNDAP is deactivated for this endpoint. + **GEFS unavailable**: ``file="GEFS"`` is currently disabled in + RocketPy because NOMADS OPeNDAP has been deactivated. .. note:: diff --git a/docs/user/environment/1-atm-models/forecast.rst b/docs/user/environment/1-atm-models/forecast.rst index ac91504e0..92f5b6b9e 100644 --- a/docs/user/environment/1-atm-models/forecast.rst +++ b/docs/user/environment/1-atm-models/forecast.rst @@ -6,8 +6,8 @@ Forecasts Weather forecasts can be used to set the atmospheric model in RocketPy. Here, we will showcase how to import global forecasts such as GFS, as well as -local forecasts like NAM and RAP for North America, all available through -OPeNDAP on the `NOAA's NCEP NOMADS `_ website. +local forecasts like NAM, RAP and HRRR for North America, all available through +OPeNDAP on the `UCAR THREDDS `_ server. Other generic forecasts can also be imported. .. important:: @@ -22,6 +22,10 @@ Other generic forecasts can also be imported. Global Forecast System (GFS) ---------------------------- +GFS is NOAA's global numerical weather prediction model. It provides worldwide +atmospheric forecasts and is usually a good default choice when you need broad +coverage, consistent availability, and launch planning several days ahead. + Using the latest forecast from GFS is simple. Set the atmospheric model to ``forecast`` and specify that GFS is the file you want. Note that since data is downloaded from a remote OPeNDAP server, this line of code can @@ -48,9 +52,34 @@ take longer than usual. `GFS overview page `_. +Artificial Intelligence Global Forecast System (AIGFS) +------------------------------------------------------ + +AIGFS is a global AI-based forecast product distributed through the same THREDDS +ecosystem used by other RocketPy forecast inputs. It is useful when you want a +global forecast alternative to traditional physics-only models. + +RocketPy supports the latest AIGFS global forecast through THREDDS. + +.. jupyter-execute:: + + env_aigfs = Environment(date=tomorrow) + env_aigfs.set_atmospheric_model(type="forecast", file="AIGFS") + env_aigfs.plots.atmospheric_model() + +.. note:: + + AIGFS is currently available as a global 0.25 degree forecast product on + UCAR THREDDS. + + North American Mesoscale Forecast System (NAM) ---------------------------------------------- +NAM is a regional forecast model focused on North America. It is best suited +for launches inside its coverage area when you want finer regional detail than +global models typically provide. + You can also request the latest forecasts from NAM. Since this is a regional model for North America, you need to specify latitude and longitude points within North America. @@ -78,6 +107,10 @@ We will use **SpacePort America** for this, represented by coordinates Rapid Refresh (RAP) ------------------- +RAP is a short-range, high-frequency regional model for North America. It is +especially useful for near-term operations, where fast update cycles are more +important than long forecast horizon. + The Rapid Refresh (RAP) model is another regional model for North America. It is similar to NAM, but with a higher resolution and a shorter forecast range. The same coordinates for SpacePort America will be used. @@ -111,6 +144,17 @@ The same coordinates for SpacePort America will be used. High Resolution Window (HIRESW) ------------------------------- +HIRESW is a convection-allowing, high-resolution regional system designed to +resolve local weather structure better than coarser grids. It is most useful +for short-range, local analysis where small-scale wind and weather features +matter. + +The High Resolution Window (HIRESW) model is a sophisticated weather forecasting +system that operates at a high spatial resolution of approximately 3 km. +It utilizes two main dynamical cores: the Advanced Research WRF (WRF-ARW) and +the Finite Volume Cubed Sphere (FV3), each designed to enhance the accuracy of +weather predictions. + .. danger:: **HIRESW shortcut unavailable**: ``file="HIRESW"`` is currently disabled in @@ -121,6 +165,33 @@ you can still load it explicitly by passing the path/URL in ``file`` and an appropriate mapping in ``dictionary``. +High-Resolution Rapid Refresh (HRRR) +------------------------------------ + +HRRR is a high-resolution, short-range forecast model for North America with +hourly updates. It is generally best for day-of-launch weather assessment and +rapidly changing local conditions. + +RocketPy supports HRRR through a dedicated THREDDS shortcut. +Like NAM and RAP, HRRR is a regional model over North America. + +If you have a HIRESW-compatible dataset from another provider (or a local copy), +you can still load it explicitly by passing the path/URL in ``file`` and an +appropriate mapping in ``dictionary``. + + env_hrrr = Environment( + date=now_plus_twelve, + latitude=32.988528, + longitude=-106.975056, + ) + env_hrrr.set_atmospheric_model(type="forecast", file="HRRR") + env_hrrr.plots.atmospheric_model() + +.. note:: + + HRRR is a high-resolution regional model with approximately 2.5 km grid + spacing over CONUS. Availability depends on upstream THREDDS data services. + Using Windy Atmosphere ---------------------- @@ -154,6 +225,10 @@ to EuRoC's launch area in Portugal. ECMWF ^^^^^ +ECMWF (HRES) is a global, high-skill forecast model known for strong +medium-range performance. It is often a good choice for mission planning when +you need reliable synoptic-scale forecasts several days ahead. + We can use the ``ECMWF`` model from Windy.com. .. jupyter-execute:: @@ -173,6 +248,10 @@ We can use the ``ECMWF`` model from Windy.com. GFS ^^^ +Windy's GFS option provides NOAA's global model through Windy's interface. It +is a practical baseline for global coverage and for comparing against other +models when assessing forecast uncertainty. + The ``GFS`` model is also available on Windy.com. This is the same model as described in the :ref:`global-forecast-system` section. @@ -186,6 +265,10 @@ described in the :ref:`global-forecast-system` section. ICON ^^^^ +ICON is DWD's global weather model, available in Windy for broad-scale +forecasting. It is useful as an independent global model source to cross-check +wind and temperature trends against GFS or ECMWF. + The ICON model is a global weather forecasting model already available on Windy.com. .. jupyter-execute:: @@ -203,6 +286,10 @@ The ICON model is a global weather forecasting model already available on Windy. ICON-EU ^^^^^^^ +ICON-EU is the regional European configuration of ICON, with higher spatial +detail over Europe than ICON-Global. It is best for European launch sites when +regional structure is important. + The ICON-EU model is a regional weather forecasting model available on Windy.com. .. code-block:: python @@ -228,4 +315,4 @@ Also, the servers may be down or may face high traffic. .. seealso:: To browse available NCEP model collections on UCAR THREDDS, visit - `THREDDS NCEP Catalog `_. \ No newline at end of file + `THREDDS NCEP Catalog `_. diff --git a/docs/user/environment/1-atm-models/soundings.rst b/docs/user/environment/1-atm-models/soundings.rst index 279750df5..4cf82543f 100644 --- a/docs/user/environment/1-atm-models/soundings.rst +++ b/docs/user/environment/1-atm-models/soundings.rst @@ -59,7 +59,7 @@ Integrated Global Radiosonde Archive (IGRA). These options can be retrieved as a text file in GSD format. However, RocketPy no longer provides a dedicated ``set_atmospheric_model`` type for -NOAA RUC Soundings. +NOAA RUC Soundings, since NOAA has discontinued the OPENDAP service. .. note:: diff --git a/docs/user/environment/3-further/other_apis.rst b/docs/user/environment/3-further/other_apis.rst index 01d4b9a30..37a9a0949 100644 --- a/docs/user/environment/3-further/other_apis.rst +++ b/docs/user/environment/3-further/other_apis.rst @@ -89,8 +89,10 @@ Instead of a custom dictionary, you can pass a built-in mapping name in the - ``"ECMWF_v0"`` - ``"NOAA"`` - ``"GFS"`` +- ``"AIGFS"`` - ``"NAM"`` - ``"RAP"`` +- ``"HRRR"`` - ``"HIRESW"`` (mapping available; latest-model shortcut currently disabled) - ``"GEFS"`` (mapping available; latest-model shortcut currently disabled) - ``"MERRA2"`` @@ -116,10 +118,7 @@ legacy aliases: - ``"NAM_LEGACY"`` - ``"NOAA_LEGACY"`` - ``"RAP_LEGACY"`` -- ``"CMC_LEGACY"`` - ``"GEFS_LEGACY"`` -- ``"HIRESW_LEGACY"`` -- ``"MERRA2_LEGACY"`` Legacy aliases primarily cover older variable naming patterns such as ``lev``, ``tmpprs``, ``hgtprs``, ``ugrdprs`` and ``vgrdprs``. diff --git a/rocketpy/environment/environment.py b/rocketpy/environment/environment.py index 8e379800c..a2ed22370 100644 --- a/rocketpy/environment/environment.py +++ b/rocketpy/environment/environment.py @@ -11,10 +11,12 @@ import pytz from rocketpy.environment.fetchers import ( + fetch_aigfs_file_return_dataset, fetch_atmospheric_data_from_windy, fetch_gefs_ensemble, fetch_gfs_file_return_dataset, fetch_hiresw_file_return_dataset, + fetch_hrrr_file_return_dataset, fetch_nam_file_return_dataset, fetch_open_elevation, fetch_rap_file_return_dataset, @@ -369,9 +371,11 @@ def __initialize_constants(self): self.__weather_model_map = WeatherModelMapping() self.__atm_type_file_to_function_map = { "forecast": { + "AIGFS": fetch_aigfs_file_return_dataset, "GFS": fetch_gfs_file_return_dataset, "NAM": fetch_nam_file_return_dataset, "RAP": fetch_rap_file_return_dataset, + "HRRR": fetch_hrrr_file_return_dataset, "HIRESW": fetch_hiresw_file_return_dataset, }, "ensemble": { @@ -665,9 +669,11 @@ def __resolve_dictionary_for_dataset(self, dictionary, dataset): def __validate_dictionary(self, file, dictionary): # removed CMC until it is fixed. available_models = [ + "AIGFS", "GFS", "NAM", "RAP", + "HRRR", "HIRESW", "GEFS", "ERA5", @@ -1132,8 +1138,9 @@ def set_atmospheric_model( # pylint: disable=too-many-statements - ``"windy"``: one of ``"ECMWF"``, ``"GFS"``, ``"ICON"`` or ``"ICONEU"``. - ``"forecast"``: local path, OPeNDAP URL, open - ``netCDF4.Dataset``, or one of ``"GFS"``, ``"NAM"`` or ``"RAP"`` - for the latest available forecast. + ``netCDF4.Dataset``, or one of ``"AIGFS"``, ``"GFS"``, + ``"NAM"``, ``"RAP"``, ``"HRRR"`` or ``"HIRESW"`` for the + latest available forecast. - ``"reanalysis"``: local path, OPeNDAP URL, or open ``netCDF4.Dataset``. - ``"ensemble"``: local path, OPeNDAP URL, open @@ -1143,8 +1150,9 @@ def set_atmospheric_model( # pylint: disable=too-many-statements Variable-name mapping for ``"forecast"``, ``"reanalysis"`` and ``"ensemble"``. It may be a custom dictionary or a built-in mapping name (for example: ``"ECMWF"``, ``"ECMWF_v0"``, - ``"NOAA"``, ``"GFS"``, ``"NAM"``, ``"RAP"``, ``"HIRESW"``, - ``"GEFS"``, ``"MERRA2"`` or ``"CMC"``). + ``"NOAA"``, ``"AIGFS"``, ``"GFS"``, ``"NAM"``, ``"RAP"``, + ``"HRRR"``, ``"HIRESW"``, ``"GEFS"``, ``"MERRA2"`` or + ``"CMC"``). If ``dictionary`` is omitted and ``file`` is one of RocketPy's latest-model shortcuts, the matching built-in mapping is selected @@ -1761,13 +1769,17 @@ def process_forecast_reanalysis(self, file, dictionary): # pylint: disable=too- # Some THREDDS datasets use projected x/y coordinates. if dictionary.get("projection") is not None: projection_variable = data.variables[dictionary["projection"]] - x_units = getattr(lon_array, "units", "m") - target_lon, target_lat = geodesic_to_lambert_conformal( - self.latitude, - self.longitude, - projection_variable, - x_units=x_units, - ) + if dictionary.get("projection") == "LambertConformal_Projection": + x_units = getattr(lon_array, "units", "m") + target_lon, target_lat = geodesic_to_lambert_conformal( + self.latitude, + self.longitude, + projection_variable, + x_units=x_units, + ) + else: + target_lon = self.longitude + target_lat = self.latitude else: target_lon = self.longitude target_lat = self.latitude @@ -2065,13 +2077,17 @@ class for some dictionary examples. # coordinate system before locating the nearest grid cell. if dictionary.get("projection") is not None: projection_variable = data.variables[dictionary["projection"]] - x_units = getattr(lon_array, "units", "m") - target_lon, target_lat = geodesic_to_lambert_conformal( - self.latitude, - self.longitude, - projection_variable, - x_units=x_units, - ) + if dictionary.get("projection") == "LambertConformal_Projection": + x_units = getattr(lon_array, "units", "m") + target_lon, target_lat = geodesic_to_lambert_conformal( + self.latitude, + self.longitude, + projection_variable, + x_units=x_units, + ) + else: + target_lon = self.longitude + target_lat = self.latitude else: target_lon = self.longitude target_lat = self.latitude diff --git a/rocketpy/environment/fetchers.py b/rocketpy/environment/fetchers.py index de63d53ad..64d5dd479 100644 --- a/rocketpy/environment/fetchers.py +++ b/rocketpy/environment/fetchers.py @@ -195,6 +195,78 @@ def fetch_rap_file_return_dataset(max_attempts=10, base_delay=2): raise RuntimeError("Unable to load latest weather data for RAP through " + file_url) +def fetch_hrrr_file_return_dataset(max_attempts=10, base_delay=2): + """Fetches the latest HRRR (High-Resolution Rapid Refresh) dataset from + the NOAA's GrADS data server using the OpenDAP protocol. + + Parameters + ---------- + max_attempts : int, optional + The maximum number of attempts to fetch the dataset. Default is 10. + base_delay : int, optional + The base delay in seconds between attempts. Default is 2. + + Returns + ------- + netCDF4.Dataset + The HRRR dataset. + + Raises + ------ + RuntimeError + If unable to load the latest weather data for HRRR. + """ + file_url = "https://thredds.ucar.edu/thredds/dodsC/grib/NCEP/HRRR/CONUS_2p5km/Best" + attempt_count = 0 + while attempt_count < max_attempts: + try: + return netCDF4.Dataset(file_url) + except OSError: + attempt_count += 1 + time.sleep(base_delay**attempt_count) + + raise RuntimeError( + "Unable to load latest weather data for HRRR through " + file_url + ) + + +def fetch_aigfs_file_return_dataset(max_attempts=10, base_delay=2): + """Fetches the latest AIGFS (Artificial Intelligence GFS) dataset from + the NOAA's GrADS data server using the OpenDAP protocol. + + Parameters + ---------- + max_attempts : int, optional + The maximum number of attempts to fetch the dataset. Default is 10. + base_delay : int, optional + The base delay in seconds between attempts. Default is 2. + + Returns + ------- + netCDF4.Dataset + The AIGFS dataset. + + Raises + ------ + RuntimeError + If unable to load the latest weather data for AIGFS. + """ + file_url = ( + "https://thredds.ucar.edu/thredds/dodsC/grib/NCEP/AIGFS/Global_0p25deg/Best" + ) + attempt_count = 0 + while attempt_count < max_attempts: + try: + return netCDF4.Dataset(file_url) + except OSError: + attempt_count += 1 + time.sleep(base_delay**attempt_count) + + raise RuntimeError( + "Unable to load latest weather data for AIGFS through " + file_url + ) + + def fetch_hiresw_file_return_dataset(max_attempts=10, base_delay=2): """Fetches the latest HiResW (High-Resolution Window) dataset from the NOAA's GrADS data server using the OpenDAP protocol. diff --git a/rocketpy/environment/weather_model_mapping.py b/rocketpy/environment/weather_model_mapping.py index b054a35c4..c8617a523 100644 --- a/rocketpy/environment/weather_model_mapping.py +++ b/rocketpy/environment/weather_model_mapping.py @@ -48,11 +48,11 @@ class WeatherModelMapping: "u_wind": "ugrdprs", "v_wind": "vgrdprs", } - NAM = { + AIGFS = { "time": "time", - "latitude": "y", - "longitude": "x", - "projection": "LambertConformal_Projection", + "latitude": "lat", + "longitude": "lon", + "projection": "LatLon_Projection", "level": "isobaric", "temperature": "Temperature_isobaric", "surface_geopotential_height": None, @@ -73,6 +73,19 @@ class WeatherModelMapping: "u_wind": "ugrdprs", "v_wind": "vgrdprs", } + NAM = { + "time": "time", + "latitude": "y", + "longitude": "x", + "projection": "LambertConformal_Projection", + "level": "isobaric", + "temperature": "Temperature_isobaric", + "surface_geopotential_height": None, + "geopotential_height": "Geopotential_height_isobaric", + "geopotential": None, + "u_wind": "u-component_of_wind_isobaric", + "v_wind": "v-component_of_wind_isobaric", + } ECMWF_v0 = { "time": "time", "latitude": "latitude", @@ -148,20 +161,20 @@ class WeatherModelMapping: "u_wind": "ugrdprs", "v_wind": "vgrdprs", } - CMC = { + HRRR = { "time": "time", - "latitude": "lat", - "longitude": "lon", - "level": "lev", - "ensemble": "ens", - "temperature": "tmpprs", + "latitude": "y", + "longitude": "x", + "projection": "LambertConformal_Projection", + "level": "isobaric", + "temperature": "Temperature_isobaric", "surface_geopotential_height": None, - "geopotential_height": "hgtprs", + "geopotential_height": "Geopotential_height_isobaric", "geopotential": None, - "u_wind": "ugrdprs", - "v_wind": "vgrdprs", + "u_wind": "u-component_of_wind_isobaric", + "v_wind": "v-component_of_wind_isobaric", } - CMC_LEGACY = { + CMC = { "time": "time", "latitude": "lat", "longitude": "lon", @@ -211,17 +224,6 @@ class WeatherModelMapping: "u_wind": "ugrdprs", "v_wind": "vgrdprs", } - HIRESW_LEGACY = { - "time": "time", - "latitude": "lat", - "longitude": "lon", - "level": "lev", - "temperature": "tmpprs", - "surface_geopotential_height": "hgtsfc", - "geopotential_height": "hgtprs", - "u_wind": "ugrdprs", - "v_wind": "vgrdprs", - } MERRA2 = { "time": "time", "latitude": "lat", @@ -235,19 +237,6 @@ class WeatherModelMapping: "u_wind": "U", "v_wind": "V", } - MERRA2_LEGACY = { - "time": "time", - "latitude": "lat", - "longitude": "lon", - "level": "lev", - "temperature": "T", - "surface_geopotential_height": None, - "surface_geopotential": "PHIS", - "geopotential_height": "H", - "geopotential": None, - "u_wind": "U", - "v_wind": "V", - } def __init__(self): """Build the lookup table with default and legacy mapping aliases.""" @@ -255,6 +244,7 @@ def __init__(self): self.all_dictionaries = { "GFS": self.GFS, "GFS_LEGACY": self.GFS_LEGACY, + "AIGFS": self.AIGFS, "NAM": self.NAM, "NAM_LEGACY": self.NAM_LEGACY, "ECMWF_v0": self.ECMWF_v0, @@ -263,14 +253,12 @@ def __init__(self): "NOAA_LEGACY": self.NOAA_LEGACY, "RAP": self.RAP, "RAP_LEGACY": self.RAP_LEGACY, + "HRRR": self.HRRR, "CMC": self.CMC, - "CMC_LEGACY": self.CMC_LEGACY, "GEFS": self.GEFS, "GEFS_LEGACY": self.GEFS_LEGACY, "HIRESW": self.HIRESW, - "HIRESW_LEGACY": self.HIRESW_LEGACY, "MERRA2": self.MERRA2, - "MERRA2_LEGACY": self.MERRA2_LEGACY, } def get(self, model): diff --git a/tests/integration/environment/test_environment.py b/tests/integration/environment/test_environment.py index 5802650dc..ff752d6c8 100644 --- a/tests/integration/environment/test_environment.py +++ b/tests/integration/environment/test_environment.py @@ -1,5 +1,5 @@ import time -from datetime import date, datetime, timezone +from datetime import date, datetime, timedelta, timezone from unittest.mock import patch import numpy as np @@ -146,6 +146,7 @@ def test_wind_plots_wrapping_direction(mock_show, example_plain_env): # pylint: assert example_plain_env.plots.atmospheric_model() is None +@pytest.mark.slow @pytest.mark.parametrize( "model_name", [ @@ -195,6 +196,42 @@ def test_gfs_atmosphere(mock_show, example_spaceport_env): # pylint: disable=un assert example_spaceport_env.all_info() is None +@pytest.mark.slow +@patch("matplotlib.pyplot.show") +def test_aigfs_atmosphere(mock_show, example_spaceport_env): # pylint: disable=unused-argument + """Tests the Forecast model with the AIGFS file. + + Parameters + ---------- + mock_show : mock + Mock object to replace matplotlib.pyplot.show() method. + example_spaceport_env : rocketpy.Environment + Example environment object to be tested. + """ + example_spaceport_env.set_atmospheric_model(type="Forecast", file="AIGFS") + assert example_spaceport_env.all_info() is None + + +@pytest.mark.slow +@patch("matplotlib.pyplot.show") +def test_hrrr_atmosphere(mock_show, example_spaceport_env): # pylint: disable=unused-argument + """Tests the Forecast model with the HRRR file. + + Parameters + ---------- + mock_show : mock + Mock object to replace matplotlib.pyplot.show() method. + example_spaceport_env : rocketpy.Environment + Example environment object to be tested. + """ + # Sometimes the HRRR latest-model can fail due to not having at least 24 + # hours in the future in the forecast, so we try with 12 hours in the future + # only. + example_spaceport_env.set_date(datetime.now() + timedelta(hours=12)) + example_spaceport_env.set_atmospheric_model(type="Forecast", file="HRRR") + assert example_spaceport_env.all_info() is None + + @pytest.mark.slow @patch("matplotlib.pyplot.show") def test_nam_atmosphere(mock_show, example_spaceport_env): # pylint: disable=unused-argument diff --git a/tests/unit/environment/test_environment.py b/tests/unit/environment/test_environment.py index beb6d5ac6..8d30e4606 100644 --- a/tests/unit/environment/test_environment.py +++ b/tests/unit/environment/test_environment.py @@ -336,8 +336,8 @@ def test_resolve_dictionary_keeps_compatible_mapping(example_plain_env): assert resolved is gfs_mapping -def test_resolve_dictionary_falls_back_to_legacy_mapping(example_plain_env): - """Fallback to a compatible built-in mapping for legacy NOMADS-style files.""" +def test_resolve_dictionary_falls_back_to_first_compatible_mapping(example_plain_env): + """Fallback to the first compatible built-in mapping for legacy-style files.""" thredds_gfs_mapping = example_plain_env._Environment__weather_model_map.get("GFS") dataset = _DummyDataset( [ @@ -356,7 +356,6 @@ def test_resolve_dictionary_falls_back_to_legacy_mapping(example_plain_env): thredds_gfs_mapping, dataset ) - # Explicit legacy mappings should be preferred over unrelated model mappings. assert resolved == example_plain_env._Environment__weather_model_map.get( "GFS_LEGACY" ) @@ -602,3 +601,51 @@ def test_set_atmospheric_model_raises_for_unknown_model_type(example_plain_env): # Act / Assert with pytest.raises(ValueError, match="Unknown model type"): environment.set_atmospheric_model(type="unknown_type") + + +@pytest.mark.parametrize("shortcut_name", ["AIGFS", "HRRR"]) +def test_forecast_shortcut_and_dictionary_are_case_insensitive( + monkeypatch, shortcut_name +): + """Ensure forecast shortcuts and built-in dictionaries ignore input casing.""" + # Arrange + env = Environment(date=(2026, 3, 17, 12), latitude=32.99, longitude=-106.97) + + sentinel_dataset = object() + env._Environment__atm_type_file_to_function_map["forecast"][shortcut_name] = ( + lambda: sentinel_dataset + ) + + captured = {} + + def fake_process_forecast_reanalysis(file, dictionary): + captured["file"] = file + captured["dictionary"] = dictionary + + monkeypatch.setattr( + env, "process_forecast_reanalysis", fake_process_forecast_reanalysis + ) + monkeypatch.setattr(env, "calculate_density_profile", lambda: None) + monkeypatch.setattr(env, "calculate_speed_of_sound_profile", lambda: None) + monkeypatch.setattr(env, "calculate_dynamic_viscosity", lambda: None) + + # Act + env.set_atmospheric_model( + type="forecast", + file=shortcut_name.lower(), + dictionary=shortcut_name.lower(), + ) + + # Assert + expected_dictionary = env._Environment__weather_model_map.get(shortcut_name) + assert captured["file"] is sentinel_dataset + assert captured["dictionary"] == expected_dictionary + assert env.atmospheric_model_file == shortcut_name + assert env.atmospheric_model_dict == expected_dictionary + + +def test_weather_model_mapping_get_is_case_insensitive(): + """Ensure built-in mapping names are resolved regardless of casing.""" + mapping = WeatherModelMapping() + assert mapping.get("aigfs") == mapping.get("AIGFS") + assert mapping.get("ecmwf_v0") == mapping.get("ECMWF_v0")