Skip to content

Commit fe2052b

Browse files
authored
Merge branch 'develop' into copilot/enhance-drag-curve-functionality
2 parents cab76fa + 793e5f6 commit fe2052b

18 files changed

Lines changed: 443 additions & 69 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,20 @@ Attention: The newest changes should be on top -->
3333
### Added
3434

3535
- ENH: Add multi-dimensional drag coefficient support (Cd as function of M, Re, α) [#875](https://github.com/RocketPy-Team/RocketPy/pull/875)
36+
- ENH: Add save functionality to `_MonteCarloPlots.all` method [#848](https://github.com/RocketPy-Team/RocketPy/pull/848)
37+
- ENH: Add persistent caching for ThrustCurve API [#881](https://github.com/RocketPy-Team/RocketPy/pull/881)
38+
- ENH: Compatibility with MERRA-2 atmosphere reanalysis files [#825](https://github.com/RocketPy-Team/RocketPy/pull/825)
3639
- ENH: Enable only radial burning [#815](https://github.com/RocketPy-Team/RocketPy/pull/815)
3740
- ENH: Add thrustcurve api integration to retrieve motor eng data [#870](https://github.com/RocketPy-Team/RocketPy/pull/870)
38-
- ENH: Custom Exception errors and messages [#285](https://github.com/RocketPy-Team/RocketPy/issues/285)
41+
- ENH: custom warning no motor or aerosurface [#871](https://github.com/RocketPy-Team/RocketPy/pull/871)
3942

4043
### Changed
4144

45+
-
46+
4247
### Fixed
4348

49+
- BUG: Fix parallel Monte Carlo simulation showing incorrect iteration count [#806](https://github.com/RocketPy-Team/RocketPy/pull/806)
4450
- BUG: Fix CSV column header spacing in FlightDataExporter [#864](https://github.com/RocketPy-Team/RocketPy/issues/864)
4551

4652

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
^^^^^^^^^^^^^^^^^^^^^^^

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,7 @@ Other profiles can be derived from it, however, winds are automatically set to
2222

2323
env.plots.atmospheric_model()
2424

25-
.. skip one line with |
26-
|
25+
|
2726
2827
The International Standard Atmosphere can also be reset at any time by using the
2928
:meth:`rocketpy.Environment.set_atmospheric_model` method. For example:

docs/user/first_simulation.rst

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ regarding the launch site:
9090

9191
| This roughly corresponds to the location of Spaceport America, New Mexico.
9292
93+
9394
Next, we need to specify the atmospheric model to be used. In this example,
9495
we will get GFS forecasts data from tomorrow.
9596

@@ -110,6 +111,7 @@ of tomorrow:
110111

111112
| Now we set the atmospheric model to be used:
112113
114+
113115
.. jupyter-execute::
114116

115117
env.set_atmospheric_model(type="Forecast", file="GFS")
@@ -189,6 +191,7 @@ to specify several parameters:
189191

190192
| We can see its characteristics by calling the info method:
191193
194+
192195
.. jupyter-execute::
193196

194197
Pro75M1670.info()
@@ -637,8 +640,9 @@ and the rocket Mach number (see :meth:`rocketpy.Flight.mach_number`) to the file
637640
)
638641

639642
| As you can see, the first argument is the file name to be created. The following
640-
arguments are the attributes to be exported. We can check the file by reading it
641-
with :func:`pandas.read_csv`:
643+
arguments are the attributes to be exported. We can check the file by reading it
644+
with :func:`pandas.read_csv`:
645+
642646

643647
.. jupyter-execute::
644648

@@ -647,8 +651,9 @@ with :func:`pandas.read_csv`:
647651
pd.read_csv("calisto_flight_data.csv")
648652

649653
| The file header specifies the meaning of each column. The time samples are
650-
obtained from the simulation solver steps. To export the data at a different
651-
sampling rate, use the ``time_step`` argument:
654+
obtained from the simulation solver steps. To export the data at a different
655+
sampling rate, use the ``time_step`` argument:
656+
652657

653658
.. jupyter-execute::
654659

docs/user/motors/genericmotor.rst

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -109,17 +109,29 @@ note that the user can still provide the parameters manually if needed.
109109
The ``load_from_thrustcurve_api`` method
110110
----------------------------------------
111111

112-
The ``GenericMotor`` class provides a convenience loader that downloads a temporary
112+
The ``GenericMotor`` class provides a convenience loader that downloads an
113113
`.eng` file from the ThrustCurve.org public API and builds a ``GenericMotor``
114114
instance from it. This is useful when you know a motor designation (for example
115-
``"M1670"``) but do not want to manually download and
116-
save the `.eng` file.
115+
``"M1670"``) but do not want to manually download and save the `.eng` file.
116+
117+
The method also includes automatic caching for faster repeated usage.
118+
Downloaded `.eng` files are stored in the user's RocketPy cache folder
119+
(``~/.rocketpy_cache``). When a subsequent request is made for the same motor,
120+
the cached copy is used instead of performing another network request.
121+
122+
You can bypass the cache by setting ``no_cache=True``:
123+
124+
- ``no_cache=False`` (default):
125+
Use a cached file if available; otherwise download and store it.
126+
127+
- ``no_cache=True``:
128+
Always fetch a fresh version from the API and overwrite the cache.
117129

118130
.. note::
119131

120-
This method performs network requests to the ThrustCurve API. Use it only
121-
when you have network access. For automated testing or reproducible runs,
122-
prefer using local `.eng` files.
132+
This method performs network requests to the ThrustCurve API unless a cached
133+
version exists. For automated testing or fully reproducible workflows, prefer
134+
local `.eng` files or set ``no_cache=True`` explicitly.
123135

124136
Example
125137
-------
@@ -128,8 +140,19 @@ Example
128140

129141
from rocketpy.motors import GenericMotor
130142

131-
# Build a motor by name (requires network access)
143+
# Build a motor by name (requires network access unless cached)
132144
motor = GenericMotor.load_from_thrustcurve_api("M1670")
133145

134-
# Use the motor as usual
146+
# Print the motor information
147+
motor.info()
148+
149+
Using the no_cache option
150+
-------------------------
151+
152+
If you want to force RocketPy to ignore the cache and download a fresh copy
153+
every time, use:
154+
155+
.. jupyter-execute::
156+
157+
motor = GenericMotor.load_from_thrustcurve_api("M1670", no_cache=True)
135158
motor.info()

docs/user/three_dof_simulation.rst

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ The environment setup is identical to standard simulations:
6767
elevation=1400
6868
)
6969

70-
env.set_atmospheric_model(type="StandardAtmosphere")
70+
env.set_atmospheric_model(type="standard_atmosphere")
7171

7272
Step 2: Create a PointMassMotor
7373
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
@@ -123,7 +123,7 @@ Or use a custom thrust function:
123123
.. seealso::
124124

125125
For detailed information about :class:`rocketpy.PointMassMotor` parameters,
126-
see the :ref:`API reference <api_reference>`.
126+
see the :class:`rocketpy.PointMassMotor` class documentation.
127127

128128
Step 3: Create a PointMassRocket
129129
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
@@ -279,6 +279,14 @@ Export trajectory data to CSV:
279279
"vz",
280280
)
281281

282+
283+
.. jupyter-execute::
284+
:hide-code:
285+
286+
import os
287+
os.remove("trajectory_3dof.csv")
288+
289+
282290
Complete Example
283291
----------------
284292

@@ -294,7 +302,7 @@ Here's a complete 3-DOF simulation from start to finish:
294302
longitude=-8.2889,
295303
elevation=100
296304
)
297-
env.set_atmospheric_model(type="StandardAtmosphere")
305+
env.set_atmospheric_model(type="standard_atmosphere")
298306

299307
# 2. Motor
300308
motor = PointMassMotor(
@@ -412,7 +420,6 @@ See Also
412420
- :ref:`First Simulation <firstsimulation>` - Standard 6-DOF simulation tutorial
413421
- :ref:`Rocket Class Usage <rocketusage>` - Full rocket modeling capabilities
414422
- :ref:`Flight Class Usage <flightusage>` - Complete flight simulation options
415-
- :doc:`../examples/3_dof_trial_sim` - Jupyter notebook example
416423

417424
Further Reading
418425
---------------

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

rocketpy/motors/motor.py

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from abc import ABC, abstractmethod
77
from functools import cached_property
88
from os import path, remove
9+
from pathlib import Path
910

1011
import numpy as np
1112
import requests
@@ -15,8 +16,11 @@
1516
from ..prints.motor_prints import _MotorPrints
1617
from ..tools import parallel_axis_theorem_from_com, tuple_handler
1718

18-
1919
# pylint: disable=too-many-public-methods
20+
# ThrustCurve API cache
21+
CACHE_DIR = Path.home() / ".rocketpy_cache"
22+
23+
2024
class Motor(ABC):
2125
"""Abstract class to specify characteristics and useful operations for
2226
motors. Cannot be instantiated.
@@ -1918,7 +1922,7 @@ def load_from_rse_file(
19181922
)
19191923

19201924
@staticmethod
1921-
def _call_thrustcurve_api(name: str):
1925+
def _call_thrustcurve_api(name: str, no_cache: bool = False): # pylint: disable=too-many-statements
19221926
"""
19231927
Download a .eng file from the ThrustCurve API
19241928
based on the given motor name.
@@ -1929,6 +1933,8 @@ def _call_thrustcurve_api(name: str):
19291933
The motor name according to the API (e.g., "Cesaroni_M1670" or "M1670").
19301934
Both manufacturer-prefixed and shorthand names are commonly used; if multiple
19311935
motors match the search, the first result is used.
1936+
no_cache : bool, optional
1937+
If True, forces a new API fetch even if the motor is cached.
19321938
19331939
Returns
19341940
-------
@@ -1941,9 +1947,31 @@ def _call_thrustcurve_api(name: str):
19411947
If no motor is found or if the downloaded .eng data is missing.
19421948
requests.exceptions.RequestException
19431949
If a network or HTTP error occurs during the API call.
1950+
1951+
Notes
1952+
-----
1953+
- The cache prevents multiple network requests for the same motor name across sessions.
1954+
- Cached files are stored in `~/.rocketpy_cache` and reused unless `no_cache=True`.
1955+
- Filenames are sanitized to avoid invalid characters.
19441956
"""
1945-
base_url = "https://www.thrustcurve.org/api/v1"
1957+
try:
1958+
CACHE_DIR.mkdir(exist_ok=True)
1959+
except OSError as e:
1960+
warnings.warn(f"Could not create cache directory: {e}. Caching disabled.")
1961+
no_cache = True
1962+
# File path in the cache
1963+
safe_name = re.sub(r"[^A-Za-z0-9_.-]", "_", name)
1964+
cache_file = CACHE_DIR / f"{safe_name}.eng.b64"
1965+
if not no_cache and cache_file.exists():
1966+
try:
1967+
return cache_file.read_text()
1968+
except (OSError, UnicodeDecodeError) as e:
1969+
warnings.warn(
1970+
f"Failed to read cached motor file '{cache_file}': {e}. "
1971+
"Fetching fresh data from API."
1972+
)
19461973

1974+
base_url = "https://www.thrustcurve.org/api/v1"
19471975
# Step 1. Search motor
19481976
response = requests.get(f"{base_url}/search.json", params={"commonName": name})
19491977
response.raise_for_status()
@@ -1979,10 +2007,20 @@ def _call_thrustcurve_api(name: str):
19792007
raise ValueError(
19802008
f"Downloaded .eng data for motor '{name}' is empty or invalid."
19812009
)
2010+
if not no_cache:
2011+
try:
2012+
cache_file.write_text(data_base64)
2013+
except (OSError, PermissionError) as e:
2014+
warnings.warn(
2015+
f"Could not write to cache file '{cache_file}': {e}. "
2016+
"Continuing without caching.",
2017+
RuntimeWarning,
2018+
)
2019+
19822020
return data_base64
19832021

19842022
@staticmethod
1985-
def load_from_thrustcurve_api(name: str, **kwargs):
2023+
def load_from_thrustcurve_api(name: str, no_cache: bool = False, **kwargs):
19862024
"""
19872025
Creates a Motor instance by downloading a .eng file from the ThrustCurve API
19882026
based on the given motor name.
@@ -2010,7 +2048,7 @@ def load_from_thrustcurve_api(name: str, **kwargs):
20102048
If a network or HTTP error occurs during the API call.
20112049
"""
20122050

2013-
data_base64 = GenericMotor._call_thrustcurve_api(name)
2051+
data_base64 = GenericMotor._call_thrustcurve_api(name, no_cache=no_cache)
20142052
data_bytes = base64.b64decode(data_base64)
20152053

20162054
# Step 3. Create the motor from the .eng file

0 commit comments

Comments
 (0)