Skip to content

Commit 157042d

Browse files
Marchma0Monta120
andauthored
Enh/motor thrustcurve api (#870)
* Add the function to create a motor from the API of thrustcurve and the test * Improve load_from_thrustcurve_api and test_load_from_thrustcurve_api with clean imports * Clean up load_from_thrustcurve_api and improve test_load_from_thrustcurve_api with exception testing * Use warnings.warn() in load_from_thrustcurve_api when motor is found (as requested) * Added documentation for the load_from_thrustcurve_api method into the genericmotors.rst file * Changes to conform to lint * Fixed Pylint errors * Update docs/user/motors/genericmotor.rst Co-authored-by: Gui-FernandesBR <63590233+Gui-FernandesBR@users.noreply.github.com> * Update docs/user/motors/genericmotor.rst Co-authored-by: Gui-FernandesBR <63590233+Gui-FernandesBR@users.noreply.github.com> * Update docs/user/motors/genericmotor.rst Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Set private the method call_thrustcurve_api * Update docs/user/motors/genericmotor.rst Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update tests/unit/motors/test_genericmotor.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update rocketpy/motors/motor.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update docs/user/motors/genericmotor.rst Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update docs/user/motors/genericmotor.rst Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update docs/user/motors/genericmotor.rst Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Modify the changelog * Bug fix * Refactor GenericMotor class: comment out unused designation and manufacturer variables * Add the function to create a motor from the API of thrustcurve and the test * Improve load_from_thrustcurve_api and test_load_from_thrustcurve_api with clean imports * Clean up load_from_thrustcurve_api and improve test_load_from_thrustcurve_api with exception testing * Use warnings.warn() in load_from_thrustcurve_api when motor is found (as requested) * Added documentation for the load_from_thrustcurve_api method into the genericmotors.rst file * Changes to conform to lint * Fixed Pylint errors * Update docs/user/motors/genericmotor.rst Co-authored-by: Gui-FernandesBR <63590233+Gui-FernandesBR@users.noreply.github.com> * Update docs/user/motors/genericmotor.rst Co-authored-by: Gui-FernandesBR <63590233+Gui-FernandesBR@users.noreply.github.com> * Update docs/user/motors/genericmotor.rst Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Set private the method call_thrustcurve_api * Update docs/user/motors/genericmotor.rst Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update tests/unit/motors/test_genericmotor.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update rocketpy/motors/motor.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update docs/user/motors/genericmotor.rst Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update docs/user/motors/genericmotor.rst Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update docs/user/motors/genericmotor.rst Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Modify the changelog * Bug fix --------- Co-authored-by: monta <montadharettaieb@gmail.com>
1 parent 9da4565 commit 157042d

File tree

4 files changed

+269
-1
lines changed

4 files changed

+269
-1
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ Attention: The newest changes should be on top -->
3232

3333
### Added
3434

35+
- ENH: Add thrustcurve api integration to retrieve motor eng data [#870](https://github.com/RocketPy-Team/RocketPy/pull/870)
3536
- ENH: Custom Exception errors and messages [#285](https://github.com/RocketPy-Team/RocketPy/issues/285)
3637

3738
### Changed

docs/user/motors/genericmotor.rst

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,3 +106,30 @@ note that the user can still provide the parameters manually if needed.
106106
The ``load_from_eng_file`` method is a very useful tool for simulating motors \
107107
when the user does not have all the information required to build a ``SolidMotor`` yet.
108108

109+
The ``load_from_thrustcurve_api`` method
110+
----------------------------------------
111+
112+
The ``GenericMotor`` class provides a convenience loader that downloads a temporary
113+
`.eng` file from the ThrustCurve.org public API and builds a ``GenericMotor``
114+
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.
117+
118+
.. note::
119+
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.
123+
124+
Example
125+
-------
126+
127+
.. jupyter-execute::
128+
129+
from rocketpy.motors import GenericMotor
130+
131+
# Build a motor by name (requires network access)
132+
motor = GenericMotor.load_from_thrustcurve_api("M1670")
133+
134+
# Use the motor as usual
135+
motor.info()

rocketpy/motors/motor.py

Lines changed: 119 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1+
import base64
12
import re
3+
import tempfile
24
import warnings
35
import xml.etree.ElementTree as ET
46
from abc import ABC, abstractmethod
57
from functools import cached_property
6-
from os import path
8+
from os import path, remove
79

810
import numpy as np
11+
import requests
912

1013
from ..mathutils.function import Function, funcify_method
1114
from ..plots.motor_plots import _MotorPlots
@@ -1914,6 +1917,121 @@ def load_from_rse_file(
19141917
coordinate_system_orientation=coordinate_system_orientation,
19151918
)
19161919

1920+
@staticmethod
1921+
def _call_thrustcurve_api(name: str):
1922+
"""
1923+
Download a .eng file from the ThrustCurve API
1924+
based on the given motor name.
1925+
1926+
Parameters
1927+
----------
1928+
name : str
1929+
The motor name according to the API (e.g., "Cesaroni_M1670" or "M1670").
1930+
Both manufacturer-prefixed and shorthand names are commonly used; if multiple
1931+
motors match the search, the first result is used.
1932+
1933+
Returns
1934+
-------
1935+
data_base64 : str
1936+
The .eng file of the motor in base64
1937+
1938+
Raises
1939+
------
1940+
ValueError
1941+
If no motor is found or if the downloaded .eng data is missing.
1942+
requests.exceptions.RequestException
1943+
If a network or HTTP error occurs during the API call.
1944+
"""
1945+
base_url = "https://www.thrustcurve.org/api/v1"
1946+
1947+
# Step 1. Search motor
1948+
response = requests.get(f"{base_url}/search.json", params={"commonName": name})
1949+
response.raise_for_status()
1950+
data = response.json()
1951+
1952+
if not data.get("results"):
1953+
raise ValueError(
1954+
f"No motor found for name '{name}'. "
1955+
"Please verify the motor name format (e.g., 'Cesaroni_M1670' or 'M1670') and try again."
1956+
)
1957+
1958+
motor_info = data["results"][0]
1959+
motor_id = motor_info.get("motorId")
1960+
# NOTE: commented bc we don't use it, but keeping for possible future use
1961+
# designation = motor_info.get("designation", "").replace("/", "-")
1962+
# manufacturer = motor_info.get("manufacturer", "")
1963+
1964+
# Step 2. Download the .eng file
1965+
dl_response = requests.get(
1966+
f"{base_url}/download.json",
1967+
params={"motorIds": motor_id, "format": "RASP", "data": "file"},
1968+
)
1969+
dl_response.raise_for_status()
1970+
dl_data = dl_response.json()
1971+
1972+
if not dl_data.get("results"):
1973+
raise ValueError(
1974+
f"No .eng file found for motor '{name}' in the ThrustCurve API."
1975+
)
1976+
1977+
data_base64 = dl_data["results"][0].get("data")
1978+
if not data_base64:
1979+
raise ValueError(
1980+
f"Downloaded .eng data for motor '{name}' is empty or invalid."
1981+
)
1982+
return data_base64
1983+
1984+
@staticmethod
1985+
def load_from_thrustcurve_api(name: str, **kwargs):
1986+
"""
1987+
Creates a Motor instance by downloading a .eng file from the ThrustCurve API
1988+
based on the given motor name.
1989+
1990+
Parameters
1991+
----------
1992+
name : str
1993+
The motor name according to the API (e.g., "Cesaroni_M1670" or "M1670").
1994+
Both manufacturer-prefixed and shorthand names are commonly used; if multiple
1995+
motors match the search, the first result is used.
1996+
**kwargs :
1997+
Additional arguments passed to the Motor constructor or loader, such as
1998+
dry_mass, nozzle_radius, etc.
1999+
2000+
Returns
2001+
-------
2002+
instance : GenericMotor
2003+
A new GenericMotor instance initialized using the downloaded .eng file.
2004+
2005+
Raises
2006+
------
2007+
ValueError
2008+
If no motor is found or if the downloaded .eng data is missing.
2009+
requests.exceptions.RequestException
2010+
If a network or HTTP error occurs during the API call.
2011+
"""
2012+
2013+
data_base64 = GenericMotor._call_thrustcurve_api(name)
2014+
data_bytes = base64.b64decode(data_base64)
2015+
2016+
# Step 3. Create the motor from the .eng file
2017+
tmp_path = None
2018+
try:
2019+
# create a temporary file that persists until we explicitly remove it
2020+
with tempfile.NamedTemporaryFile(suffix=".eng", delete=False) as tmp_file:
2021+
tmp_file.write(data_bytes)
2022+
tmp_file.flush()
2023+
tmp_path = tmp_file.name
2024+
2025+
return GenericMotor.load_from_eng_file(tmp_path, **kwargs)
2026+
finally:
2027+
# Ensuring the temporary file is removed
2028+
if tmp_path and path.exists(tmp_path):
2029+
try:
2030+
remove(tmp_path)
2031+
except OSError:
2032+
# If cleanup fails, don't raise: we don't want to mask prior exceptions.
2033+
pass
2034+
19172035
def all_info(self):
19182036
"""Prints out all data and graphs available about the Motor."""
19192037
# Print motor details

tests/unit/motors/test_genericmotor.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
import base64
2+
13
import numpy as np
24
import pytest
5+
import requests
36
import scipy.integrate
47

58
from rocketpy import Function, Motor
@@ -211,3 +214,122 @@ def test_load_from_rse_file(generic_motor):
211214
assert thrust_curve[0][1] == 0.0 # First thrust point
212215
assert thrust_curve[-1][0] == 2.2 # Last point of time
213216
assert thrust_curve[-1][1] == 0.0 # Last thrust point
217+
218+
219+
class MockResponse:
220+
"""Mocked response for requests."""
221+
222+
def __init__(self, json_data):
223+
self._json_data = json_data
224+
225+
def json(self):
226+
return self._json_data
227+
228+
def raise_for_status(self):
229+
return None
230+
231+
232+
def _mock_get(search_results=None, download_results=None):
233+
"""Return a mock_get function with predefined search/download results."""
234+
235+
def _get(url, **_kwargs):
236+
if "search.json" in url:
237+
return MockResponse(search_results or {"results": []})
238+
if "download.json" in url:
239+
return MockResponse(download_results or {"results": []})
240+
raise RuntimeError(f"Unexpected URL: {url}")
241+
242+
return _get
243+
244+
245+
# Module-level constant for expected motor specs
246+
EXPECTED_MOTOR_SPECS = {
247+
"burn_time": (0, 3.9),
248+
"dry_mass": 2.130,
249+
"propellant_initial_mass": 3.101,
250+
"chamber_radius": 75 / 1000,
251+
"chamber_height": 757 / 1000,
252+
"nozzle_radius": (75 / 1000) * 0.85,
253+
"average_thrust": 1545.218,
254+
"total_impulse": 6026.350,
255+
"max_thrust": 2200.0,
256+
"exhaust_velocity": 1943.357,
257+
"chamber_position": 0,
258+
}
259+
260+
261+
def assert_motor_specs(motor):
262+
specs = EXPECTED_MOTOR_SPECS
263+
assert motor.burn_time == specs["burn_time"]
264+
assert motor.dry_mass == specs["dry_mass"]
265+
assert motor.propellant_initial_mass == specs["propellant_initial_mass"]
266+
assert motor.chamber_radius == specs["chamber_radius"]
267+
assert motor.chamber_height == specs["chamber_height"]
268+
assert motor.chamber_position == specs["chamber_position"]
269+
assert motor.average_thrust == pytest.approx(specs["average_thrust"])
270+
assert motor.total_impulse == pytest.approx(specs["total_impulse"])
271+
assert motor.exhaust_velocity.average(*specs["burn_time"]) == pytest.approx(
272+
specs["exhaust_velocity"]
273+
)
274+
assert motor.max_thrust == pytest.approx(specs["max_thrust"])
275+
assert motor.nozzle_radius == pytest.approx(specs["nozzle_radius"])
276+
277+
278+
def test_load_from_thrustcurve_api(monkeypatch, generic_motor):
279+
"""Tests GenericMotor.load_from_thrustcurve_api with mocked API."""
280+
281+
eng_path = "data/motors/cesaroni/Cesaroni_M1670.eng"
282+
with open(eng_path, "rb") as f:
283+
encoded = base64.b64encode(f.read()).decode("utf-8")
284+
285+
search_json = {
286+
"results": [
287+
{
288+
"motorId": "12345",
289+
"designation": "Cesaroni_M1670",
290+
"manufacturer": "Cesaroni",
291+
}
292+
]
293+
}
294+
download_json = {"results": [{"data": encoded}]}
295+
monkeypatch.setattr(requests, "get", _mock_get(search_json, download_json))
296+
monkeypatch.setattr(requests.Session, "get", _mock_get(search_json, download_json))
297+
298+
motor = type(generic_motor).load_from_thrustcurve_api("M1670")
299+
300+
assert_motor_specs(motor)
301+
302+
_, _, points = Motor.import_eng(eng_path)
303+
assert motor.thrust.y_array == pytest.approx(
304+
Function(points, "Time (s)", "Thrust (N)", "linear", "zero").y_array
305+
)
306+
307+
error_cases = [
308+
("No motor found", {"results": []}, None),
309+
(
310+
"No .eng file found",
311+
{
312+
"results": [
313+
{"motorId": "123", "designation": "Fake", "manufacturer": "Test"}
314+
]
315+
},
316+
{"results": []},
317+
),
318+
(
319+
"Downloaded .eng data",
320+
{
321+
"results": [
322+
{"motorId": "123", "designation": "Fake", "manufacturer": "Test"}
323+
]
324+
},
325+
{"results": [{"data": ""}]},
326+
),
327+
]
328+
329+
for msg, search_res, download_res in error_cases:
330+
monkeypatch.setattr(requests, "get", _mock_get(search_res, download_res))
331+
monkeypatch.setattr(
332+
requests.Session, "get", _mock_get(search_res, download_res)
333+
)
334+
with pytest.raises(ValueError, match=msg):
335+
type(generic_motor).load_from_thrustcurve_api("FakeMotor")

0 commit comments

Comments
 (0)