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")