Skip to content

Commit e6c0351

Browse files
committed
ENH: Add persistent caching for ThrustCurve API and updated documentation
1 parent 02c1827 commit e6c0351

4 files changed

Lines changed: 100 additions & 18 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,14 @@ 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)
3534

36-
- ENH: Enable only radial burning [#815](https://github.com/RocketPy-Team/RocketPy/pull/815)
37-
- 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)
35+
- Add caching to ThrustCurve API requests to avoid repeated downloads for the same motor (#878)
36+
- GenericMotor.load_from_thrustcurve_api(name, no_cache=False) to download motors directly from the ThrustCurve API.
37+
- Automatic caching of downloaded .eng files in ~/.rocketpy_cache.
38+
- no_cache option to bypass the cache and force a fresh API download.
3939

4040
### Changed
41+
- Unit tests for GenericMotor now include coverage for API caching and error handling.
4142

4243
### Fixed
4344

@@ -78,7 +79,6 @@ ENH: Compatibility with MERRA-2 atmosphere reanalysis files [#825](https://githu
7879
## [v1.10.0] - 2025-05-16
7980

8081
### Added
81-
8282
- ENH: Support for ND arithmetic in Function class. [#810] (https://github.com/RocketPy-Team/RocketPy/pull/810)
8383
- ENH: allow users to provide custom samplers [#803](https://github.com/RocketPy-Team/RocketPy/pull/803)
8484
- ENH: Implement Multivariate Rejection Sampling (MRS) [#738] (https://github.com/RocketPy-Team/RocketPy/pull/738)

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

rocketpy/motors/motor.py

Lines changed: 25 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,12 @@
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+
CACHE_DIR.mkdir(exist_ok=True)
23+
24+
2025
class Motor(ABC):
2126
"""Abstract class to specify characteristics and useful operations for
2227
motors. Cannot be instantiated.
@@ -1918,7 +1923,7 @@ def load_from_rse_file(
19181923
)
19191924

19201925
@staticmethod
1921-
def _call_thrustcurve_api(name: str):
1926+
def _call_thrustcurve_api(name: str, no_cache: bool = False):
19221927
"""
19231928
Download a .eng file from the ThrustCurve API
19241929
based on the given motor name.
@@ -1929,6 +1934,8 @@ def _call_thrustcurve_api(name: str):
19291934
The motor name according to the API (e.g., "Cesaroni_M1670" or "M1670").
19301935
Both manufacturer-prefixed and shorthand names are commonly used; if multiple
19311936
motors match the search, the first result is used.
1937+
no_cache : bool, optional
1938+
If True, forces a new API fetch even if the motor is cached.
19321939
19331940
Returns
19341941
-------
@@ -1941,9 +1948,21 @@ def _call_thrustcurve_api(name: str):
19411948
If no motor is found or if the downloaded .eng data is missing.
19421949
requests.exceptions.RequestException
19431950
If a network or HTTP error occurs during the API call.
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+
# File path in the cache
1958+
safe_name = re.sub(r"[^A-Za-z0-9_.-]", "_", name)
1959+
cache_file = CACHE_DIR / f"{safe_name}.eng.b64"
19461960

1961+
# Use cached file if it exists and no_cache is False
1962+
if cache_file.exists() and not no_cache:
1963+
return cache_file.read_text()
1964+
1965+
base_url = "https://www.thrustcurve.org/api/v1"
19471966
# Step 1. Search motor
19481967
response = requests.get(f"{base_url}/search.json", params={"commonName": name})
19491968
response.raise_for_status()
@@ -1979,10 +1998,11 @@ def _call_thrustcurve_api(name: str):
19791998
raise ValueError(
19801999
f"Downloaded .eng data for motor '{name}' is empty or invalid."
19812000
)
2001+
cache_file.write_text(data_base64)
19822002
return data_base64
19832003

19842004
@staticmethod
1985-
def load_from_thrustcurve_api(name: str, **kwargs):
2005+
def load_from_thrustcurve_api(name: str, no_cache: bool = False, **kwargs):
19862006
"""
19872007
Creates a Motor instance by downloading a .eng file from the ThrustCurve API
19882008
based on the given motor name.
@@ -2010,7 +2030,7 @@ def load_from_thrustcurve_api(name: str, **kwargs):
20102030
If a network or HTTP error occurs during the API call.
20112031
"""
20122032

2013-
data_base64 = GenericMotor._call_thrustcurve_api(name)
2033+
data_base64 = GenericMotor._call_thrustcurve_api(name, no_cache=no_cache)
20142034
data_bytes = base64.b64decode(data_base64)
20152035

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

tests/unit/motors/test_genericmotor.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import scipy.integrate
77

88
from rocketpy import Function, Motor
9+
from rocketpy.motors.motor import GenericMotor
910

1011
BURN_TIME = (2, 7)
1112

@@ -333,3 +334,41 @@ def test_load_from_thrustcurve_api(monkeypatch, generic_motor):
333334
)
334335
with pytest.raises(ValueError, match=msg):
335336
type(generic_motor).load_from_thrustcurve_api("FakeMotor")
337+
338+
339+
def test_thrustcurve_api_cache(monkeypatch, tmp_path):
340+
"""Tests that ThrustCurve API is caching works correctly."""
341+
342+
eng_path = "data/motors/cesaroni/Cesaroni_M1670.eng"
343+
with open(eng_path, "rb") as f:
344+
encoded = base64.b64encode(f.read()).decode("utf-8")
345+
346+
search_json = {"results": [{"motorId": "12345"}]}
347+
download_json = {"results": [{"data": encoded}]}
348+
349+
# Patch requests.get to return mocked API responses
350+
monkeypatch.setattr(requests, "get", _mock_get(search_json, download_json))
351+
352+
# Patch the module-level CACHE_DIR to use the tmp_path
353+
monkeypatch.setattr("rocketpy.motors.motor.CACHE_DIR", tmp_path)
354+
355+
# First call writes to cache
356+
motor1 = GenericMotor.load_from_thrustcurve_api("M1670")
357+
cache_file = tmp_path / "M1670.eng.b64"
358+
assert cache_file.exists()
359+
360+
# Second call reads from cache; API should not be called
361+
monkeypatch.setattr(
362+
requests,
363+
"get",
364+
lambda *args, **kwargs: (_ for _ in ()).throw(
365+
RuntimeError("API should not be called")
366+
),
367+
)
368+
motor2 = GenericMotor.load_from_thrustcurve_api("M1670")
369+
assert motor2.thrust.y_array == pytest.approx(motor1.thrust.y_array)
370+
371+
# Bypass cache with no_cache=True
372+
monkeypatch.setattr(requests, "get", _mock_get(search_json, download_json))
373+
motor3 = GenericMotor.load_from_thrustcurve_api("M1670", no_cache=True)
374+
assert motor3.thrust.y_array == pytest.approx(motor1.thrust.y_array)

0 commit comments

Comments
 (0)