Skip to content

Commit 02c1827

Browse files
ENH: Compatibility with MERRA-2 atmosphere reanalysis files #825 (#877)
* created new branch * ENH: Add MERRA-2 compatibility with unit conversion and documentation. * ENH: Finalize MERRA-2 support (Resolve conflicts, Update Docs) * Style: Fix ruff formatting errors * REV: Address all code review comments * Apply suggestion from @Gui-FernandesBR --------- Co-authored-by: Gui-FernandesBR <63590233+Gui-FernandesBR@users.noreply.github.com>
1 parent 704c796 commit 02c1827

File tree

6 files changed

+145
-3
lines changed

6 files changed

+145
-3
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
3131
Attention: The newest changes should be on top -->
3232

3333
### Added
34+
ENH: Compatibility with MERRA-2 atmosphere reanalysis files [#825](https://github.com/RocketPy-Team/RocketPy/pull/825)
3435

3536
- ENH: Enable only radial burning [#815](https://github.com/RocketPy-Team/RocketPy/pull/815)
3637
- ENH: Add thrustcurve api integration to retrieve motor eng data [#870](https://github.com/RocketPy-Team/RocketPy/pull/870)

docs/user/environment/1-atm-models/reanalysis.rst

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,27 @@ ERA5 data can be downloaded from the
4646
processed by RocketPy. It is recommended that you download only the \
4747
necessary data.
4848

49+
MERRA-2
50+
-------
51+
52+
The Modern-Era Retrospective analysis for Research and Applications, Version 2 (MERRA-2) is a NASA atmospheric reanalysis for the satellite era using the Goddard Earth Observing System, Version 5 (GEOS-5) with its Atmospheric Data Assimilation System (ADAS).
53+
54+
You can download these files from the `NASA GES DISC <https://disc.gsfc.nasa.gov/>`_.
55+
56+
To use MERRA-2 data in RocketPy, you generally need the **Assimilated Meteorological Fields** collection (specifically the 3D Pressure Level data, usually named ``inst3_3d_asm_Np``). Note that MERRA-2 files typically use the ``.nc4`` extension (NetCDF-4), which is fully supported by RocketPy.
57+
58+
You can load these files using the ``dictionary="MERRA2"`` argument:
59+
60+
.. code-block:: python
61+
62+
env.set_atmospheric_model(
63+
type="Reanalysis",
64+
file="MERRA2_400.inst3_3d_asm_Np.20230620.nc4",
65+
dictionary="MERRA2"
66+
)
67+
68+
RocketPy automatically handles the unit conversion for MERRA-2's surface geopotential (energy) to geometric height (meters).
69+
4970

5071
Setting the Environment
5172
^^^^^^^^^^^^^^^^^^^^^^^

rocketpy/environment/environment.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -607,7 +607,7 @@ def __set_earth_rotation_vector(self):
607607

608608
def __validate_dictionary(self, file, dictionary):
609609
# removed CMC until it is fixed.
610-
available_models = ["GFS", "NAM", "RAP", "HIRESW", "GEFS", "ERA5"]
610+
available_models = ["GFS", "NAM", "RAP", "HIRESW", "GEFS", "ERA5", "MERRA2"]
611611
if isinstance(dictionary, str):
612612
dictionary = self.__weather_model_map.get(dictionary)
613613
elif file in available_models:
@@ -1186,7 +1186,7 @@ def set_atmospheric_model( # pylint: disable=too-many-statements
11861186
``Reanalysis`` or ``Ensemble``. It specifies the dictionary to be
11871187
used when reading ``netCDF`` and ``OPeNDAP`` files, allowing the
11881188
correct retrieval of data. Acceptable values include ``ECMWF``,
1189-
``NOAA`` and ``UCAR`` for default dictionaries which can generally
1189+
``NOAA``, ``UCAR`` and ``MERRA2`` for default dictionaries which can generally
11901190
be used to read datasets from these institutes. Alternatively, a
11911191
dictionary structure can also be given, specifying the short names
11921192
used for time, latitude, longitude, pressure levels, temperature
@@ -1893,10 +1893,20 @@ def process_forecast_reanalysis(self, file, dictionary): # pylint: disable=too-
18931893
self._max_expected_height = max(height[0], height[-1])
18941894

18951895
# Get elevation data from file
1896-
if dictionary["surface_geopotential_height"] is not None:
1896+
if dictionary.get("surface_geopotential_height") is not None:
18971897
self.elevation = get_elevation_data_from_dataset(
18981898
dictionary, data, time_index, lat_index, lon_index, x, y, x1, x2, y1, y2
18991899
)
1900+
# 2. If not found, try Geopotential (m^2/s^2) and convert
1901+
elif dictionary.get("surface_geopotential") is not None:
1902+
temp_dict = dictionary.copy()
1903+
temp_dict["surface_geopotential_height"] = dictionary[
1904+
"surface_geopotential"
1905+
]
1906+
surface_geopotential_value = get_elevation_data_from_dataset(
1907+
temp_dict, data, time_index, lat_index, lon_index, x, y, x1, x2, y1, y2
1908+
)
1909+
self.elevation = surface_geopotential_value / self.standard_g
19001910

19011911
# Compute info data
19021912
self.atmospheric_model_init_date = get_initial_date_from_time_array(time_array)

rocketpy/environment/weather_model_mapping.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,19 @@ class WeatherModelMapping:
114114
"u_wind": "ugrdprs",
115115
"v_wind": "vgrdprs",
116116
}
117+
MERRA2 = {
118+
"time": "time",
119+
"latitude": "lat",
120+
"longitude": "lon",
121+
"level": "lev",
122+
"temperature": "T",
123+
"surface_geopotential_height": None,
124+
"surface_geopotential": "PHIS", # special key for Geopotential (m^2/s^2)
125+
"geopotential_height": "H",
126+
"geopotential": None,
127+
"u_wind": "U",
128+
"v_wind": "V",
129+
}
117130

118131
def __init__(self):
119132
"""Initialize the class, creates a dictionary with all the weather models
@@ -129,6 +142,7 @@ def __init__(self):
129142
"CMC": self.CMC,
130143
"GEFS": self.GEFS,
131144
"HIRESW": self.HIRESW,
145+
"MERRA2": self.MERRA2,
132146
}
133147

134148
def get(self, model):

tests/conftest.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import netCDF4
2+
import numpy as np
13
import matplotlib
24
import pytest
35

@@ -72,3 +74,67 @@ def pytest_collection_modifyitems(config, items):
7274
for item in items:
7375
if "slow" in item.keywords:
7476
item.add_marker(skip_slow)
77+
78+
79+
@pytest.fixture
80+
def merra2_file_path(tmp_path): # pylint: disable=too-many-statements
81+
"""
82+
Generates a temporary NetCDF file that STRICTLY mimics the structure of a
83+
NASA MERRA-2 'inst3_3d_asm_Np' file (Assimilated Meteorological Fields)
84+
because MERRA-2 files are too large.
85+
86+
"""
87+
file_path = tmp_path / "MERRA2_300.inst3_3d_asm_Np.20230620.nc4"
88+
89+
with netCDF4.Dataset(file_path, "w", format="NETCDF4") as nc:
90+
# Define Dimensions
91+
nc.createDimension("lon", 5)
92+
nc.createDimension("lat", 5)
93+
nc.createDimension("lev", 5)
94+
nc.createDimension("time", None)
95+
96+
# Define Coordinates
97+
lon = nc.createVariable("lon", "f8", ("lon",))
98+
lon.units = "degrees_east"
99+
lon[:] = np.linspace(-180, 180, 5)
100+
101+
lat = nc.createVariable("lat", "f8", ("lat",))
102+
lat.units = "degrees_north"
103+
lat[:] = np.linspace(-90, 90, 5)
104+
105+
lev = nc.createVariable("lev", "f8", ("lev",))
106+
lev.units = "hPa"
107+
lev[:] = np.linspace(1000, 100, 5)
108+
109+
time = nc.createVariable("time", "i4", ("time",))
110+
time.units = "minutes since 2023-06-20 00:00:00"
111+
time[:] = [720]
112+
113+
# Define Data Variables
114+
# NetCDF names are uppercase ("T") to match NASA specs.
115+
116+
t_var = nc.createVariable("T", "f4", ("time", "lev", "lat", "lon"))
117+
t_var.units = "K"
118+
t_var[:] = np.full((1, 5, 5, 5), 300.0)
119+
120+
u_var = nc.createVariable("U", "f4", ("time", "lev", "lat", "lon"))
121+
u_var.units = "m s-1"
122+
u_var[:] = np.full((1, 5, 5, 5), 10.0)
123+
124+
v_var = nc.createVariable("V", "f4", ("time", "lev", "lat", "lon"))
125+
v_var.units = "m s-1"
126+
v_var[:] = np.full((1, 5, 5, 5), 5.0)
127+
128+
h_var = nc.createVariable("H", "f4", ("time", "lev", "lat", "lon"))
129+
h_var.units = "m"
130+
h_var[:] = np.linspace(0, 10000, 5).reshape(1, 5, 1, 1) * np.ones((1, 5, 5, 5))
131+
132+
# PHIS: Surface Geopotential Height [m2 s-2]
133+
phis = nc.createVariable("PHIS", "f4", ("time", "lat", "lon"))
134+
phis.units = "m2 s-2"
135+
136+
# We set PHIS to 9806.65 (Energy).
137+
# We expect the code to divide by ~9.8 and get ~1000.0 (Height).
138+
phis[:] = np.full((1, 5, 5), 9806.65)
139+
140+
return str(file_path)

tests/integration/environment/test_environment.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,3 +258,33 @@ def test_cmc_atmosphere(mock_show, example_spaceport_env): # pylint: disable=un
258258
"""
259259
example_spaceport_env.set_atmospheric_model(type="Ensemble", file="CMC")
260260
assert example_spaceport_env.all_info() is None
261+
262+
263+
def test_merra2_full_specification_compliance(merra2_file_path, example_plain_env):
264+
"""
265+
Tests that RocketPy loads a file complying with NASA MERRA-2 file specs.
266+
"""
267+
# 1. Initialize Environment
268+
env = example_plain_env
269+
env.set_date((2023, 6, 20, 12))
270+
env.set_location(latitude=0, longitude=0)
271+
272+
# 2. Force standard gravity to a known constant for precise math checking
273+
env.standard_g = 9.80665
274+
275+
# 3. Load the Atmospheric Model (Using the file generated above)
276+
env.set_atmospheric_model(
277+
type="Reanalysis", file=merra2_file_path, dictionary="MERRA2"
278+
)
279+
280+
# 4. Verify Unit Conversion (Energy -> Height)
281+
# Input: 9806.65 m2/s2
282+
# Expected: 1000.0 m
283+
print(f"Calculated Elevation: {env.elevation} m")
284+
assert abs(env.elevation - 1000.0) < 1e-6, (
285+
f"Failed to convert PHIS (m2/s2) to meters. Got {env.elevation}, expected 1000.0"
286+
)
287+
288+
# 5. Verify Variable Mapping
289+
assert abs(env.temperature(0) - 300.0) < 1e-6
290+
assert env.wind_speed(0) > 0

0 commit comments

Comments
 (0)