diff --git a/.github/workflows/linters.yml b/.github/workflows/linters.yml index 3e5a3e81d..3e9fe6154 100644 --- a/.github/workflows/linters.yml +++ b/.github/workflows/linters.yml @@ -29,6 +29,7 @@ jobs: pip install .[all] pip install .[tests] pip install pylint ruff + pip install --upgrade pylint - name: Ruff (lint) run: ruff check --output-format=github . - name: Ruff (format) diff --git a/.pylintrc b/.pylintrc index e417e0b11..e25204956 100644 --- a/.pylintrc +++ b/.pylintrc @@ -1,4 +1,5 @@ [MAIN] +# pylint: disable=unrecognized-option # Analyse import fallback blocks. This can be used to support both Python 2 and # 3 compatible code, which means that the block might have code that exists @@ -101,7 +102,7 @@ source-roots= # When enabled, pylint would attempt to guess common misconfiguration and emit # user-friendly hints instead of false-positive error messages. -suggestion-mode=yes +#suggestion-mode=yes # Allow loading of arbitrary C extensions. Extensions are imported into the # active Python interpreter and may run arbitrary code. diff --git a/docs/user/motors/genericmotor.rst b/docs/user/motors/genericmotor.rst index f9da46fd0..8e4b78e31 100644 --- a/docs/user/motors/genericmotor.rst +++ b/docs/user/motors/genericmotor.rst @@ -1,5 +1,7 @@ +# pylint: disable=unrecognized-option .. _genericmotor: + GenericMotor Class Usage ======================== @@ -106,3 +108,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 diff --git a/rocketpy/motors/motor.py b/rocketpy/motors/motor.py index 7930ed52b..c2e00a428 100644 --- a/rocketpy/motors/motor.py +++ b/rocketpy/motors/motor.py @@ -1,11 +1,14 @@ +import base64 import re +import tempfile import warnings 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 from ..mathutils.function import Function, funcify_method from ..plots.motor_plots import _MotorPlots @@ -1914,6 +1917,121 @@ def load_from_rse_file( coordinate_system_orientation=coordinate_system_orientation, ) + @staticmethod + def call_thrustcurve_api(name: str): + """ + Download 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. + + Returns + ------- + data_base64 : String + The .eng file of the motor in base64 + + 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 + response = requests.get(f"{base_url}/search.json", params={"commonName": name}) + response.raise_for_status() + data = response.json() + + if not data.get("results"): + 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_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) + + # 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() + 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." + ) + + 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." + ) + 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 + 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 + + return GenericMotor.load_from_eng_file(tmp_path, **kwargs) + 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.""" # Print motor details diff --git a/tests/unit/motors/test_genericmotor.py b/tests/unit/motors/test_genericmotor.py index 776d7b691..d2880fddc 100644 --- a/tests/unit/motors/test_genericmotor.py +++ b/tests/unit/motors/test_genericmotor.py @@ -1,5 +1,8 @@ +import base64 + import numpy as np import pytest +import requests import scipy.integrate from rocketpy import Function, Motor @@ -211,3 +214,146 @@ 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(monkeypatch, _generic_motor): # pylint: disable=too-many-statements + """ + 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: + """ + Class to Mock the API + """ + 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) # noqa: F821 + + # 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 + chamber_radius = 75 / 1000 + chamber_height = 757 / 1000 + nozzle_radius = chamber_radius * 0.85 # 85% of chamber radius + + 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") # noqa: F821 + + # 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 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) # noqa: F821 + 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) # noqa :F821 + with pytest.raises(ValueError, match="No .eng file found"): + type(_generic_motor).load_from_thrustcurve_api("FakeMotor") #noqa:F821 + + # 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) #noqa: F821 + with pytest.raises(ValueError, match="Downloaded .eng data"): + type(_generic_motor).load_from_thrustcurve_api("FakeMotor") #noqa: F821