From 05ab711751ca680382bcf2a18115e722507487cb Mon Sep 17 00:00:00 2001 From: Marchma0 Date: Tue, 4 Nov 2025 18:02:45 +0000 Subject: [PATCH 01/39] Add the function to create a motor from the API of thrustcurve and the test --- rocketpy/motors/motor.py | 59 ++++++++++++++++++++++++++ tests/unit/motors/test_genericmotor.py | 47 ++++++++++++++++++++ 2 files changed, 106 insertions(+) diff --git a/rocketpy/motors/motor.py b/rocketpy/motors/motor.py index 7930ed52b..cc21c822b 100644 --- a/rocketpy/motors/motor.py +++ b/rocketpy/motors/motor.py @@ -1,4 +1,6 @@ +import base64 import re +import tempfile import warnings import xml.etree.ElementTree as ET from abc import ABC, abstractmethod @@ -6,6 +8,7 @@ from os import path import numpy as np +import requests from ..mathutils.function import Function, funcify_method from ..plots.motor_plots import _MotorPlots @@ -1914,6 +1917,62 @@ def load_from_rse_file( coordinate_system_orientation=coordinate_system_orientation, ) + @staticmethod + def load_from_thrustcurve_api(name: str, **kwargs): + """ + Creates a Motor instance by downloading a .eng file from the ThrustCurve API + based on the given motor name. + + Parameters + ---------- + name : str + The motor name according to the API (e.g., "Cesaroni_M1670"). + **kwargs : + Additional arguments passed to the Motor constructor, such as dry_mass, nozzle_radius, etc. + + Returns + ------- + instance : cls + A new Motor instance initialized using the downloaded .eng file. + """ + + base_url = "https://www.thrustcurve.org/api/v1" + + # Step 1. Search motor + response = requests.get(f"{base_url}/search.json", params={"commonName": name}) + response.raise_for_status() + data = response.json() + + if not data.get("results"): + print("No motor found.") + return None + + motor = data["results"][0] + motor_id = motor["motorId"] + designation = motor["designation"].replace("/", "-") + print(f"Motor found: {designation} ({motor['manufacturer']})") + + # Step 2. Download the .eng file + dl_response = requests.get( + f"{base_url}/download.json", + params={"motorIds": motor_id, "format": "RASP", "data": "file"}, + ) + dl_response.raise_for_status() + data = dl_response.json() + + data_base64 = data["results"][0]["data"] + data_bytes = base64.b64decode(data_base64) + + # Step 3. Create the motor from the .eng file + + with tempfile.NamedTemporaryFile(suffix=".eng", delete=True) as tmp_file: + tmp_file.write(data_bytes) + tmp_file.flush() + + motor = GenericMotor.load_from_eng_file(tmp_file.name, **kwargs) + + return motor + def all_info(self): """Prints out all data and graphs available about the Motor.""" # Print motor details diff --git a/tests/unit/motors/test_genericmotor.py b/tests/unit/motors/test_genericmotor.py index 776d7b691..82ff4547e 100644 --- a/tests/unit/motors/test_genericmotor.py +++ b/tests/unit/motors/test_genericmotor.py @@ -211,3 +211,50 @@ def test_load_from_rse_file(generic_motor): assert thrust_curve[0][1] == 0.0 # First thrust point assert thrust_curve[-1][0] == 2.2 # Last point of time assert thrust_curve[-1][1] == 0.0 # Last thrust point + + +def test_load_from_thrustcurve_api(generic_motor): + """Tests the GenericMotor.load_from_thrustcurve_api method. + + Parameters + ---------- + generic_motor : rocketpy.GenericMotor + The GenericMotor object to be used in the tests. + """ + # using cesaroni data as example + burn_time = (0, 3.9) + dry_mass = 5.231 - 3.101 # 2.130 kg + propellant_initial_mass = 3.101 + chamber_radius = 75 / 1000 + chamber_height = 757 / 1000 + nozzle_radius = chamber_radius * 0.85 # 85% of chamber radius + + # Parameters from manual testing using the SolidMotor class as a reference + average_thrust = 1545.218 + total_impulse = 6026.350 + max_thrust = 2200.0 + exhaust_velocity = 1943.357 + + # creating motor from .eng file + generic_motor = generic_motor.load_from_thrustcurve_api("M1670") + + # testing relevant parameters + assert generic_motor.burn_time == burn_time + assert generic_motor.dry_mass == dry_mass + assert generic_motor.propellant_initial_mass == propellant_initial_mass + assert generic_motor.chamber_radius == chamber_radius + assert generic_motor.chamber_height == chamber_height + assert generic_motor.chamber_position == 0 + assert generic_motor.average_thrust == pytest.approx(average_thrust) + assert generic_motor.total_impulse == pytest.approx(total_impulse) + assert generic_motor.exhaust_velocity.average(*burn_time) == pytest.approx( + exhaust_velocity + ) + assert generic_motor.max_thrust == pytest.approx(max_thrust) + assert generic_motor.nozzle_radius == pytest.approx(nozzle_radius) + + # testing thrust curve + _, _, points = Motor.import_eng("data/motors/cesaroni/Cesaroni_M1670.eng") + assert generic_motor.thrust.y_array == pytest.approx( + Function(points, "Time (s)", "Thrust (N)", "linear", "zero").y_array + ) From da39fcbe39e763da46c33b7d7027e07548ccf063 Mon Sep 17 00:00:00 2001 From: monta Date: Tue, 4 Nov 2025 22:31:36 +0100 Subject: [PATCH 02/39] Improve load_from_thrustcurve_api and test_load_from_thrustcurve_api with clean imports --- rocketpy/motors/motor.py | 80 +++++++++++++++------- tests/unit/motors/test_genericmotor.py | 93 +++++++++++++++++++------- 2 files changed, 123 insertions(+), 50 deletions(-) diff --git a/rocketpy/motors/motor.py b/rocketpy/motors/motor.py index cc21c822b..8678ce65d 100644 --- a/rocketpy/motors/motor.py +++ b/rocketpy/motors/motor.py @@ -5,16 +5,18 @@ import xml.etree.ElementTree as ET from abc import ABC, abstractmethod from functools import cached_property -from os import path +from os import path, remove import numpy as np import requests - +import logging from ..mathutils.function import Function, funcify_method from ..plots.motor_plots import _MotorPlots from ..prints.motor_prints import _MotorPrints from ..tools import parallel_axis_theorem_from_com, tuple_handler +logger = logging.getLogger(__name__) + # pylint: disable=too-many-public-methods class Motor(ABC): @@ -1916,7 +1918,7 @@ def load_from_rse_file( interpolation_method=interpolation_method, coordinate_system_orientation=coordinate_system_orientation, ) - + @staticmethod def load_from_thrustcurve_api(name: str, **kwargs): """ @@ -1926,16 +1928,25 @@ def load_from_thrustcurve_api(name: str, **kwargs): Parameters ---------- name : str - The motor name according to the API (e.g., "Cesaroni_M1670"). + The motor name according to the API (e.g., "Cesaroni_M1670" or "M1670"). + Both manufacturer-prefixed and shorthand names are commonly used; if multiple + motors match the search, the first result is used. **kwargs : - Additional arguments passed to the Motor constructor, such as dry_mass, nozzle_radius, etc. + Additional arguments passed to the Motor constructor or loader, such as + dry_mass, nozzle_radius, etc. Returns ------- - instance : cls - A new Motor instance initialized using the downloaded .eng file. + instance : GenericMotor + A new GenericMotor instance initialized using the downloaded .eng file. + + Raises + ------ + ValueError + If no motor is found or if the downloaded .eng data is missing. + requests.exceptions.RequestException + If a network or HTTP error occurs during the API call. """ - base_url = "https://www.thrustcurve.org/api/v1" # Step 1. Search motor @@ -1944,13 +1955,17 @@ def load_from_thrustcurve_api(name: str, **kwargs): data = response.json() if not data.get("results"): - print("No motor found.") - return None + raise ValueError( + f"No motor found for name '{name}'. " + "Please verify the motor name format (e.g., 'Cesaroni_M1670' or 'M1670') and try again." + ) - motor = data["results"][0] - motor_id = motor["motorId"] - designation = motor["designation"].replace("/", "-") - print(f"Motor found: {designation} ({motor['manufacturer']})") + motor_info = data["results"][0] + motor_id = motor_info.get("motorId") + designation = motor_info.get("designation", "").replace("/", "-") + manufacturer = motor_info.get("manufacturer", "") + # Logging the fact that the motor was found + logger.info(f"Motor found: {designation} ({manufacturer})") # Step 2. Download the .eng file dl_response = requests.get( @@ -1958,20 +1973,37 @@ def load_from_thrustcurve_api(name: str, **kwargs): params={"motorIds": motor_id, "format": "RASP", "data": "file"}, ) dl_response.raise_for_status() - data = dl_response.json() + dl_data = dl_response.json() - data_base64 = data["results"][0]["data"] - data_bytes = base64.b64decode(data_base64) - - # Step 3. Create the motor from the .eng file + if not dl_data.get("results"): + raise ValueError(f"No .eng file found for motor '{name}' in the ThrustCurve API.") - with tempfile.NamedTemporaryFile(suffix=".eng", delete=True) as tmp_file: - tmp_file.write(data_bytes) - tmp_file.flush() + data_base64 = dl_data["results"][0].get("data") + if not data_base64: + raise ValueError(f"Downloaded .eng data for motor '{name}' is empty or invalid.") - motor = GenericMotor.load_from_eng_file(tmp_file.name, **kwargs) + data_bytes = base64.b64decode(data_base64) - return motor + # Step 3. Create the motor from the .eng file + tmp_path = None + try: + # create a temporary file that persists until we explicitly remove it + with tempfile.NamedTemporaryFile(suffix=".eng", delete=False) as tmp_file: + tmp_file.write(data_bytes) + tmp_file.flush() + tmp_path = tmp_file.name + + + motor_instance = GenericMotor.load_from_eng_file(tmp_path, **kwargs) + return motor_instance + finally: + # Ensuring the temporary file is removed + if tmp_path and path.exists(tmp_path): + try: + remove(tmp_path) + except OSError: + # If cleanup fails, don't raise: we don't want to mask prior exceptions. + pass def all_info(self): """Prints out all data and graphs available about the Motor.""" diff --git a/tests/unit/motors/test_genericmotor.py b/tests/unit/motors/test_genericmotor.py index 82ff4547e..3a4a41873 100644 --- a/tests/unit/motors/test_genericmotor.py +++ b/tests/unit/motors/test_genericmotor.py @@ -1,6 +1,9 @@ import numpy as np import pytest import scipy.integrate +import requests +import base64 + from rocketpy import Function, Motor @@ -212,16 +215,56 @@ def test_load_from_rse_file(generic_motor): assert thrust_curve[-1][0] == 2.2 # Last point of time assert thrust_curve[-1][1] == 0.0 # Last thrust point - -def test_load_from_thrustcurve_api(generic_motor): - """Tests the GenericMotor.load_from_thrustcurve_api method. - +def test_load_from_thrustcurve_api(monkeypatch, generic_motor): + """ + Tests the GenericMotor.load_from_thrustcurve_api method with mocked ThrustCurve API responses. Parameters ---------- + monkeypatch : pytest.MonkeyPatch + The pytest monkeypatch fixture for mocking. generic_motor : rocketpy.GenericMotor The GenericMotor object to be used in the tests. + """ - # using cesaroni data as example + + class MockResponse: + def __init__(self, json_data): + self._json_data = json_data + + def json(self): + return self._json_data + + def raise_for_status(self): + # Simulate a successful HTTP response (200) + return None + + # Provide mocked responses for the two endpoints: search.json and download.json + def mock_get(url, params=None): + if "search.json" in url: + # Return a mock search result with a motorId and designation + return MockResponse( + { + "results": [ + { + "motorId": "12345", + "designation": "Cesaroni_M1670", + "manufacturer": "Cesaroni", + } + ] + } + ) + elif "download.json" in url: + # Read the local .eng file and return its base64-encoded content as the API would + eng_path = "data/motors/cesaroni/Cesaroni_M1670.eng" + with open(eng_path, "rb") as f: + encoded = base64.b64encode(f.read()).decode("utf-8") + return MockResponse({"results": [{"data": encoded}]}) + else: + raise RuntimeError(f"Unexpected URL called in test mock: {url}") + + monkeypatch.setattr(requests, "get", mock_get) + + # Expected parameters from the original test burn_time = (0, 3.9) dry_mass = 5.231 - 3.101 # 2.130 kg propellant_initial_mass = 3.101 @@ -229,32 +272,30 @@ def test_load_from_thrustcurve_api(generic_motor): chamber_height = 757 / 1000 nozzle_radius = chamber_radius * 0.85 # 85% of chamber radius - # Parameters from manual testing using the SolidMotor class as a reference average_thrust = 1545.218 total_impulse = 6026.350 max_thrust = 2200.0 exhaust_velocity = 1943.357 - # creating motor from .eng file - generic_motor = generic_motor.load_from_thrustcurve_api("M1670") - - # testing relevant parameters - assert generic_motor.burn_time == burn_time - assert generic_motor.dry_mass == dry_mass - assert generic_motor.propellant_initial_mass == propellant_initial_mass - assert generic_motor.chamber_radius == chamber_radius - assert generic_motor.chamber_height == chamber_height - assert generic_motor.chamber_position == 0 - assert generic_motor.average_thrust == pytest.approx(average_thrust) - assert generic_motor.total_impulse == pytest.approx(total_impulse) - assert generic_motor.exhaust_velocity.average(*burn_time) == pytest.approx( - exhaust_velocity - ) - assert generic_motor.max_thrust == pytest.approx(max_thrust) - assert generic_motor.nozzle_radius == pytest.approx(nozzle_radius) - - # testing thrust curve + # Call the method using the class (works if it's a staticmethod); using type(generic_motor) + # ensures test works if the method is invoked on a GenericMotor instance in the project + motor = type(generic_motor).load_from_thrustcurve_api("M1670") + + # Assertions (same as original) + assert motor.burn_time == burn_time + assert motor.dry_mass == dry_mass + assert motor.propellant_initial_mass == propellant_initial_mass + assert motor.chamber_radius == chamber_radius + assert motor.chamber_height == chamber_height + assert motor.chamber_position == 0 + assert motor.average_thrust == pytest.approx(average_thrust) + assert motor.total_impulse == pytest.approx(total_impulse) + assert motor.exhaust_velocity.average(*burn_time) == pytest.approx(exhaust_velocity) + assert motor.max_thrust == pytest.approx(max_thrust) + assert motor.nozzle_radius == pytest.approx(nozzle_radius) + + # testing thrust curve equality against the local .eng import (as in original test) _, _, points = Motor.import_eng("data/motors/cesaroni/Cesaroni_M1670.eng") - assert generic_motor.thrust.y_array == pytest.approx( + assert motor.thrust.y_array == pytest.approx( Function(points, "Time (s)", "Thrust (N)", "linear", "zero").y_array ) From 9fdc704ba4111991b6c9fd573248041cb784e281 Mon Sep 17 00:00:00 2001 From: monta Date: Wed, 5 Nov 2025 08:37:52 +0100 Subject: [PATCH 03/39] Clean up load_from_thrustcurve_api and improve test_load_from_thrustcurve_api with exception testing --- rocketpy/motors/motor.py | 19 ++++---- tests/unit/motors/test_genericmotor.py | 63 ++++++++++++++++++++++++-- 2 files changed, 70 insertions(+), 12 deletions(-) diff --git a/rocketpy/motors/motor.py b/rocketpy/motors/motor.py index 8678ce65d..1f548a48d 100644 --- a/rocketpy/motors/motor.py +++ b/rocketpy/motors/motor.py @@ -1,4 +1,5 @@ import base64 +import logging import re import tempfile import warnings @@ -9,7 +10,7 @@ import numpy as np import requests -import logging + from ..mathutils.function import Function, funcify_method from ..plots.motor_plots import _MotorPlots from ..prints.motor_prints import _MotorPrints @@ -1918,7 +1919,7 @@ def load_from_rse_file( interpolation_method=interpolation_method, coordinate_system_orientation=coordinate_system_orientation, ) - + @staticmethod def load_from_thrustcurve_api(name: str, **kwargs): """ @@ -1964,7 +1965,7 @@ def load_from_thrustcurve_api(name: str, **kwargs): motor_id = motor_info.get("motorId") designation = motor_info.get("designation", "").replace("/", "-") manufacturer = motor_info.get("manufacturer", "") - # Logging the fact that the motor was found + # Logging the fact that the motor was found logger.info(f"Motor found: {designation} ({manufacturer})") # Step 2. Download the .eng file @@ -1976,11 +1977,15 @@ def load_from_thrustcurve_api(name: str, **kwargs): dl_data = dl_response.json() if not dl_data.get("results"): - raise ValueError(f"No .eng file found for motor '{name}' in the ThrustCurve API.") + raise ValueError( + f"No .eng file found for motor '{name}' in the ThrustCurve API." + ) data_base64 = dl_data["results"][0].get("data") if not data_base64: - raise ValueError(f"Downloaded .eng data for motor '{name}' is empty or invalid.") + raise ValueError( + f"Downloaded .eng data for motor '{name}' is empty or invalid." + ) data_bytes = base64.b64decode(data_base64) @@ -1993,9 +1998,7 @@ def load_from_thrustcurve_api(name: str, **kwargs): tmp_file.flush() tmp_path = tmp_file.name - - motor_instance = GenericMotor.load_from_eng_file(tmp_path, **kwargs) - return motor_instance + return GenericMotor.load_from_eng_file(tmp_path, **kwargs) finally: # Ensuring the temporary file is removed if tmp_path and path.exists(tmp_path): diff --git a/tests/unit/motors/test_genericmotor.py b/tests/unit/motors/test_genericmotor.py index 3a4a41873..306f6cd74 100644 --- a/tests/unit/motors/test_genericmotor.py +++ b/tests/unit/motors/test_genericmotor.py @@ -1,9 +1,9 @@ +import base64 + import numpy as np import pytest -import scipy.integrate import requests -import base64 - +import scipy.integrate from rocketpy import Function, Motor @@ -215,6 +215,7 @@ def test_load_from_rse_file(generic_motor): assert thrust_curve[-1][0] == 2.2 # Last point of time assert thrust_curve[-1][1] == 0.0 # Last thrust point + def test_load_from_thrustcurve_api(monkeypatch, generic_motor): """ Tests the GenericMotor.load_from_thrustcurve_api method with mocked ThrustCurve API responses. @@ -224,7 +225,7 @@ def test_load_from_thrustcurve_api(monkeypatch, generic_motor): The pytest monkeypatch fixture for mocking. generic_motor : rocketpy.GenericMotor The GenericMotor object to be used in the tests. - + """ class MockResponse: @@ -299,3 +300,57 @@ def mock_get(url, params=None): assert motor.thrust.y_array == pytest.approx( Function(points, "Time (s)", "Thrust (N)", "linear", "zero").y_array ) + + # 1. No motor found + def mock_get_no_motor(url, params=None): + if "search.json" in url: + return MockResponse({"results": []}) + return MockResponse({"results": []}) + + monkeypatch.setattr(requests, "get", mock_get_no_motor) + with pytest.raises(ValueError, match="No motor found"): + type(generic_motor).load_from_thrustcurve_api("NonexistentMotor") + + # 2. No .eng file found + def mock_get_no_eng(url, params=None): + if "search.json" in url: + return MockResponse( + { + "results": [ + { + "motorId": "123", + "designation": "Fake", + "manufacturer": "Test", + } + ] + } + ) + elif "download.json" in url: + return MockResponse({"results": []}) + return MockResponse({}) + + monkeypatch.setattr(requests, "get", mock_get_no_eng) + with pytest.raises(ValueError, match="No .eng file found"): + type(generic_motor).load_from_thrustcurve_api("FakeMotor") + + # 3. Empty .eng data + def mock_get_empty_data(url, params=None): + if "search.json" in url: + return MockResponse( + { + "results": [ + { + "motorId": "123", + "designation": "Fake", + "manufacturer": "Test", + } + ] + } + ) + elif "download.json" in url: + return MockResponse({"results": [{"data": ""}]}) + return MockResponse({}) + + monkeypatch.setattr(requests, "get", mock_get_empty_data) + with pytest.raises(ValueError, match="Downloaded .eng data"): + type(generic_motor).load_from_thrustcurve_api("FakeMotor") From d123b47c9302eed2bfcf927cf3160c63e59a3143 Mon Sep 17 00:00:00 2001 From: monta Date: Wed, 5 Nov 2025 12:43:16 +0100 Subject: [PATCH 04/39] Use warnings.warn() in load_from_thrustcurve_api when motor is found (as requested) --- rocketpy/motors/motor.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/rocketpy/motors/motor.py b/rocketpy/motors/motor.py index 1f548a48d..dc1575fb1 100644 --- a/rocketpy/motors/motor.py +++ b/rocketpy/motors/motor.py @@ -1,5 +1,4 @@ import base64 -import logging import re import tempfile import warnings @@ -16,8 +15,6 @@ from ..prints.motor_prints import _MotorPrints from ..tools import parallel_axis_theorem_from_com, tuple_handler -logger = logging.getLogger(__name__) - # pylint: disable=too-many-public-methods class Motor(ABC): @@ -1965,8 +1962,7 @@ def load_from_thrustcurve_api(name: str, **kwargs): motor_id = motor_info.get("motorId") designation = motor_info.get("designation", "").replace("/", "-") manufacturer = motor_info.get("manufacturer", "") - # Logging the fact that the motor was found - logger.info(f"Motor found: {designation} ({manufacturer})") + warnings.warn(f"Motor found: {designation} ({manufacturer})", UserWarning) # Step 2. Download the .eng file dl_response = requests.get( From d6c5deeb007e7095abb0c836274321ce8190709f Mon Sep 17 00:00:00 2001 From: monta Date: Wed, 5 Nov 2025 15:00:44 +0100 Subject: [PATCH 05/39] Added documentation for the load_from_thrustcurve_api method into the genericmotors.rst file --- docs/user/motors/genericmotor.rst | 80 +++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/docs/user/motors/genericmotor.rst b/docs/user/motors/genericmotor.rst index f9da46fd0..35745597b 100644 --- a/docs/user/motors/genericmotor.rst +++ b/docs/user/motors/genericmotor.rst @@ -106,3 +106,83 @@ note that the user can still provide the parameters manually if needed. The ``load_from_eng_file`` method is a very useful tool for simulating motors \ when the user does not have all the information required to build a ``SolidMotor`` yet. +The ``load_from_thrustcurve_api`` method +--------------------------------------- + +The ``GenericMotor`` class provides a convenience loader that downloads a temporary +`.eng` file from the ThrustCurve.org public API and builds a ``GenericMotor`` +instance from it. This is useful when you know a motor designation (for example +``"M1670"``) but do not want to manually download and +save the `.eng` file. + +.. note:: + + This method performs network requests to the ThrustCurve API. Use it only + when you have network access. For automated testing or reproducible runs, + prefer using local `.eng` files. +Signature +---------- + +``GenericMotor.load_from_thrustcurve_api(name: str, **kwargs) -> GenericMotor`` + +Parameters +---------- +name : str + Motor name to search on ThrustCurve (example: + ``"M1670"``).Only shorthand names are accepted (e.g. ``"M1670"``, not + ``"Cesaroni M1670"``). + when multiple matches occur the first result returned by the API is used. +**kwargs : + Same optional arguments accepted by the :class:`GenericMotor` constructor + (e.g. ``dry_mass``, ``nozzle_radius``, ``interpolation_method``). Any + parameters provided here override values parsed from the downloaded file. + +Returns +---------- +GenericMotor + A new ``GenericMotor`` instance created from the .eng data downloaded from + ThrustCurve. + +Raises +---------- +ValueError + If the API search returns no motor, or if the download endpoint returns no + .eng file or empty/invalid data. +requests.exceptions.RequestException + +Behavior notes +--------------- +- The method first performs a search on ThrustCurve using the provided name. + If no results are returned a :class:`ValueError` is raised. +- If a motor is found the method requests the .eng file in RASP format, decodes + it and temporarily writes it to disk; a ``GenericMotor`` is then constructed + using the existing .eng file loader. The temporary file is removed even if an + error occurs. +- The function emits a non-fatal informational warning when a motor is found + (``warnings.warn(...)``). This follows the repository convention for + non-critical messages; callers can filter or suppress warnings as needed. + +Example +--------------- + +.. jupyter-execute:: + + from rocketpy.motors import GenericMotor + + # Build a motor by name (requires network access) + motor = GenericMotor.load_from_thrustcurve_api("M1670") + + # Use the motor as usual + motor.info() + +Testing advice +--------------- +- ``pytest``'s ``caplog`` or ``capfd`` to assert on log/warning output. + +Security & reliability +---------------- +- The method makes outgoing HTTP requests and decodes base64-encoded content; + validate inputs in upstream code if you accept motor names from untrusted + sources. +- Network failures, API rate limits, or changes to the ThrustCurve API may + break loading; consider caching downloaded `.eng` files for production use. \ No newline at end of file From 142eaf8566907596b67da955499253c8121d688c Mon Sep 17 00:00:00 2001 From: Marchma0 Date: Wed, 12 Nov 2025 14:41:32 +0000 Subject: [PATCH 06/39] Changes to conform to lint --- rocketpy/motors/motor.py | 42 +++++++++++++++++++++----- tests/unit/motors/test_genericmotor.py | 3 ++ 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/rocketpy/motors/motor.py b/rocketpy/motors/motor.py index dc1575fb1..c2e00a428 100644 --- a/rocketpy/motors/motor.py +++ b/rocketpy/motors/motor.py @@ -1918,9 +1918,9 @@ def load_from_rse_file( ) @staticmethod - def load_from_thrustcurve_api(name: str, **kwargs): + def call_thrustcurve_api(name: str): """ - Creates a Motor instance by downloading a .eng file from the ThrustCurve API + Download a .eng file from the ThrustCurve API based on the given motor name. Parameters @@ -1929,14 +1929,11 @@ def load_from_thrustcurve_api(name: str, **kwargs): The motor name according to the API (e.g., "Cesaroni_M1670" or "M1670"). Both manufacturer-prefixed and shorthand names are commonly used; if multiple motors match the search, the first result is used. - **kwargs : - Additional arguments passed to the Motor constructor or loader, such as - dry_mass, nozzle_radius, etc. Returns ------- - instance : GenericMotor - A new GenericMotor instance initialized using the downloaded .eng file. + data_base64 : String + The .eng file of the motor in base64 Raises ------ @@ -1982,7 +1979,38 @@ def load_from_thrustcurve_api(name: str, **kwargs): raise ValueError( f"Downloaded .eng data for motor '{name}' is empty or invalid." ) + return data_base64 + + @staticmethod + def load_from_thrustcurve_api(name: str, **kwargs): + """ + Creates a Motor instance by downloading a .eng file from the ThrustCurve API + based on the given motor name. + + Parameters + ---------- + name : str + The motor name according to the API (e.g., "Cesaroni_M1670" or "M1670"). + Both manufacturer-prefixed and shorthand names are commonly used; if multiple + motors match the search, the first result is used. + **kwargs : + Additional arguments passed to the Motor constructor or loader, such as + dry_mass, nozzle_radius, etc. + + Returns + ------- + instance : GenericMotor + A new GenericMotor instance initialized using the downloaded .eng file. + + Raises + ------ + ValueError + If no motor is found or if the downloaded .eng data is missing. + requests.exceptions.RequestException + If a network or HTTP error occurs during the API call. + """ + data_base64 = GenericMotor.call_thrustcurve_api(name) data_bytes = base64.b64decode(data_base64) # Step 3. Create the motor from the .eng file diff --git a/tests/unit/motors/test_genericmotor.py b/tests/unit/motors/test_genericmotor.py index 306f6cd74..40a19f24d 100644 --- a/tests/unit/motors/test_genericmotor.py +++ b/tests/unit/motors/test_genericmotor.py @@ -229,6 +229,9 @@ def test_load_from_thrustcurve_api(monkeypatch, generic_motor): """ class MockResponse: + """ + Class to Mock the API + """ def __init__(self, json_data): self._json_data = json_data From 361ffabb91b147ea3a07cdbbf44bf0fdd90098d9 Mon Sep 17 00:00:00 2001 From: Marchma0 Date: Mon, 17 Nov 2025 15:35:41 +0000 Subject: [PATCH 07/39] Fixed Pylint errors --- tests/unit/motors/test_genericmotor.py | 188 ++++++++++--------------- 1 file changed, 78 insertions(+), 110 deletions(-) diff --git a/tests/unit/motors/test_genericmotor.py b/tests/unit/motors/test_genericmotor.py index 40a19f24d..f2ea7446a 100644 --- a/tests/unit/motors/test_genericmotor.py +++ b/tests/unit/motors/test_genericmotor.py @@ -216,76 +216,44 @@ def test_load_from_rse_file(generic_motor): assert thrust_curve[-1][1] == 0.0 # Last thrust point -def test_load_from_thrustcurve_api(monkeypatch, generic_motor): - """ - Tests the GenericMotor.load_from_thrustcurve_api method with mocked ThrustCurve API responses. - Parameters - ---------- - monkeypatch : pytest.MonkeyPatch - The pytest monkeypatch fixture for mocking. - generic_motor : rocketpy.GenericMotor - The GenericMotor object to be used in the tests. +class MockResponse: + """Mocked response for requests.""" - """ + def __init__(self, json_data): + self._json_data = json_data - class MockResponse: - """ - Class to Mock the API - """ - def __init__(self, json_data): - self._json_data = json_data + def json(self): + return self._json_data - def json(self): - return self._json_data + def raise_for_status(self): + return None - def raise_for_status(self): - # Simulate a successful HTTP response (200) - return None - # Provide mocked responses for the two endpoints: search.json and download.json - def mock_get(url, params=None): +def _mock_get(search_results=None, download_results=None): + """Return a mock_get function with predefined search/download results.""" + + def _get(url, **_kwargs): if "search.json" in url: - # Return a mock search result with a motorId and designation - return MockResponse( - { - "results": [ - { - "motorId": "12345", - "designation": "Cesaroni_M1670", - "manufacturer": "Cesaroni", - } - ] - } - ) - elif "download.json" in url: - # Read the local .eng file and return its base64-encoded content as the API would - eng_path = "data/motors/cesaroni/Cesaroni_M1670.eng" - with open(eng_path, "rb") as f: - encoded = base64.b64encode(f.read()).decode("utf-8") - return MockResponse({"results": [{"data": encoded}]}) - else: - raise RuntimeError(f"Unexpected URL called in test mock: {url}") - - monkeypatch.setattr(requests, "get", mock_get) - - # Expected parameters from the original test + return MockResponse(search_results or {"results": []}) + if "download.json" in url: + return MockResponse(download_results or {"results": []}) + raise RuntimeError(f"Unexpected URL: {url}") + + return _get + + +def assert_motor_specs(motor): burn_time = (0, 3.9) - dry_mass = 5.231 - 3.101 # 2.130 kg + dry_mass = 2.130 propellant_initial_mass = 3.101 chamber_radius = 75 / 1000 chamber_height = 757 / 1000 - nozzle_radius = chamber_radius * 0.85 # 85% of chamber radius - + nozzle_radius = chamber_radius * 0.85 average_thrust = 1545.218 total_impulse = 6026.350 max_thrust = 2200.0 exhaust_velocity = 1943.357 - # Call the method using the class (works if it's a staticmethod); using type(generic_motor) - # ensures test works if the method is invoked on a GenericMotor instance in the project - motor = type(generic_motor).load_from_thrustcurve_api("M1670") - - # Assertions (same as original) assert motor.burn_time == burn_time assert motor.dry_mass == dry_mass assert motor.propellant_initial_mass == propellant_initial_mass @@ -298,62 +266,62 @@ def mock_get(url, params=None): assert motor.max_thrust == pytest.approx(max_thrust) assert motor.nozzle_radius == pytest.approx(nozzle_radius) - # testing thrust curve equality against the local .eng import (as in original test) - _, _, points = Motor.import_eng("data/motors/cesaroni/Cesaroni_M1670.eng") + +def test_load_from_thrustcurve_api(monkeypatch, generic_motor): + """Tests GenericMotor.load_from_thrustcurve_api with mocked API.""" + + eng_path = "data/motors/cesaroni/Cesaroni_M1670.eng" + with open(eng_path, "rb") as f: + encoded = base64.b64encode(f.read()).decode("utf-8") + + search_json = { + "results": [ + { + "motorId": "12345", + "designation": "Cesaroni_M1670", + "manufacturer": "Cesaroni", + } + ] + } + download_json = {"results": [{"data": encoded}]} + monkeypatch.setattr(requests, "get", _mock_get(search_json, download_json)) + monkeypatch.setattr(requests.Session, "get", _mock_get(search_json, download_json)) + + motor = type(generic_motor).load_from_thrustcurve_api("M1670") + + assert_motor_specs(motor) + + _, _, points = Motor.import_eng(eng_path) assert motor.thrust.y_array == pytest.approx( Function(points, "Time (s)", "Thrust (N)", "linear", "zero").y_array ) - # 1. No motor found - def mock_get_no_motor(url, params=None): - if "search.json" in url: - return MockResponse({"results": []}) - return MockResponse({"results": []}) - - monkeypatch.setattr(requests, "get", mock_get_no_motor) - with pytest.raises(ValueError, match="No motor found"): - type(generic_motor).load_from_thrustcurve_api("NonexistentMotor") - - # 2. No .eng file found - def mock_get_no_eng(url, params=None): - if "search.json" in url: - return MockResponse( - { - "results": [ - { - "motorId": "123", - "designation": "Fake", - "manufacturer": "Test", - } - ] - } - ) - elif "download.json" in url: - return MockResponse({"results": []}) - return MockResponse({}) - - monkeypatch.setattr(requests, "get", mock_get_no_eng) - with pytest.raises(ValueError, match="No .eng file found"): - type(generic_motor).load_from_thrustcurve_api("FakeMotor") - - # 3. Empty .eng data - def mock_get_empty_data(url, params=None): - if "search.json" in url: - return MockResponse( - { - "results": [ - { - "motorId": "123", - "designation": "Fake", - "manufacturer": "Test", - } - ] - } - ) - elif "download.json" in url: - return MockResponse({"results": [{"data": ""}]}) - return MockResponse({}) - - monkeypatch.setattr(requests, "get", mock_get_empty_data) - with pytest.raises(ValueError, match="Downloaded .eng data"): - type(generic_motor).load_from_thrustcurve_api("FakeMotor") + error_cases = [ + ("No motor found", {"results": []}, None), + ( + "No .eng file found", + { + "results": [ + {"motorId": "123", "designation": "Fake", "manufacturer": "Test"} + ] + }, + {"results": []}, + ), + ( + "Downloaded .eng data", + { + "results": [ + {"motorId": "123", "designation": "Fake", "manufacturer": "Test"} + ] + }, + {"results": [{"data": ""}]}, + ), + ] + + for msg, search_res, download_res in error_cases: + monkeypatch.setattr(requests, "get", _mock_get(search_res, download_res)) + monkeypatch.setattr( + requests.Session, "get", _mock_get(search_res, download_res) + ) + with pytest.raises(ValueError, match=msg): + type(generic_motor).load_from_thrustcurve_api("FakeMotor") From 55beb5a2cee517f27f5b37f9e7e7a717683ad866 Mon Sep 17 00:00:00 2001 From: Marchma0 <88397818+Marchma0@users.noreply.github.com> Date: Wed, 26 Nov 2025 09:33:01 +0100 Subject: [PATCH 08/39] Update docs/user/motors/genericmotor.rst Co-authored-by: Gui-FernandesBR <63590233+Gui-FernandesBR@users.noreply.github.com> --- docs/user/motors/genericmotor.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user/motors/genericmotor.rst b/docs/user/motors/genericmotor.rst index 35745597b..0418cb797 100644 --- a/docs/user/motors/genericmotor.rst +++ b/docs/user/motors/genericmotor.rst @@ -107,7 +107,7 @@ note that the user can still provide the parameters manually if needed. when the user does not have all the information required to build a ``SolidMotor`` yet. The ``load_from_thrustcurve_api`` method ---------------------------------------- +---------------------------------------- The ``GenericMotor`` class provides a convenience loader that downloads a temporary `.eng` file from the ThrustCurve.org public API and builds a ``GenericMotor`` From 731eb30c8bdbc59d53bf324178bda2e9d15fd493 Mon Sep 17 00:00:00 2001 From: Marchma0 <88397818+Marchma0@users.noreply.github.com> Date: Wed, 26 Nov 2025 09:33:18 +0100 Subject: [PATCH 09/39] Update docs/user/motors/genericmotor.rst Co-authored-by: Gui-FernandesBR <63590233+Gui-FernandesBR@users.noreply.github.com> --- docs/user/motors/genericmotor.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/user/motors/genericmotor.rst b/docs/user/motors/genericmotor.rst index 0418cb797..8f03f1af3 100644 --- a/docs/user/motors/genericmotor.rst +++ b/docs/user/motors/genericmotor.rst @@ -120,6 +120,7 @@ save the `.eng` file. This method performs network requests to the ThrustCurve API. Use it only when you have network access. For automated testing or reproducible runs, prefer using local `.eng` files. + Signature ---------- From f5e470e9920ec5c985c8bdacde3a7bb345e59b9a Mon Sep 17 00:00:00 2001 From: Marchma0 <88397818+Marchma0@users.noreply.github.com> Date: Wed, 26 Nov 2025 09:33:39 +0100 Subject: [PATCH 10/39] Update docs/user/motors/genericmotor.rst Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/user/motors/genericmotor.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user/motors/genericmotor.rst b/docs/user/motors/genericmotor.rst index 8f03f1af3..d2959b35c 100644 --- a/docs/user/motors/genericmotor.rst +++ b/docs/user/motors/genericmotor.rst @@ -155,7 +155,7 @@ Behavior notes --------------- - The method first performs a search on ThrustCurve using the provided name. If no results are returned a :class:`ValueError` is raised. -- If a motor is found the method requests the .eng file in RASP format, decodes +- If a motor is found, the method requests the .eng file in RASP format, decodes it and temporarily writes it to disk; a ``GenericMotor`` is then constructed using the existing .eng file loader. The temporary file is removed even if an error occurs. From ee61517c3f3414df55149bfc27298e847afa41c4 Mon Sep 17 00:00:00 2001 From: Marchma0 Date: Wed, 26 Nov 2025 08:39:08 +0000 Subject: [PATCH 11/39] Set private the method call_thrustcurve_api --- rocketpy/motors/motor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rocketpy/motors/motor.py b/rocketpy/motors/motor.py index c2e00a428..3706be365 100644 --- a/rocketpy/motors/motor.py +++ b/rocketpy/motors/motor.py @@ -1918,7 +1918,7 @@ def load_from_rse_file( ) @staticmethod - def call_thrustcurve_api(name: str): + def _call_thrustcurve_api(name: str): """ Download a .eng file from the ThrustCurve API based on the given motor name. From cf2013fd0de266827706d560b85953032143982a Mon Sep 17 00:00:00 2001 From: Marchma0 <88397818+Marchma0@users.noreply.github.com> Date: Wed, 26 Nov 2025 09:40:02 +0100 Subject: [PATCH 12/39] Update docs/user/motors/genericmotor.rst Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/user/motors/genericmotor.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user/motors/genericmotor.rst b/docs/user/motors/genericmotor.rst index d2959b35c..8fca14b35 100644 --- a/docs/user/motors/genericmotor.rst +++ b/docs/user/motors/genericmotor.rst @@ -156,7 +156,7 @@ Behavior notes - The method first performs a search on ThrustCurve using the provided name. If no results are returned a :class:`ValueError` is raised. - If a motor is found, the method requests the .eng file in RASP format, decodes - it and temporarily writes it to disk; a ``GenericMotor`` is then constructed + it and temporarily writes it to disk. A ``GenericMotor`` is then constructed using the existing .eng file loader. The temporary file is removed even if an error occurs. - The function emits a non-fatal informational warning when a motor is found From 068362a81d852fcce03b615e4bcd1eddf9192276 Mon Sep 17 00:00:00 2001 From: Marchma0 <88397818+Marchma0@users.noreply.github.com> Date: Wed, 26 Nov 2025 09:40:48 +0100 Subject: [PATCH 13/39] Update tests/unit/motors/test_genericmotor.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/unit/motors/test_genericmotor.py | 51 ++++++++++++++------------ 1 file changed, 27 insertions(+), 24 deletions(-) diff --git a/tests/unit/motors/test_genericmotor.py b/tests/unit/motors/test_genericmotor.py index f2ea7446a..c97096464 100644 --- a/tests/unit/motors/test_genericmotor.py +++ b/tests/unit/motors/test_genericmotor.py @@ -242,31 +242,34 @@ def _get(url, **_kwargs): return _get -def assert_motor_specs(motor): - burn_time = (0, 3.9) - dry_mass = 2.130 - propellant_initial_mass = 3.101 - chamber_radius = 75 / 1000 - chamber_height = 757 / 1000 - nozzle_radius = chamber_radius * 0.85 - average_thrust = 1545.218 - total_impulse = 6026.350 - max_thrust = 2200.0 - exhaust_velocity = 1943.357 - - assert motor.burn_time == burn_time - assert motor.dry_mass == dry_mass - assert motor.propellant_initial_mass == propellant_initial_mass - assert motor.chamber_radius == chamber_radius - assert motor.chamber_height == chamber_height - assert motor.chamber_position == 0 - assert motor.average_thrust == pytest.approx(average_thrust) - assert motor.total_impulse == pytest.approx(total_impulse) - assert motor.exhaust_velocity.average(*burn_time) == pytest.approx(exhaust_velocity) - assert motor.max_thrust == pytest.approx(max_thrust) - assert motor.nozzle_radius == pytest.approx(nozzle_radius) - +# Module-level constant for expected motor specs +EXPECTED_MOTOR_SPECS = { + "burn_time": (0, 3.9), + "dry_mass": 2.130, + "propellant_initial_mass": 3.101, + "chamber_radius": 75 / 1000, + "chamber_height": 757 / 1000, + "nozzle_radius": (75 / 1000) * 0.85, + "average_thrust": 1545.218, + "total_impulse": 6026.350, + "max_thrust": 2200.0, + "exhaust_velocity": 1943.357, + "chamber_position": 0, +} +def assert_motor_specs(motor): + specs = EXPECTED_MOTOR_SPECS + assert motor.burn_time == specs["burn_time"] + assert motor.dry_mass == specs["dry_mass"] + assert motor.propellant_initial_mass == specs["propellant_initial_mass"] + assert motor.chamber_radius == specs["chamber_radius"] + assert motor.chamber_height == specs["chamber_height"] + assert motor.chamber_position == specs["chamber_position"] + assert motor.average_thrust == pytest.approx(specs["average_thrust"]) + assert motor.total_impulse == pytest.approx(specs["total_impulse"]) + assert motor.exhaust_velocity.average(*specs["burn_time"]) == pytest.approx(specs["exhaust_velocity"]) + assert motor.max_thrust == pytest.approx(specs["max_thrust"]) + assert motor.nozzle_radius == pytest.approx(specs["nozzle_radius"]) def test_load_from_thrustcurve_api(monkeypatch, generic_motor): """Tests GenericMotor.load_from_thrustcurve_api with mocked API.""" From a319ab62ef7ce51d1225006a4fdf19eff45182e1 Mon Sep 17 00:00:00 2001 From: Marchma0 <88397818+Marchma0@users.noreply.github.com> Date: Wed, 26 Nov 2025 09:41:22 +0100 Subject: [PATCH 14/39] Update rocketpy/motors/motor.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- rocketpy/motors/motor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rocketpy/motors/motor.py b/rocketpy/motors/motor.py index 3706be365..05171be83 100644 --- a/rocketpy/motors/motor.py +++ b/rocketpy/motors/motor.py @@ -1932,7 +1932,7 @@ def _call_thrustcurve_api(name: str): Returns ------- - data_base64 : String + data_base64 : str The .eng file of the motor in base64 Raises From 4555a2d29324662f1d251f38aadfc7dd31f05f40 Mon Sep 17 00:00:00 2001 From: Marchma0 <88397818+Marchma0@users.noreply.github.com> Date: Wed, 26 Nov 2025 09:41:33 +0100 Subject: [PATCH 15/39] Update docs/user/motors/genericmotor.rst Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/user/motors/genericmotor.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user/motors/genericmotor.rst b/docs/user/motors/genericmotor.rst index 8fca14b35..53ad29b8f 100644 --- a/docs/user/motors/genericmotor.rst +++ b/docs/user/motors/genericmotor.rst @@ -178,7 +178,7 @@ Example Testing advice --------------- -- ``pytest``'s ``caplog`` or ``capfd`` to assert on log/warning output. +- Use ``pytest``'s ``caplog`` or ``capfd`` to assert on log/warning output. Security & reliability ---------------- From 7c7e0796857e164657a162c093d8ba45f4462341 Mon Sep 17 00:00:00 2001 From: Marchma0 <88397818+Marchma0@users.noreply.github.com> Date: Wed, 26 Nov 2025 09:43:13 +0100 Subject: [PATCH 16/39] Update docs/user/motors/genericmotor.rst Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/user/motors/genericmotor.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/user/motors/genericmotor.rst b/docs/user/motors/genericmotor.rst index 53ad29b8f..90c4380af 100644 --- a/docs/user/motors/genericmotor.rst +++ b/docs/user/motors/genericmotor.rst @@ -129,10 +129,10 @@ Signature Parameters ---------- name : str - Motor name to search on ThrustCurve (example: - ``"M1670"``).Only shorthand names are accepted (e.g. ``"M1670"``, not - ``"Cesaroni M1670"``). - when multiple matches occur the first result returned by the API is used. + Motor name to search on ThrustCurve (examples: + ``"M1670"`` or ``"Cesaroni M1670"``). Both shorthand and manufacturer-prefixed + names are accepted. When multiple matches occur, the first result returned by + the API is used. **kwargs : Same optional arguments accepted by the :class:`GenericMotor` constructor (e.g. ``dry_mass``, ``nozzle_radius``, ``interpolation_method``). Any From 04ce65e6e642e0b7271b9e458e0bf66500b6e97b Mon Sep 17 00:00:00 2001 From: Marchma0 <88397818+Marchma0@users.noreply.github.com> Date: Wed, 26 Nov 2025 09:43:27 +0100 Subject: [PATCH 17/39] Update docs/user/motors/genericmotor.rst Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/user/motors/genericmotor.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/user/motors/genericmotor.rst b/docs/user/motors/genericmotor.rst index 90c4380af..f572944e6 100644 --- a/docs/user/motors/genericmotor.rst +++ b/docs/user/motors/genericmotor.rst @@ -150,6 +150,7 @@ ValueError If the API search returns no motor, or if the download endpoint returns no .eng file or empty/invalid data. requests.exceptions.RequestException + If a network or HTTP error occurs during the API call to ThrustCurve. Behavior notes --------------- From b68b4046697c9d63ace4864ddeeefc2935c5fb8e Mon Sep 17 00:00:00 2001 From: Marchma0 Date: Wed, 26 Nov 2025 08:58:00 +0000 Subject: [PATCH 18/39] Modify the changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7090973ad..a45a2045e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). Attention: The newest changes should be on top --> ### Added +- ENH: Add thrustcurve api integration to retrieve motor eng data [#870](https://github.com/RocketPy-Team/RocketPy/pull/870) ### Changed From be791570797c24b8827bedfda0ce1cc6fcd52dc1 Mon Sep 17 00:00:00 2001 From: Marchma0 Date: Wed, 26 Nov 2025 16:29:00 +0000 Subject: [PATCH 19/39] Bug fix --- rocketpy/motors/motor.py | 2 +- tests/unit/motors/test_genericmotor.py | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/rocketpy/motors/motor.py b/rocketpy/motors/motor.py index 05171be83..4415b362e 100644 --- a/rocketpy/motors/motor.py +++ b/rocketpy/motors/motor.py @@ -2010,7 +2010,7 @@ def load_from_thrustcurve_api(name: str, **kwargs): If a network or HTTP error occurs during the API call. """ - data_base64 = GenericMotor.call_thrustcurve_api(name) + data_base64 = GenericMotor._call_thrustcurve_api(name) data_bytes = base64.b64decode(data_base64) # Step 3. Create the motor from the .eng file diff --git a/tests/unit/motors/test_genericmotor.py b/tests/unit/motors/test_genericmotor.py index c97096464..3d0fbd766 100644 --- a/tests/unit/motors/test_genericmotor.py +++ b/tests/unit/motors/test_genericmotor.py @@ -257,6 +257,7 @@ def _get(url, **_kwargs): "chamber_position": 0, } + def assert_motor_specs(motor): specs = EXPECTED_MOTOR_SPECS assert motor.burn_time == specs["burn_time"] @@ -267,9 +268,13 @@ def assert_motor_specs(motor): assert motor.chamber_position == specs["chamber_position"] assert motor.average_thrust == pytest.approx(specs["average_thrust"]) assert motor.total_impulse == pytest.approx(specs["total_impulse"]) - assert motor.exhaust_velocity.average(*specs["burn_time"]) == pytest.approx(specs["exhaust_velocity"]) + assert motor.exhaust_velocity.average(*specs["burn_time"]) == pytest.approx( + specs["exhaust_velocity"] + ) assert motor.max_thrust == pytest.approx(specs["max_thrust"]) assert motor.nozzle_radius == pytest.approx(specs["nozzle_radius"]) + + def test_load_from_thrustcurve_api(monkeypatch, generic_motor): """Tests GenericMotor.load_from_thrustcurve_api with mocked API.""" From 90aac5fa564ebfdf40e1c39f957ac705039fe6b0 Mon Sep 17 00:00:00 2001 From: Gui-FernandesBR Date: Wed, 26 Nov 2025 22:58:06 -0300 Subject: [PATCH 20/39] Refactor GenericMotor class: comment out unused designation and manufacturer variables --- docs/user/motors/genericmotor.rst | 57 +------------------------------ rocketpy/motors/motor.py | 6 ++-- 2 files changed, 4 insertions(+), 59 deletions(-) diff --git a/docs/user/motors/genericmotor.rst b/docs/user/motors/genericmotor.rst index f572944e6..8c5b40703 100644 --- a/docs/user/motors/genericmotor.rst +++ b/docs/user/motors/genericmotor.rst @@ -121,51 +121,8 @@ save the `.eng` file. when you have network access. For automated testing or reproducible runs, prefer using local `.eng` files. -Signature ----------- - -``GenericMotor.load_from_thrustcurve_api(name: str, **kwargs) -> GenericMotor`` - -Parameters ----------- -name : str - Motor name to search on ThrustCurve (examples: - ``"M1670"`` or ``"Cesaroni M1670"``). Both shorthand and manufacturer-prefixed - names are accepted. When multiple matches occur, the first result returned by - the API is used. -**kwargs : - Same optional arguments accepted by the :class:`GenericMotor` constructor - (e.g. ``dry_mass``, ``nozzle_radius``, ``interpolation_method``). Any - parameters provided here override values parsed from the downloaded file. - -Returns ----------- -GenericMotor - A new ``GenericMotor`` instance created from the .eng data downloaded from - ThrustCurve. - -Raises ----------- -ValueError - If the API search returns no motor, or if the download endpoint returns no - .eng file or empty/invalid data. -requests.exceptions.RequestException - If a network or HTTP error occurs during the API call to ThrustCurve. - -Behavior notes ---------------- -- The method first performs a search on ThrustCurve using the provided name. - If no results are returned a :class:`ValueError` is raised. -- If a motor is found, the method requests the .eng file in RASP format, decodes - it and temporarily writes it to disk. A ``GenericMotor`` is then constructed - using the existing .eng file loader. The temporary file is removed even if an - error occurs. -- The function emits a non-fatal informational warning when a motor is found - (``warnings.warn(...)``). This follows the repository convention for - non-critical messages; callers can filter or suppress warnings as needed. - Example ---------------- +------- .. jupyter-execute:: @@ -176,15 +133,3 @@ Example # Use the motor as usual motor.info() - -Testing advice ---------------- -- Use ``pytest``'s ``caplog`` or ``capfd`` to assert on log/warning output. - -Security & reliability ----------------- -- The method makes outgoing HTTP requests and decodes base64-encoded content; - validate inputs in upstream code if you accept motor names from untrusted - sources. -- Network failures, API rate limits, or changes to the ThrustCurve API may - break loading; consider caching downloaded `.eng` files for production use. \ No newline at end of file diff --git a/rocketpy/motors/motor.py b/rocketpy/motors/motor.py index 4415b362e..c81c713d4 100644 --- a/rocketpy/motors/motor.py +++ b/rocketpy/motors/motor.py @@ -1957,9 +1957,9 @@ def _call_thrustcurve_api(name: str): motor_info = data["results"][0] motor_id = motor_info.get("motorId") - designation = motor_info.get("designation", "").replace("/", "-") - manufacturer = motor_info.get("manufacturer", "") - warnings.warn(f"Motor found: {designation} ({manufacturer})", UserWarning) + # NOTE: commented bc we don't use it, but keeping for possible future use + # designation = motor_info.get("designation", "").replace("/", "-") + # manufacturer = motor_info.get("manufacturer", "") # Step 2. Download the .eng file dl_response = requests.get( From 22ea545e71cc98c90ca5ea8a01f3fded44803340 Mon Sep 17 00:00:00 2001 From: Marchma0 Date: Tue, 4 Nov 2025 18:02:45 +0000 Subject: [PATCH 21/39] Add the function to create a motor from the API of thrustcurve and the test --- rocketpy/motors/motor.py | 59 ++++++++++++++++++++++++++ tests/unit/motors/test_genericmotor.py | 47 ++++++++++++++++++++ 2 files changed, 106 insertions(+) diff --git a/rocketpy/motors/motor.py b/rocketpy/motors/motor.py index 7930ed52b..cc21c822b 100644 --- a/rocketpy/motors/motor.py +++ b/rocketpy/motors/motor.py @@ -1,4 +1,6 @@ +import base64 import re +import tempfile import warnings import xml.etree.ElementTree as ET from abc import ABC, abstractmethod @@ -6,6 +8,7 @@ from os import path import numpy as np +import requests from ..mathutils.function import Function, funcify_method from ..plots.motor_plots import _MotorPlots @@ -1914,6 +1917,62 @@ def load_from_rse_file( coordinate_system_orientation=coordinate_system_orientation, ) + @staticmethod + def load_from_thrustcurve_api(name: str, **kwargs): + """ + Creates a Motor instance by downloading a .eng file from the ThrustCurve API + based on the given motor name. + + Parameters + ---------- + name : str + The motor name according to the API (e.g., "Cesaroni_M1670"). + **kwargs : + Additional arguments passed to the Motor constructor, such as dry_mass, nozzle_radius, etc. + + Returns + ------- + instance : cls + A new Motor instance initialized using the downloaded .eng file. + """ + + base_url = "https://www.thrustcurve.org/api/v1" + + # Step 1. Search motor + response = requests.get(f"{base_url}/search.json", params={"commonName": name}) + response.raise_for_status() + data = response.json() + + if not data.get("results"): + print("No motor found.") + return None + + motor = data["results"][0] + motor_id = motor["motorId"] + designation = motor["designation"].replace("/", "-") + print(f"Motor found: {designation} ({motor['manufacturer']})") + + # Step 2. Download the .eng file + dl_response = requests.get( + f"{base_url}/download.json", + params={"motorIds": motor_id, "format": "RASP", "data": "file"}, + ) + dl_response.raise_for_status() + data = dl_response.json() + + data_base64 = data["results"][0]["data"] + data_bytes = base64.b64decode(data_base64) + + # Step 3. Create the motor from the .eng file + + with tempfile.NamedTemporaryFile(suffix=".eng", delete=True) as tmp_file: + tmp_file.write(data_bytes) + tmp_file.flush() + + motor = GenericMotor.load_from_eng_file(tmp_file.name, **kwargs) + + return motor + def all_info(self): """Prints out all data and graphs available about the Motor.""" # Print motor details diff --git a/tests/unit/motors/test_genericmotor.py b/tests/unit/motors/test_genericmotor.py index 776d7b691..82ff4547e 100644 --- a/tests/unit/motors/test_genericmotor.py +++ b/tests/unit/motors/test_genericmotor.py @@ -211,3 +211,50 @@ def test_load_from_rse_file(generic_motor): assert thrust_curve[0][1] == 0.0 # First thrust point assert thrust_curve[-1][0] == 2.2 # Last point of time assert thrust_curve[-1][1] == 0.0 # Last thrust point + + +def test_load_from_thrustcurve_api(generic_motor): + """Tests the GenericMotor.load_from_thrustcurve_api method. + + Parameters + ---------- + generic_motor : rocketpy.GenericMotor + The GenericMotor object to be used in the tests. + """ + # using cesaroni data as example + burn_time = (0, 3.9) + dry_mass = 5.231 - 3.101 # 2.130 kg + propellant_initial_mass = 3.101 + chamber_radius = 75 / 1000 + chamber_height = 757 / 1000 + nozzle_radius = chamber_radius * 0.85 # 85% of chamber radius + + # Parameters from manual testing using the SolidMotor class as a reference + average_thrust = 1545.218 + total_impulse = 6026.350 + max_thrust = 2200.0 + exhaust_velocity = 1943.357 + + # creating motor from .eng file + generic_motor = generic_motor.load_from_thrustcurve_api("M1670") + + # testing relevant parameters + assert generic_motor.burn_time == burn_time + assert generic_motor.dry_mass == dry_mass + assert generic_motor.propellant_initial_mass == propellant_initial_mass + assert generic_motor.chamber_radius == chamber_radius + assert generic_motor.chamber_height == chamber_height + assert generic_motor.chamber_position == 0 + assert generic_motor.average_thrust == pytest.approx(average_thrust) + assert generic_motor.total_impulse == pytest.approx(total_impulse) + assert generic_motor.exhaust_velocity.average(*burn_time) == pytest.approx( + exhaust_velocity + ) + assert generic_motor.max_thrust == pytest.approx(max_thrust) + assert generic_motor.nozzle_radius == pytest.approx(nozzle_radius) + + # testing thrust curve + _, _, points = Motor.import_eng("data/motors/cesaroni/Cesaroni_M1670.eng") + assert generic_motor.thrust.y_array == pytest.approx( + Function(points, "Time (s)", "Thrust (N)", "linear", "zero").y_array + ) From 9315bd463278a19ad46ad9939bf479373454c101 Mon Sep 17 00:00:00 2001 From: monta Date: Tue, 4 Nov 2025 22:31:36 +0100 Subject: [PATCH 22/39] Improve load_from_thrustcurve_api and test_load_from_thrustcurve_api with clean imports --- rocketpy/motors/motor.py | 80 +++++++++++++++------- tests/unit/motors/test_genericmotor.py | 93 +++++++++++++++++++------- 2 files changed, 123 insertions(+), 50 deletions(-) diff --git a/rocketpy/motors/motor.py b/rocketpy/motors/motor.py index cc21c822b..8678ce65d 100644 --- a/rocketpy/motors/motor.py +++ b/rocketpy/motors/motor.py @@ -5,16 +5,18 @@ import xml.etree.ElementTree as ET from abc import ABC, abstractmethod from functools import cached_property -from os import path +from os import path, remove import numpy as np import requests - +import logging from ..mathutils.function import Function, funcify_method from ..plots.motor_plots import _MotorPlots from ..prints.motor_prints import _MotorPrints from ..tools import parallel_axis_theorem_from_com, tuple_handler +logger = logging.getLogger(__name__) + # pylint: disable=too-many-public-methods class Motor(ABC): @@ -1916,7 +1918,7 @@ def load_from_rse_file( interpolation_method=interpolation_method, coordinate_system_orientation=coordinate_system_orientation, ) - + @staticmethod def load_from_thrustcurve_api(name: str, **kwargs): """ @@ -1926,16 +1928,25 @@ def load_from_thrustcurve_api(name: str, **kwargs): Parameters ---------- name : str - The motor name according to the API (e.g., "Cesaroni_M1670"). + The motor name according to the API (e.g., "Cesaroni_M1670" or "M1670"). + Both manufacturer-prefixed and shorthand names are commonly used; if multiple + motors match the search, the first result is used. **kwargs : - Additional arguments passed to the Motor constructor, such as dry_mass, nozzle_radius, etc. + Additional arguments passed to the Motor constructor or loader, such as + dry_mass, nozzle_radius, etc. Returns ------- - instance : cls - A new Motor instance initialized using the downloaded .eng file. + instance : GenericMotor + A new GenericMotor instance initialized using the downloaded .eng file. + + Raises + ------ + ValueError + If no motor is found or if the downloaded .eng data is missing. + requests.exceptions.RequestException + If a network or HTTP error occurs during the API call. """ - base_url = "https://www.thrustcurve.org/api/v1" # Step 1. Search motor @@ -1944,13 +1955,17 @@ def load_from_thrustcurve_api(name: str, **kwargs): data = response.json() if not data.get("results"): - print("No motor found.") - return None + raise ValueError( + f"No motor found for name '{name}'. " + "Please verify the motor name format (e.g., 'Cesaroni_M1670' or 'M1670') and try again." + ) - motor = data["results"][0] - motor_id = motor["motorId"] - designation = motor["designation"].replace("/", "-") - print(f"Motor found: {designation} ({motor['manufacturer']})") + motor_info = data["results"][0] + motor_id = motor_info.get("motorId") + designation = motor_info.get("designation", "").replace("/", "-") + manufacturer = motor_info.get("manufacturer", "") + # Logging the fact that the motor was found + logger.info(f"Motor found: {designation} ({manufacturer})") # Step 2. Download the .eng file dl_response = requests.get( @@ -1958,20 +1973,37 @@ def load_from_thrustcurve_api(name: str, **kwargs): params={"motorIds": motor_id, "format": "RASP", "data": "file"}, ) dl_response.raise_for_status() - data = dl_response.json() + dl_data = dl_response.json() - data_base64 = data["results"][0]["data"] - data_bytes = base64.b64decode(data_base64) - - # Step 3. Create the motor from the .eng file + if not dl_data.get("results"): + raise ValueError(f"No .eng file found for motor '{name}' in the ThrustCurve API.") - with tempfile.NamedTemporaryFile(suffix=".eng", delete=True) as tmp_file: - tmp_file.write(data_bytes) - tmp_file.flush() + data_base64 = dl_data["results"][0].get("data") + if not data_base64: + raise ValueError(f"Downloaded .eng data for motor '{name}' is empty or invalid.") - motor = GenericMotor.load_from_eng_file(tmp_file.name, **kwargs) + data_bytes = base64.b64decode(data_base64) - return motor + # Step 3. Create the motor from the .eng file + tmp_path = None + try: + # create a temporary file that persists until we explicitly remove it + with tempfile.NamedTemporaryFile(suffix=".eng", delete=False) as tmp_file: + tmp_file.write(data_bytes) + tmp_file.flush() + tmp_path = tmp_file.name + + + motor_instance = GenericMotor.load_from_eng_file(tmp_path, **kwargs) + return motor_instance + finally: + # Ensuring the temporary file is removed + if tmp_path and path.exists(tmp_path): + try: + remove(tmp_path) + except OSError: + # If cleanup fails, don't raise: we don't want to mask prior exceptions. + pass def all_info(self): """Prints out all data and graphs available about the Motor.""" diff --git a/tests/unit/motors/test_genericmotor.py b/tests/unit/motors/test_genericmotor.py index 82ff4547e..3a4a41873 100644 --- a/tests/unit/motors/test_genericmotor.py +++ b/tests/unit/motors/test_genericmotor.py @@ -1,6 +1,9 @@ import numpy as np import pytest import scipy.integrate +import requests +import base64 + from rocketpy import Function, Motor @@ -212,16 +215,56 @@ def test_load_from_rse_file(generic_motor): assert thrust_curve[-1][0] == 2.2 # Last point of time assert thrust_curve[-1][1] == 0.0 # Last thrust point - -def test_load_from_thrustcurve_api(generic_motor): - """Tests the GenericMotor.load_from_thrustcurve_api method. - +def test_load_from_thrustcurve_api(monkeypatch, generic_motor): + """ + Tests the GenericMotor.load_from_thrustcurve_api method with mocked ThrustCurve API responses. Parameters ---------- + monkeypatch : pytest.MonkeyPatch + The pytest monkeypatch fixture for mocking. generic_motor : rocketpy.GenericMotor The GenericMotor object to be used in the tests. + """ - # using cesaroni data as example + + class MockResponse: + def __init__(self, json_data): + self._json_data = json_data + + def json(self): + return self._json_data + + def raise_for_status(self): + # Simulate a successful HTTP response (200) + return None + + # Provide mocked responses for the two endpoints: search.json and download.json + def mock_get(url, params=None): + if "search.json" in url: + # Return a mock search result with a motorId and designation + return MockResponse( + { + "results": [ + { + "motorId": "12345", + "designation": "Cesaroni_M1670", + "manufacturer": "Cesaroni", + } + ] + } + ) + elif "download.json" in url: + # Read the local .eng file and return its base64-encoded content as the API would + eng_path = "data/motors/cesaroni/Cesaroni_M1670.eng" + with open(eng_path, "rb") as f: + encoded = base64.b64encode(f.read()).decode("utf-8") + return MockResponse({"results": [{"data": encoded}]}) + else: + raise RuntimeError(f"Unexpected URL called in test mock: {url}") + + monkeypatch.setattr(requests, "get", mock_get) + + # Expected parameters from the original test burn_time = (0, 3.9) dry_mass = 5.231 - 3.101 # 2.130 kg propellant_initial_mass = 3.101 @@ -229,32 +272,30 @@ def test_load_from_thrustcurve_api(generic_motor): chamber_height = 757 / 1000 nozzle_radius = chamber_radius * 0.85 # 85% of chamber radius - # Parameters from manual testing using the SolidMotor class as a reference average_thrust = 1545.218 total_impulse = 6026.350 max_thrust = 2200.0 exhaust_velocity = 1943.357 - # creating motor from .eng file - generic_motor = generic_motor.load_from_thrustcurve_api("M1670") - - # testing relevant parameters - assert generic_motor.burn_time == burn_time - assert generic_motor.dry_mass == dry_mass - assert generic_motor.propellant_initial_mass == propellant_initial_mass - assert generic_motor.chamber_radius == chamber_radius - assert generic_motor.chamber_height == chamber_height - assert generic_motor.chamber_position == 0 - assert generic_motor.average_thrust == pytest.approx(average_thrust) - assert generic_motor.total_impulse == pytest.approx(total_impulse) - assert generic_motor.exhaust_velocity.average(*burn_time) == pytest.approx( - exhaust_velocity - ) - assert generic_motor.max_thrust == pytest.approx(max_thrust) - assert generic_motor.nozzle_radius == pytest.approx(nozzle_radius) - - # testing thrust curve + # Call the method using the class (works if it's a staticmethod); using type(generic_motor) + # ensures test works if the method is invoked on a GenericMotor instance in the project + motor = type(generic_motor).load_from_thrustcurve_api("M1670") + + # Assertions (same as original) + assert motor.burn_time == burn_time + assert motor.dry_mass == dry_mass + assert motor.propellant_initial_mass == propellant_initial_mass + assert motor.chamber_radius == chamber_radius + assert motor.chamber_height == chamber_height + assert motor.chamber_position == 0 + assert motor.average_thrust == pytest.approx(average_thrust) + assert motor.total_impulse == pytest.approx(total_impulse) + assert motor.exhaust_velocity.average(*burn_time) == pytest.approx(exhaust_velocity) + assert motor.max_thrust == pytest.approx(max_thrust) + assert motor.nozzle_radius == pytest.approx(nozzle_radius) + + # testing thrust curve equality against the local .eng import (as in original test) _, _, points = Motor.import_eng("data/motors/cesaroni/Cesaroni_M1670.eng") - assert generic_motor.thrust.y_array == pytest.approx( + assert motor.thrust.y_array == pytest.approx( Function(points, "Time (s)", "Thrust (N)", "linear", "zero").y_array ) From df3335a85d5655ca1160fde6a804af6801695aa9 Mon Sep 17 00:00:00 2001 From: monta Date: Wed, 5 Nov 2025 08:37:52 +0100 Subject: [PATCH 23/39] Clean up load_from_thrustcurve_api and improve test_load_from_thrustcurve_api with exception testing --- rocketpy/motors/motor.py | 19 ++++---- tests/unit/motors/test_genericmotor.py | 63 ++++++++++++++++++++++++-- 2 files changed, 70 insertions(+), 12 deletions(-) diff --git a/rocketpy/motors/motor.py b/rocketpy/motors/motor.py index 8678ce65d..1f548a48d 100644 --- a/rocketpy/motors/motor.py +++ b/rocketpy/motors/motor.py @@ -1,4 +1,5 @@ import base64 +import logging import re import tempfile import warnings @@ -9,7 +10,7 @@ import numpy as np import requests -import logging + from ..mathutils.function import Function, funcify_method from ..plots.motor_plots import _MotorPlots from ..prints.motor_prints import _MotorPrints @@ -1918,7 +1919,7 @@ def load_from_rse_file( interpolation_method=interpolation_method, coordinate_system_orientation=coordinate_system_orientation, ) - + @staticmethod def load_from_thrustcurve_api(name: str, **kwargs): """ @@ -1964,7 +1965,7 @@ def load_from_thrustcurve_api(name: str, **kwargs): motor_id = motor_info.get("motorId") designation = motor_info.get("designation", "").replace("/", "-") manufacturer = motor_info.get("manufacturer", "") - # Logging the fact that the motor was found + # Logging the fact that the motor was found logger.info(f"Motor found: {designation} ({manufacturer})") # Step 2. Download the .eng file @@ -1976,11 +1977,15 @@ def load_from_thrustcurve_api(name: str, **kwargs): dl_data = dl_response.json() if not dl_data.get("results"): - raise ValueError(f"No .eng file found for motor '{name}' in the ThrustCurve API.") + raise ValueError( + f"No .eng file found for motor '{name}' in the ThrustCurve API." + ) data_base64 = dl_data["results"][0].get("data") if not data_base64: - raise ValueError(f"Downloaded .eng data for motor '{name}' is empty or invalid.") + raise ValueError( + f"Downloaded .eng data for motor '{name}' is empty or invalid." + ) data_bytes = base64.b64decode(data_base64) @@ -1993,9 +1998,7 @@ def load_from_thrustcurve_api(name: str, **kwargs): tmp_file.flush() tmp_path = tmp_file.name - - motor_instance = GenericMotor.load_from_eng_file(tmp_path, **kwargs) - return motor_instance + return GenericMotor.load_from_eng_file(tmp_path, **kwargs) finally: # Ensuring the temporary file is removed if tmp_path and path.exists(tmp_path): diff --git a/tests/unit/motors/test_genericmotor.py b/tests/unit/motors/test_genericmotor.py index 3a4a41873..306f6cd74 100644 --- a/tests/unit/motors/test_genericmotor.py +++ b/tests/unit/motors/test_genericmotor.py @@ -1,9 +1,9 @@ +import base64 + import numpy as np import pytest -import scipy.integrate import requests -import base64 - +import scipy.integrate from rocketpy import Function, Motor @@ -215,6 +215,7 @@ def test_load_from_rse_file(generic_motor): assert thrust_curve[-1][0] == 2.2 # Last point of time assert thrust_curve[-1][1] == 0.0 # Last thrust point + def test_load_from_thrustcurve_api(monkeypatch, generic_motor): """ Tests the GenericMotor.load_from_thrustcurve_api method with mocked ThrustCurve API responses. @@ -224,7 +225,7 @@ def test_load_from_thrustcurve_api(monkeypatch, generic_motor): The pytest monkeypatch fixture for mocking. generic_motor : rocketpy.GenericMotor The GenericMotor object to be used in the tests. - + """ class MockResponse: @@ -299,3 +300,57 @@ def mock_get(url, params=None): assert motor.thrust.y_array == pytest.approx( Function(points, "Time (s)", "Thrust (N)", "linear", "zero").y_array ) + + # 1. No motor found + def mock_get_no_motor(url, params=None): + if "search.json" in url: + return MockResponse({"results": []}) + return MockResponse({"results": []}) + + monkeypatch.setattr(requests, "get", mock_get_no_motor) + with pytest.raises(ValueError, match="No motor found"): + type(generic_motor).load_from_thrustcurve_api("NonexistentMotor") + + # 2. No .eng file found + def mock_get_no_eng(url, params=None): + if "search.json" in url: + return MockResponse( + { + "results": [ + { + "motorId": "123", + "designation": "Fake", + "manufacturer": "Test", + } + ] + } + ) + elif "download.json" in url: + return MockResponse({"results": []}) + return MockResponse({}) + + monkeypatch.setattr(requests, "get", mock_get_no_eng) + with pytest.raises(ValueError, match="No .eng file found"): + type(generic_motor).load_from_thrustcurve_api("FakeMotor") + + # 3. Empty .eng data + def mock_get_empty_data(url, params=None): + if "search.json" in url: + return MockResponse( + { + "results": [ + { + "motorId": "123", + "designation": "Fake", + "manufacturer": "Test", + } + ] + } + ) + elif "download.json" in url: + return MockResponse({"results": [{"data": ""}]}) + return MockResponse({}) + + monkeypatch.setattr(requests, "get", mock_get_empty_data) + with pytest.raises(ValueError, match="Downloaded .eng data"): + type(generic_motor).load_from_thrustcurve_api("FakeMotor") From eb47e3128e45a891e9d70c86cfebc26807e18bff Mon Sep 17 00:00:00 2001 From: monta Date: Wed, 5 Nov 2025 12:43:16 +0100 Subject: [PATCH 24/39] Use warnings.warn() in load_from_thrustcurve_api when motor is found (as requested) --- rocketpy/motors/motor.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/rocketpy/motors/motor.py b/rocketpy/motors/motor.py index 1f548a48d..dc1575fb1 100644 --- a/rocketpy/motors/motor.py +++ b/rocketpy/motors/motor.py @@ -1,5 +1,4 @@ import base64 -import logging import re import tempfile import warnings @@ -16,8 +15,6 @@ from ..prints.motor_prints import _MotorPrints from ..tools import parallel_axis_theorem_from_com, tuple_handler -logger = logging.getLogger(__name__) - # pylint: disable=too-many-public-methods class Motor(ABC): @@ -1965,8 +1962,7 @@ def load_from_thrustcurve_api(name: str, **kwargs): motor_id = motor_info.get("motorId") designation = motor_info.get("designation", "").replace("/", "-") manufacturer = motor_info.get("manufacturer", "") - # Logging the fact that the motor was found - logger.info(f"Motor found: {designation} ({manufacturer})") + warnings.warn(f"Motor found: {designation} ({manufacturer})", UserWarning) # Step 2. Download the .eng file dl_response = requests.get( From 505a9fa298c20111c3301108ddbcfd19858264f1 Mon Sep 17 00:00:00 2001 From: monta Date: Wed, 5 Nov 2025 15:00:44 +0100 Subject: [PATCH 25/39] Added documentation for the load_from_thrustcurve_api method into the genericmotors.rst file --- docs/user/motors/genericmotor.rst | 80 +++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/docs/user/motors/genericmotor.rst b/docs/user/motors/genericmotor.rst index f9da46fd0..35745597b 100644 --- a/docs/user/motors/genericmotor.rst +++ b/docs/user/motors/genericmotor.rst @@ -106,3 +106,83 @@ note that the user can still provide the parameters manually if needed. The ``load_from_eng_file`` method is a very useful tool for simulating motors \ when the user does not have all the information required to build a ``SolidMotor`` yet. +The ``load_from_thrustcurve_api`` method +--------------------------------------- + +The ``GenericMotor`` class provides a convenience loader that downloads a temporary +`.eng` file from the ThrustCurve.org public API and builds a ``GenericMotor`` +instance from it. This is useful when you know a motor designation (for example +``"M1670"``) but do not want to manually download and +save the `.eng` file. + +.. note:: + + This method performs network requests to the ThrustCurve API. Use it only + when you have network access. For automated testing or reproducible runs, + prefer using local `.eng` files. +Signature +---------- + +``GenericMotor.load_from_thrustcurve_api(name: str, **kwargs) -> GenericMotor`` + +Parameters +---------- +name : str + Motor name to search on ThrustCurve (example: + ``"M1670"``).Only shorthand names are accepted (e.g. ``"M1670"``, not + ``"Cesaroni M1670"``). + when multiple matches occur the first result returned by the API is used. +**kwargs : + Same optional arguments accepted by the :class:`GenericMotor` constructor + (e.g. ``dry_mass``, ``nozzle_radius``, ``interpolation_method``). Any + parameters provided here override values parsed from the downloaded file. + +Returns +---------- +GenericMotor + A new ``GenericMotor`` instance created from the .eng data downloaded from + ThrustCurve. + +Raises +---------- +ValueError + If the API search returns no motor, or if the download endpoint returns no + .eng file or empty/invalid data. +requests.exceptions.RequestException + +Behavior notes +--------------- +- The method first performs a search on ThrustCurve using the provided name. + If no results are returned a :class:`ValueError` is raised. +- If a motor is found the method requests the .eng file in RASP format, decodes + it and temporarily writes it to disk; a ``GenericMotor`` is then constructed + using the existing .eng file loader. The temporary file is removed even if an + error occurs. +- The function emits a non-fatal informational warning when a motor is found + (``warnings.warn(...)``). This follows the repository convention for + non-critical messages; callers can filter or suppress warnings as needed. + +Example +--------------- + +.. jupyter-execute:: + + from rocketpy.motors import GenericMotor + + # Build a motor by name (requires network access) + motor = GenericMotor.load_from_thrustcurve_api("M1670") + + # Use the motor as usual + motor.info() + +Testing advice +--------------- +- ``pytest``'s ``caplog`` or ``capfd`` to assert on log/warning output. + +Security & reliability +---------------- +- The method makes outgoing HTTP requests and decodes base64-encoded content; + validate inputs in upstream code if you accept motor names from untrusted + sources. +- Network failures, API rate limits, or changes to the ThrustCurve API may + break loading; consider caching downloaded `.eng` files for production use. \ No newline at end of file From 9ac9bcca8f3d99fdb07c6cf7666b50bacb9c72da Mon Sep 17 00:00:00 2001 From: Marchma0 Date: Wed, 12 Nov 2025 14:41:32 +0000 Subject: [PATCH 26/39] Changes to conform to lint --- rocketpy/motors/motor.py | 42 +++++++++++++++++++++----- tests/unit/motors/test_genericmotor.py | 3 ++ 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/rocketpy/motors/motor.py b/rocketpy/motors/motor.py index dc1575fb1..c2e00a428 100644 --- a/rocketpy/motors/motor.py +++ b/rocketpy/motors/motor.py @@ -1918,9 +1918,9 @@ def load_from_rse_file( ) @staticmethod - def load_from_thrustcurve_api(name: str, **kwargs): + def call_thrustcurve_api(name: str): """ - Creates a Motor instance by downloading a .eng file from the ThrustCurve API + Download a .eng file from the ThrustCurve API based on the given motor name. Parameters @@ -1929,14 +1929,11 @@ def load_from_thrustcurve_api(name: str, **kwargs): The motor name according to the API (e.g., "Cesaroni_M1670" or "M1670"). Both manufacturer-prefixed and shorthand names are commonly used; if multiple motors match the search, the first result is used. - **kwargs : - Additional arguments passed to the Motor constructor or loader, such as - dry_mass, nozzle_radius, etc. Returns ------- - instance : GenericMotor - A new GenericMotor instance initialized using the downloaded .eng file. + data_base64 : String + The .eng file of the motor in base64 Raises ------ @@ -1982,7 +1979,38 @@ def load_from_thrustcurve_api(name: str, **kwargs): raise ValueError( f"Downloaded .eng data for motor '{name}' is empty or invalid." ) + return data_base64 + + @staticmethod + def load_from_thrustcurve_api(name: str, **kwargs): + """ + Creates a Motor instance by downloading a .eng file from the ThrustCurve API + based on the given motor name. + + Parameters + ---------- + name : str + The motor name according to the API (e.g., "Cesaroni_M1670" or "M1670"). + Both manufacturer-prefixed and shorthand names are commonly used; if multiple + motors match the search, the first result is used. + **kwargs : + Additional arguments passed to the Motor constructor or loader, such as + dry_mass, nozzle_radius, etc. + + Returns + ------- + instance : GenericMotor + A new GenericMotor instance initialized using the downloaded .eng file. + + Raises + ------ + ValueError + If no motor is found or if the downloaded .eng data is missing. + requests.exceptions.RequestException + If a network or HTTP error occurs during the API call. + """ + data_base64 = GenericMotor.call_thrustcurve_api(name) data_bytes = base64.b64decode(data_base64) # Step 3. Create the motor from the .eng file diff --git a/tests/unit/motors/test_genericmotor.py b/tests/unit/motors/test_genericmotor.py index 306f6cd74..40a19f24d 100644 --- a/tests/unit/motors/test_genericmotor.py +++ b/tests/unit/motors/test_genericmotor.py @@ -229,6 +229,9 @@ def test_load_from_thrustcurve_api(monkeypatch, generic_motor): """ class MockResponse: + """ + Class to Mock the API + """ def __init__(self, json_data): self._json_data = json_data From c4bf266def9aa01df352e7992004c28027e18f9f Mon Sep 17 00:00:00 2001 From: Marchma0 Date: Mon, 17 Nov 2025 15:35:41 +0000 Subject: [PATCH 27/39] Fixed Pylint errors --- tests/unit/motors/test_genericmotor.py | 188 ++++++++++--------------- 1 file changed, 78 insertions(+), 110 deletions(-) diff --git a/tests/unit/motors/test_genericmotor.py b/tests/unit/motors/test_genericmotor.py index 40a19f24d..f2ea7446a 100644 --- a/tests/unit/motors/test_genericmotor.py +++ b/tests/unit/motors/test_genericmotor.py @@ -216,76 +216,44 @@ def test_load_from_rse_file(generic_motor): assert thrust_curve[-1][1] == 0.0 # Last thrust point -def test_load_from_thrustcurve_api(monkeypatch, generic_motor): - """ - Tests the GenericMotor.load_from_thrustcurve_api method with mocked ThrustCurve API responses. - Parameters - ---------- - monkeypatch : pytest.MonkeyPatch - The pytest monkeypatch fixture for mocking. - generic_motor : rocketpy.GenericMotor - The GenericMotor object to be used in the tests. +class MockResponse: + """Mocked response for requests.""" - """ + def __init__(self, json_data): + self._json_data = json_data - class MockResponse: - """ - Class to Mock the API - """ - def __init__(self, json_data): - self._json_data = json_data + def json(self): + return self._json_data - def json(self): - return self._json_data + def raise_for_status(self): + return None - def raise_for_status(self): - # Simulate a successful HTTP response (200) - return None - # Provide mocked responses for the two endpoints: search.json and download.json - def mock_get(url, params=None): +def _mock_get(search_results=None, download_results=None): + """Return a mock_get function with predefined search/download results.""" + + def _get(url, **_kwargs): if "search.json" in url: - # Return a mock search result with a motorId and designation - return MockResponse( - { - "results": [ - { - "motorId": "12345", - "designation": "Cesaroni_M1670", - "manufacturer": "Cesaroni", - } - ] - } - ) - elif "download.json" in url: - # Read the local .eng file and return its base64-encoded content as the API would - eng_path = "data/motors/cesaroni/Cesaroni_M1670.eng" - with open(eng_path, "rb") as f: - encoded = base64.b64encode(f.read()).decode("utf-8") - return MockResponse({"results": [{"data": encoded}]}) - else: - raise RuntimeError(f"Unexpected URL called in test mock: {url}") - - monkeypatch.setattr(requests, "get", mock_get) - - # Expected parameters from the original test + return MockResponse(search_results or {"results": []}) + if "download.json" in url: + return MockResponse(download_results or {"results": []}) + raise RuntimeError(f"Unexpected URL: {url}") + + return _get + + +def assert_motor_specs(motor): burn_time = (0, 3.9) - dry_mass = 5.231 - 3.101 # 2.130 kg + dry_mass = 2.130 propellant_initial_mass = 3.101 chamber_radius = 75 / 1000 chamber_height = 757 / 1000 - nozzle_radius = chamber_radius * 0.85 # 85% of chamber radius - + nozzle_radius = chamber_radius * 0.85 average_thrust = 1545.218 total_impulse = 6026.350 max_thrust = 2200.0 exhaust_velocity = 1943.357 - # Call the method using the class (works if it's a staticmethod); using type(generic_motor) - # ensures test works if the method is invoked on a GenericMotor instance in the project - motor = type(generic_motor).load_from_thrustcurve_api("M1670") - - # Assertions (same as original) assert motor.burn_time == burn_time assert motor.dry_mass == dry_mass assert motor.propellant_initial_mass == propellant_initial_mass @@ -298,62 +266,62 @@ def mock_get(url, params=None): assert motor.max_thrust == pytest.approx(max_thrust) assert motor.nozzle_radius == pytest.approx(nozzle_radius) - # testing thrust curve equality against the local .eng import (as in original test) - _, _, points = Motor.import_eng("data/motors/cesaroni/Cesaroni_M1670.eng") + +def test_load_from_thrustcurve_api(monkeypatch, generic_motor): + """Tests GenericMotor.load_from_thrustcurve_api with mocked API.""" + + eng_path = "data/motors/cesaroni/Cesaroni_M1670.eng" + with open(eng_path, "rb") as f: + encoded = base64.b64encode(f.read()).decode("utf-8") + + search_json = { + "results": [ + { + "motorId": "12345", + "designation": "Cesaroni_M1670", + "manufacturer": "Cesaroni", + } + ] + } + download_json = {"results": [{"data": encoded}]} + monkeypatch.setattr(requests, "get", _mock_get(search_json, download_json)) + monkeypatch.setattr(requests.Session, "get", _mock_get(search_json, download_json)) + + motor = type(generic_motor).load_from_thrustcurve_api("M1670") + + assert_motor_specs(motor) + + _, _, points = Motor.import_eng(eng_path) assert motor.thrust.y_array == pytest.approx( Function(points, "Time (s)", "Thrust (N)", "linear", "zero").y_array ) - # 1. No motor found - def mock_get_no_motor(url, params=None): - if "search.json" in url: - return MockResponse({"results": []}) - return MockResponse({"results": []}) - - monkeypatch.setattr(requests, "get", mock_get_no_motor) - with pytest.raises(ValueError, match="No motor found"): - type(generic_motor).load_from_thrustcurve_api("NonexistentMotor") - - # 2. No .eng file found - def mock_get_no_eng(url, params=None): - if "search.json" in url: - return MockResponse( - { - "results": [ - { - "motorId": "123", - "designation": "Fake", - "manufacturer": "Test", - } - ] - } - ) - elif "download.json" in url: - return MockResponse({"results": []}) - return MockResponse({}) - - monkeypatch.setattr(requests, "get", mock_get_no_eng) - with pytest.raises(ValueError, match="No .eng file found"): - type(generic_motor).load_from_thrustcurve_api("FakeMotor") - - # 3. Empty .eng data - def mock_get_empty_data(url, params=None): - if "search.json" in url: - return MockResponse( - { - "results": [ - { - "motorId": "123", - "designation": "Fake", - "manufacturer": "Test", - } - ] - } - ) - elif "download.json" in url: - return MockResponse({"results": [{"data": ""}]}) - return MockResponse({}) - - monkeypatch.setattr(requests, "get", mock_get_empty_data) - with pytest.raises(ValueError, match="Downloaded .eng data"): - type(generic_motor).load_from_thrustcurve_api("FakeMotor") + error_cases = [ + ("No motor found", {"results": []}, None), + ( + "No .eng file found", + { + "results": [ + {"motorId": "123", "designation": "Fake", "manufacturer": "Test"} + ] + }, + {"results": []}, + ), + ( + "Downloaded .eng data", + { + "results": [ + {"motorId": "123", "designation": "Fake", "manufacturer": "Test"} + ] + }, + {"results": [{"data": ""}]}, + ), + ] + + for msg, search_res, download_res in error_cases: + monkeypatch.setattr(requests, "get", _mock_get(search_res, download_res)) + monkeypatch.setattr( + requests.Session, "get", _mock_get(search_res, download_res) + ) + with pytest.raises(ValueError, match=msg): + type(generic_motor).load_from_thrustcurve_api("FakeMotor") From 94fa35e2ed8c77d64c9e3b597ef8c4db40c60cb5 Mon Sep 17 00:00:00 2001 From: Marchma0 <88397818+Marchma0@users.noreply.github.com> Date: Wed, 26 Nov 2025 09:33:01 +0100 Subject: [PATCH 28/39] Update docs/user/motors/genericmotor.rst Co-authored-by: Gui-FernandesBR <63590233+Gui-FernandesBR@users.noreply.github.com> --- docs/user/motors/genericmotor.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user/motors/genericmotor.rst b/docs/user/motors/genericmotor.rst index 35745597b..0418cb797 100644 --- a/docs/user/motors/genericmotor.rst +++ b/docs/user/motors/genericmotor.rst @@ -107,7 +107,7 @@ note that the user can still provide the parameters manually if needed. when the user does not have all the information required to build a ``SolidMotor`` yet. The ``load_from_thrustcurve_api`` method ---------------------------------------- +---------------------------------------- The ``GenericMotor`` class provides a convenience loader that downloads a temporary `.eng` file from the ThrustCurve.org public API and builds a ``GenericMotor`` From d50c03f614043ae2a3e021fa4f52a331f47bf913 Mon Sep 17 00:00:00 2001 From: Marchma0 <88397818+Marchma0@users.noreply.github.com> Date: Wed, 26 Nov 2025 09:33:18 +0100 Subject: [PATCH 29/39] Update docs/user/motors/genericmotor.rst Co-authored-by: Gui-FernandesBR <63590233+Gui-FernandesBR@users.noreply.github.com> --- docs/user/motors/genericmotor.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/user/motors/genericmotor.rst b/docs/user/motors/genericmotor.rst index 0418cb797..8f03f1af3 100644 --- a/docs/user/motors/genericmotor.rst +++ b/docs/user/motors/genericmotor.rst @@ -120,6 +120,7 @@ save the `.eng` file. This method performs network requests to the ThrustCurve API. Use it only when you have network access. For automated testing or reproducible runs, prefer using local `.eng` files. + Signature ---------- From d3f09978858c8edeaf3d919bd64cf826e9fc1a04 Mon Sep 17 00:00:00 2001 From: Marchma0 <88397818+Marchma0@users.noreply.github.com> Date: Wed, 26 Nov 2025 09:33:39 +0100 Subject: [PATCH 30/39] Update docs/user/motors/genericmotor.rst Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/user/motors/genericmotor.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user/motors/genericmotor.rst b/docs/user/motors/genericmotor.rst index 8f03f1af3..d2959b35c 100644 --- a/docs/user/motors/genericmotor.rst +++ b/docs/user/motors/genericmotor.rst @@ -155,7 +155,7 @@ Behavior notes --------------- - The method first performs a search on ThrustCurve using the provided name. If no results are returned a :class:`ValueError` is raised. -- If a motor is found the method requests the .eng file in RASP format, decodes +- If a motor is found, the method requests the .eng file in RASP format, decodes it and temporarily writes it to disk; a ``GenericMotor`` is then constructed using the existing .eng file loader. The temporary file is removed even if an error occurs. From ea1ed6e4a4a43e4d2637973883225f04c84de62f Mon Sep 17 00:00:00 2001 From: Marchma0 Date: Wed, 26 Nov 2025 08:39:08 +0000 Subject: [PATCH 31/39] Set private the method call_thrustcurve_api --- rocketpy/motors/motor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rocketpy/motors/motor.py b/rocketpy/motors/motor.py index c2e00a428..3706be365 100644 --- a/rocketpy/motors/motor.py +++ b/rocketpy/motors/motor.py @@ -1918,7 +1918,7 @@ def load_from_rse_file( ) @staticmethod - def call_thrustcurve_api(name: str): + def _call_thrustcurve_api(name: str): """ Download a .eng file from the ThrustCurve API based on the given motor name. From eda5e5a559463fa3fa7b49de5aa0ac6e3373d51c Mon Sep 17 00:00:00 2001 From: Marchma0 <88397818+Marchma0@users.noreply.github.com> Date: Wed, 26 Nov 2025 09:40:02 +0100 Subject: [PATCH 32/39] Update docs/user/motors/genericmotor.rst Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/user/motors/genericmotor.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user/motors/genericmotor.rst b/docs/user/motors/genericmotor.rst index d2959b35c..8fca14b35 100644 --- a/docs/user/motors/genericmotor.rst +++ b/docs/user/motors/genericmotor.rst @@ -156,7 +156,7 @@ Behavior notes - The method first performs a search on ThrustCurve using the provided name. If no results are returned a :class:`ValueError` is raised. - If a motor is found, the method requests the .eng file in RASP format, decodes - it and temporarily writes it to disk; a ``GenericMotor`` is then constructed + it and temporarily writes it to disk. A ``GenericMotor`` is then constructed using the existing .eng file loader. The temporary file is removed even if an error occurs. - The function emits a non-fatal informational warning when a motor is found From 15f12c4c836707aff2c95494b6657a6e861c5310 Mon Sep 17 00:00:00 2001 From: Marchma0 <88397818+Marchma0@users.noreply.github.com> Date: Wed, 26 Nov 2025 09:40:48 +0100 Subject: [PATCH 33/39] Update tests/unit/motors/test_genericmotor.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/unit/motors/test_genericmotor.py | 51 ++++++++++++++------------ 1 file changed, 27 insertions(+), 24 deletions(-) diff --git a/tests/unit/motors/test_genericmotor.py b/tests/unit/motors/test_genericmotor.py index f2ea7446a..c97096464 100644 --- a/tests/unit/motors/test_genericmotor.py +++ b/tests/unit/motors/test_genericmotor.py @@ -242,31 +242,34 @@ def _get(url, **_kwargs): return _get -def assert_motor_specs(motor): - burn_time = (0, 3.9) - dry_mass = 2.130 - propellant_initial_mass = 3.101 - chamber_radius = 75 / 1000 - chamber_height = 757 / 1000 - nozzle_radius = chamber_radius * 0.85 - average_thrust = 1545.218 - total_impulse = 6026.350 - max_thrust = 2200.0 - exhaust_velocity = 1943.357 - - assert motor.burn_time == burn_time - assert motor.dry_mass == dry_mass - assert motor.propellant_initial_mass == propellant_initial_mass - assert motor.chamber_radius == chamber_radius - assert motor.chamber_height == chamber_height - assert motor.chamber_position == 0 - assert motor.average_thrust == pytest.approx(average_thrust) - assert motor.total_impulse == pytest.approx(total_impulse) - assert motor.exhaust_velocity.average(*burn_time) == pytest.approx(exhaust_velocity) - assert motor.max_thrust == pytest.approx(max_thrust) - assert motor.nozzle_radius == pytest.approx(nozzle_radius) - +# Module-level constant for expected motor specs +EXPECTED_MOTOR_SPECS = { + "burn_time": (0, 3.9), + "dry_mass": 2.130, + "propellant_initial_mass": 3.101, + "chamber_radius": 75 / 1000, + "chamber_height": 757 / 1000, + "nozzle_radius": (75 / 1000) * 0.85, + "average_thrust": 1545.218, + "total_impulse": 6026.350, + "max_thrust": 2200.0, + "exhaust_velocity": 1943.357, + "chamber_position": 0, +} +def assert_motor_specs(motor): + specs = EXPECTED_MOTOR_SPECS + assert motor.burn_time == specs["burn_time"] + assert motor.dry_mass == specs["dry_mass"] + assert motor.propellant_initial_mass == specs["propellant_initial_mass"] + assert motor.chamber_radius == specs["chamber_radius"] + assert motor.chamber_height == specs["chamber_height"] + assert motor.chamber_position == specs["chamber_position"] + assert motor.average_thrust == pytest.approx(specs["average_thrust"]) + assert motor.total_impulse == pytest.approx(specs["total_impulse"]) + assert motor.exhaust_velocity.average(*specs["burn_time"]) == pytest.approx(specs["exhaust_velocity"]) + assert motor.max_thrust == pytest.approx(specs["max_thrust"]) + assert motor.nozzle_radius == pytest.approx(specs["nozzle_radius"]) def test_load_from_thrustcurve_api(monkeypatch, generic_motor): """Tests GenericMotor.load_from_thrustcurve_api with mocked API.""" From ddbc22d808b127e82444ca6cde7c9f0ecdae81fc Mon Sep 17 00:00:00 2001 From: Marchma0 <88397818+Marchma0@users.noreply.github.com> Date: Wed, 26 Nov 2025 09:41:22 +0100 Subject: [PATCH 34/39] Update rocketpy/motors/motor.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- rocketpy/motors/motor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rocketpy/motors/motor.py b/rocketpy/motors/motor.py index 3706be365..05171be83 100644 --- a/rocketpy/motors/motor.py +++ b/rocketpy/motors/motor.py @@ -1932,7 +1932,7 @@ def _call_thrustcurve_api(name: str): Returns ------- - data_base64 : String + data_base64 : str The .eng file of the motor in base64 Raises From 18a0fcbbd97c73744037b415e99a255f62d67f7e Mon Sep 17 00:00:00 2001 From: Marchma0 <88397818+Marchma0@users.noreply.github.com> Date: Wed, 26 Nov 2025 09:41:33 +0100 Subject: [PATCH 35/39] Update docs/user/motors/genericmotor.rst Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/user/motors/genericmotor.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user/motors/genericmotor.rst b/docs/user/motors/genericmotor.rst index 8fca14b35..53ad29b8f 100644 --- a/docs/user/motors/genericmotor.rst +++ b/docs/user/motors/genericmotor.rst @@ -178,7 +178,7 @@ Example Testing advice --------------- -- ``pytest``'s ``caplog`` or ``capfd`` to assert on log/warning output. +- Use ``pytest``'s ``caplog`` or ``capfd`` to assert on log/warning output. Security & reliability ---------------- From fb8db2645df95df1f52fff00ed628febbbc00b33 Mon Sep 17 00:00:00 2001 From: Marchma0 <88397818+Marchma0@users.noreply.github.com> Date: Wed, 26 Nov 2025 09:43:13 +0100 Subject: [PATCH 36/39] Update docs/user/motors/genericmotor.rst Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/user/motors/genericmotor.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/user/motors/genericmotor.rst b/docs/user/motors/genericmotor.rst index 53ad29b8f..90c4380af 100644 --- a/docs/user/motors/genericmotor.rst +++ b/docs/user/motors/genericmotor.rst @@ -129,10 +129,10 @@ Signature Parameters ---------- name : str - Motor name to search on ThrustCurve (example: - ``"M1670"``).Only shorthand names are accepted (e.g. ``"M1670"``, not - ``"Cesaroni M1670"``). - when multiple matches occur the first result returned by the API is used. + Motor name to search on ThrustCurve (examples: + ``"M1670"`` or ``"Cesaroni M1670"``). Both shorthand and manufacturer-prefixed + names are accepted. When multiple matches occur, the first result returned by + the API is used. **kwargs : Same optional arguments accepted by the :class:`GenericMotor` constructor (e.g. ``dry_mass``, ``nozzle_radius``, ``interpolation_method``). Any From 0d6fb3495918dba020c38d82686d30a0eeca51a5 Mon Sep 17 00:00:00 2001 From: Marchma0 <88397818+Marchma0@users.noreply.github.com> Date: Wed, 26 Nov 2025 09:43:27 +0100 Subject: [PATCH 37/39] Update docs/user/motors/genericmotor.rst Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/user/motors/genericmotor.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/user/motors/genericmotor.rst b/docs/user/motors/genericmotor.rst index 90c4380af..f572944e6 100644 --- a/docs/user/motors/genericmotor.rst +++ b/docs/user/motors/genericmotor.rst @@ -150,6 +150,7 @@ ValueError If the API search returns no motor, or if the download endpoint returns no .eng file or empty/invalid data. requests.exceptions.RequestException + If a network or HTTP error occurs during the API call to ThrustCurve. Behavior notes --------------- From 3d53911f8a3b3328517158e9ed848f80ed3aa04e Mon Sep 17 00:00:00 2001 From: Marchma0 Date: Wed, 26 Nov 2025 08:58:00 +0000 Subject: [PATCH 38/39] Modify the changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6774e4e10..9324dfe6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). Attention: The newest changes should be on top --> ### Added +- ENH: Add thrustcurve api integration to retrieve motor eng data [#870](https://github.com/RocketPy-Team/RocketPy/pull/870) - ENH: Custom Exception errors and messages [#285](https://github.com/RocketPy-Team/RocketPy/issues/285) From a048bb4e6e67bbcab67b764ea6d22b16a0bcc893 Mon Sep 17 00:00:00 2001 From: Marchma0 Date: Wed, 26 Nov 2025 16:29:00 +0000 Subject: [PATCH 39/39] Bug fix --- rocketpy/motors/motor.py | 2 +- tests/unit/motors/test_genericmotor.py | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/rocketpy/motors/motor.py b/rocketpy/motors/motor.py index 05171be83..4415b362e 100644 --- a/rocketpy/motors/motor.py +++ b/rocketpy/motors/motor.py @@ -2010,7 +2010,7 @@ def load_from_thrustcurve_api(name: str, **kwargs): If a network or HTTP error occurs during the API call. """ - data_base64 = GenericMotor.call_thrustcurve_api(name) + data_base64 = GenericMotor._call_thrustcurve_api(name) data_bytes = base64.b64decode(data_base64) # Step 3. Create the motor from the .eng file diff --git a/tests/unit/motors/test_genericmotor.py b/tests/unit/motors/test_genericmotor.py index c97096464..3d0fbd766 100644 --- a/tests/unit/motors/test_genericmotor.py +++ b/tests/unit/motors/test_genericmotor.py @@ -257,6 +257,7 @@ def _get(url, **_kwargs): "chamber_position": 0, } + def assert_motor_specs(motor): specs = EXPECTED_MOTOR_SPECS assert motor.burn_time == specs["burn_time"] @@ -267,9 +268,13 @@ def assert_motor_specs(motor): assert motor.chamber_position == specs["chamber_position"] assert motor.average_thrust == pytest.approx(specs["average_thrust"]) assert motor.total_impulse == pytest.approx(specs["total_impulse"]) - assert motor.exhaust_velocity.average(*specs["burn_time"]) == pytest.approx(specs["exhaust_velocity"]) + assert motor.exhaust_velocity.average(*specs["burn_time"]) == pytest.approx( + specs["exhaust_velocity"] + ) assert motor.max_thrust == pytest.approx(specs["max_thrust"]) assert motor.nozzle_radius == pytest.approx(specs["nozzle_radius"]) + + def test_load_from_thrustcurve_api(monkeypatch, generic_motor): """Tests GenericMotor.load_from_thrustcurve_api with mocked API."""