Skip to content

Commit ae46e55

Browse files
authored
Enh/thrustcurve api cache (#881)
* ENH: Add persistent caching for ThrustCurve API and updated documentation * REV: Address feedback (Robustness, Docstrings, Changelog) * DOCS: Restore CHANGELOG history and add new entry * REV: Final (hopefully) polish (Robustness test, Coverage, Docs)
1 parent 02c1827 commit ae46e55

File tree

4 files changed

+184
-13
lines changed

4 files changed

+184
-13
lines changed

CHANGELOG.md

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

3333
### Added
34+
35+
- ENH: Add persistent caching for ThrustCurve API (#881)
36+
3437
ENH: Compatibility with MERRA-2 atmosphere reanalysis files [#825](https://github.com/RocketPy-Team/RocketPy/pull/825)
3538

3639
- ENH: Enable only radial burning [#815](https://github.com/RocketPy-Team/RocketPy/pull/815)

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

tests/unit/motors/test_genericmotor.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import base64
2+
import pathlib
23

34
import numpy as np
45
import pytest
56
import requests
67
import scipy.integrate
78

89
from rocketpy import Function, Motor
10+
from rocketpy.motors.motor import GenericMotor
911

1012
BURN_TIME = (2, 7)
1113

@@ -333,3 +335,108 @@ def test_load_from_thrustcurve_api(monkeypatch, generic_motor):
333335
)
334336
with pytest.raises(ValueError, match=msg):
335337
type(generic_motor).load_from_thrustcurve_api("FakeMotor")
338+
339+
340+
def test_thrustcurve_api_cache(monkeypatch, tmp_path):
341+
"""Tests that ThrustCurve API caching works correctly."""
342+
343+
eng_path = "data/motors/cesaroni/Cesaroni_M1670.eng"
344+
with open(eng_path, "rb") as f:
345+
encoded = base64.b64encode(f.read()).decode("utf-8")
346+
347+
search_json = {"results": [{"motorId": "12345"}]}
348+
download_json = {"results": [{"data": encoded}]}
349+
350+
# Patch requests.get to return mocked API responses
351+
monkeypatch.setattr(requests, "get", _mock_get(search_json, download_json))
352+
353+
# Patch the module-level CACHE_DIR to use the tmp_path
354+
monkeypatch.setattr("rocketpy.motors.motor.CACHE_DIR", tmp_path)
355+
356+
# First call writes to cache
357+
motor1 = GenericMotor.load_from_thrustcurve_api("M1670")
358+
cache_file = tmp_path / "M1670.eng.b64"
359+
assert cache_file.exists()
360+
361+
# Second call reads from cache; API should not be called
362+
monkeypatch.setattr(
363+
requests,
364+
"get",
365+
lambda *args, **kwargs: (_ for _ in ()).throw(
366+
RuntimeError("API should not be called")
367+
),
368+
)
369+
motor2 = GenericMotor.load_from_thrustcurve_api("M1670")
370+
assert motor2.thrust.y_array == pytest.approx(motor1.thrust.y_array)
371+
372+
# Bypass cache with no_cache=True
373+
monkeypatch.setattr(requests, "get", _mock_get(search_json, download_json))
374+
motor3 = GenericMotor.load_from_thrustcurve_api("M1670", no_cache=True)
375+
assert motor3.thrust.y_array == pytest.approx(motor1.thrust.y_array)
376+
377+
378+
def test_thrustcurve_api_cache_robustness(monkeypatch, tmp_path): # pylint: disable=too-many-statements
379+
"""
380+
Tests exception handling for cache operations to ensure 100% coverage.
381+
Simulates OS errors for mkdir, write, and read operations.
382+
"""
383+
384+
# 1. Setup Mock API to return success
385+
eng_path = "data/motors/cesaroni/Cesaroni_M1670.eng"
386+
with open(eng_path, "rb") as f:
387+
encoded = base64.b64encode(f.read()).decode("utf-8")
388+
389+
search_json = {"results": [{"motorId": "12345"}]}
390+
download_json = {"results": [{"data": encoded}]}
391+
monkeypatch.setattr(requests, "get", _mock_get(search_json, download_json))
392+
393+
# Point cache to tmp_path so we don't mess with real home
394+
monkeypatch.setattr("rocketpy.motors.motor.CACHE_DIR", tmp_path)
395+
396+
# CASE 1: mkdir fails -> should warn and continue (disable caching)
397+
original_mkdir = pathlib.Path.mkdir
398+
399+
def mock_mkdir_fail(self, *args, **kwargs):
400+
if self == tmp_path:
401+
raise OSError("Simulated mkdir error")
402+
return original_mkdir(self, *args, **kwargs)
403+
404+
monkeypatch.setattr(pathlib.Path, "mkdir", mock_mkdir_fail)
405+
406+
with pytest.warns(UserWarning, match="Could not create cache directory"):
407+
GenericMotor.load_from_thrustcurve_api("M1670")
408+
409+
# Reset mkdir logic for next test
410+
monkeypatch.setattr(pathlib.Path, "mkdir", original_mkdir)
411+
412+
# CASE 2: write_text fails -> should warn and continue
413+
original_write = pathlib.Path.write_text
414+
415+
def mock_write_fail(self, *args, **kwargs):
416+
if "M1670.eng.b64" in str(self):
417+
raise OSError("Simulated write error")
418+
return original_write(self, *args, **kwargs)
419+
420+
monkeypatch.setattr(pathlib.Path, "write_text", mock_write_fail)
421+
422+
with pytest.warns(RuntimeWarning, match="Could not write to cache file"):
423+
GenericMotor.load_from_thrustcurve_api("M1670")
424+
425+
# Reset write logic
426+
monkeypatch.setattr(pathlib.Path, "write_text", original_write)
427+
428+
# CASE 3: read_text fails (corrupt file) -> should warn and fetch fresh
429+
cache_file = tmp_path / "M1670.eng.b64"
430+
cache_file.write_text("corrupted_data")
431+
432+
original_read = pathlib.Path.read_text
433+
434+
def mock_read_fail(self, *args, **kwargs):
435+
if self == cache_file:
436+
raise UnicodeDecodeError("utf-8", b"", 0, 1, "bad")
437+
return original_read(self, *args, **kwargs)
438+
439+
monkeypatch.setattr(pathlib.Path, "read_text", mock_read_fail)
440+
441+
with pytest.warns(UserWarning, match="Failed to read cached motor file"):
442+
GenericMotor.load_from_thrustcurve_api("M1670")

0 commit comments

Comments
 (0)