From 50be27f136b4d8f3ecf649c0bae55c3c34671cad Mon Sep 17 00:00:00 2001 From: Gabriel Barberini Date: Sun, 1 Mar 2026 20:47:05 +0100 Subject: [PATCH 1/2] ENH: portable .rpy flight import/export and notebook generation (#56, #57) Replace dill-based binary serialization with RocketPy's native JSON-based .rpy format (RocketPyEncoder/RocketPyDecoder), making flight export and import architecture-, OS-, and Python-version- agnostic. Add POST /flights/upload to import .rpy files by decomposing them into Environment, Motor, Rocket and Flight models persisted through the standard CRUD pipeline. Add GET /flights/{id}/notebook to export a flight as a Jupyter notebook that loads the .rpy file via load_from_rpy. Closes #56, closes #57. Made-with: Cursor --- requirements.txt | 1 + src/controllers/flight.py | 83 +++- src/dependencies.py | 20 +- src/routes/flight.py | 100 ++++- src/routes/rocket.py | 4 + src/services/flight.py | 401 +++++++++++++++++- src/services/rocket.py | 2 +- src/views/flight.py | 8 + .../test_routes/test_environments_route.py | 8 +- tests/unit/test_routes/test_flights_route.py | 138 +++++- tests/unit/test_routes/test_motors_route.py | 8 +- tests/unit/test_routes/test_rockets_route.py | 8 +- 12 files changed, 721 insertions(+), 60 deletions(-) diff --git a/requirements.txt b/requirements.txt index 0814cd1..7274c7a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ dill python-dotenv +python-multipart fastapi uvloop pydantic diff --git a/src/controllers/flight.py b/src/controllers/flight.py index 8c45258..754ea5d 100644 --- a/src/controllers/flight.py +++ b/src/controllers/flight.py @@ -4,12 +4,13 @@ ControllerBase, controller_exception_handler, ) -from src.views.flight import FlightSimulation, FlightCreated +from src.views.flight import FlightSimulation, FlightCreated, FlightImported from src.models.flight import ( FlightModel, FlightWithReferencesRequest, ) from src.models.environment import EnvironmentModel +from src.models.motor import MotorModel from src.models.rocket import RocketModel from src.repositories.interface import RepositoryInterface from src.services.flight import FlightService @@ -22,6 +23,7 @@ class FlightController(ControllerBase): Enables: - Simulation of a RocketPy Flight. - CRUD for Flight BaseApiModel. + - Import/export as portable .rpy files and Jupyter notebooks. """ def __init__(self): @@ -122,25 +124,26 @@ async def update_rocket_by_flight_id( return @controller_exception_handler - async def get_rocketpy_flight_binary( + async def get_rocketpy_flight_rpy( self, flight_id: str, ) -> bytes: """ - Get rocketpy.flight as dill binary. + Get rocketpy.flight as a portable ``.rpy`` JSON file. Args: flight_id: str Returns: - bytes + bytes (UTF-8 encoded JSON) Raises: - HTTP 404 Not Found: If the flight is not found in the database. + HTTP 404 Not Found: If the flight is not found + in the database. """ flight = await self.get_flight_by_id(flight_id) flight_service = FlightService.from_flight_model(flight.flight) - return flight_service.get_flight_binary() + return flight_service.get_flight_rpy() @controller_exception_handler async def get_flight_simulation( @@ -162,3 +165,71 @@ async def get_flight_simulation( flight = await self.get_flight_by_id(flight_id) flight_service = FlightService.from_flight_model(flight.flight) return flight_service.get_flight_simulation() + + async def _persist_model(self, model_cls, model_instance) -> str: + repo_cls = RepositoryInterface.get_model_repo(model_cls) + async with repo_cls() as repo: + creator = getattr(repo, f"create_{model_cls.NAME}") + return await creator(model_instance) + + @controller_exception_handler + async def import_flight_from_rpy( + self, + content: bytes, + ) -> FlightImported: + """ + Import a ``.rpy`` JSON file: decompose the RocketPy Flight + into Environment, Motor, Rocket and Flight models, persist + each one via the normal CRUD pipeline, and return all IDs. + + Args: + content: raw bytes of a ``.rpy`` JSON file. + + Returns: + FlightImported with environment_id, motor_id, + rocket_id, and flight_id. + + Raises: + HTTP 422: If the file is not a valid ``.rpy`` Flight. + """ + try: + flight_service = FlightService.from_rpy(content) + except Exception as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Invalid .rpy file: {exc}", + ) from exc + + env, motor, rocket, flight = flight_service.extract_models() + + env_id = await self._persist_model(EnvironmentModel, env) + motor_id = await self._persist_model(MotorModel, motor) + rocket_id = await self._persist_model(RocketModel, rocket) + flight_id = await self._persist_model(FlightModel, flight) + + return FlightImported( + flight_id=flight_id, + rocket_id=rocket_id, + motor_id=motor_id, + environment_id=env_id, + ) + + @controller_exception_handler + async def get_flight_notebook( + self, + flight_id: str, + ) -> dict: + """ + Generate a Jupyter notebook for a persisted flight. + + Args: + flight_id: str + + Returns: + dict representing a valid .ipynb. + + Raises: + HTTP 404 Not Found: If the flight does not exist. + """ + await self.get_flight_by_id(flight_id) + return FlightService.generate_notebook(flight_id) diff --git a/src/dependencies.py b/src/dependencies.py index ccdb67b..6805deb 100644 --- a/src/dependencies.py +++ b/src/dependencies.py @@ -8,14 +8,15 @@ from src.controllers.environment import EnvironmentController from src.controllers.flight import FlightController + @cache def get_rocket_controller() -> RocketController: """ Provides a singleton RocketController instance. - + The controller is stateless and can be safely reused across requests. Using functools.cache memoizes this function so a single instance is reused per process; it does not by itself guarantee thread-safe initialization in multi-threaded setups. - + Returns: RocketController: Shared controller instance for rocket operations. """ @@ -26,7 +27,7 @@ def get_rocket_controller() -> RocketController: def get_motor_controller() -> MotorController: """ Provides a singleton MotorController instance. - + Returns: MotorController: Shared controller instance for motor operations. """ @@ -37,7 +38,7 @@ def get_motor_controller() -> MotorController: def get_environment_controller() -> EnvironmentController: """ Provides a singleton EnvironmentController instance. - + Returns: EnvironmentController: Shared controller instance for environment operations. """ @@ -48,15 +49,20 @@ def get_environment_controller() -> EnvironmentController: def get_flight_controller() -> FlightController: """ Provides a singleton FlightController instance. - + Returns: FlightController: Shared controller instance for flight operations. """ return FlightController() -RocketControllerDep = Annotated[RocketController, Depends(get_rocket_controller)] + +RocketControllerDep = Annotated[ + RocketController, Depends(get_rocket_controller) +] MotorControllerDep = Annotated[MotorController, Depends(get_motor_controller)] EnvironmentControllerDep = Annotated[ EnvironmentController, Depends(get_environment_controller) ] -FlightControllerDep = Annotated[FlightController, Depends(get_flight_controller)] +FlightControllerDep = Annotated[ + FlightController, Depends(get_flight_controller) +] diff --git a/src/routes/flight.py b/src/routes/flight.py index 03f8460..822537d 100644 --- a/src/routes/flight.py +++ b/src/routes/flight.py @@ -2,13 +2,16 @@ Flight routes with dependency injection for improved performance. """ -from fastapi import APIRouter, Response +import json + +from fastapi import APIRouter, Response, UploadFile, File from opentelemetry import trace from src.views.flight import ( FlightSimulation, FlightCreated, FlightRetrieved, + FlightImported, ) from src.models.environment import EnvironmentModel from src.models.flight import FlightModel, FlightWithReferencesRequest @@ -76,6 +79,7 @@ async def read_flight( with tracer.start_as_current_span("read_flight"): return await controller.get_flight_by_id(flight_id) + @router.put("/{flight_id}", status_code=204) async def update_flight( flight_id: str, @@ -117,6 +121,7 @@ async def update_flight_from_references( flight_id, payload ) + @router.delete("/{flight_id}", status_code=204) async def delete_flight( flight_id: str, @@ -136,34 +141,104 @@ async def delete_flight( "/{flight_id}/rocketpy", responses={ 200: { - "description": "Binary file download", - "content": {"application/octet-stream": {}}, + "description": "Portable .rpy JSON file download", + "content": {"application/json": {}}, } }, status_code=200, response_class=Response, ) +async def get_rocketpy_flight_rpy( + flight_id: str, + controller: FlightControllerDep, +): + """ + Export a rocketpy Flight as a portable ``.rpy`` JSON file. + + The ``.rpy`` format is architecture-, OS-, and + Python-version-agnostic. + + ## Args + ``` flight_id: str ``` + """ + with tracer.start_as_current_span("get_rocketpy_flight_rpy"): + headers = { + 'Content-Disposition': ( + 'attachment; filename=' f'"rocketpy_flight_{flight_id}.rpy"' + ), + } + rpy = await controller.get_rocketpy_flight_rpy(flight_id) + return Response( + content=rpy, + headers=headers, + media_type="application/json", + status_code=200, + ) -async def get_rocketpy_flight_binary( + +@router.post( + "/upload", + status_code=201, + responses={ + 201: {"description": "Flight imported from .rpy file"}, + 422: {"description": "Invalid .rpy file"}, + }, +) +async def import_flight_from_rpy( + file: UploadFile = File(...), + controller: FlightControllerDep = None, +) -> FlightImported: + """ + Upload a ``.rpy`` JSON file containing a RocketPy Flight. + + The file is deserialized and decomposed into its + constituent objects (Environment, Motor, Rocket, Flight). + Each object is persisted as a normal JSON model and the + corresponding IDs are returned. + + ## Args + ``` file: .rpy JSON upload ``` + """ + with tracer.start_as_current_span("import_flight_from_rpy"): + content = await file.read() + return await controller.import_flight_from_rpy(content) + + +@router.get( + "/{flight_id}/notebook", + responses={ + 200: { + "description": "Jupyter notebook file download", + "content": {"application/x-ipynb+json": {}}, + } + }, + status_code=200, + response_class=Response, +) +async def get_flight_notebook( flight_id: str, controller: FlightControllerDep, ): """ - Loads rocketpy.flight as a dill binary. - Currently only amd64 architecture is supported. + Export a flight as a Jupyter notebook (.ipynb). + + The notebook loads the flight's ``.rpy`` file and calls + ``flight.all_info()`` for interactive exploration. ## Args ``` flight_id: str ``` """ - with tracer.start_as_current_span("get_rocketpy_flight_binary"): + with tracer.start_as_current_span("get_flight_notebook"): + notebook = await controller.get_flight_notebook(flight_id) + content = json.dumps(notebook, indent=1).encode() + filename = f"flight_{flight_id}.ipynb" headers = { - 'Content-Disposition': f'attachment; filename="rocketpy_flight_{flight_id}.dill"' + "Content-Disposition": (f'attachment; filename="{filename}"'), } - binary = await controller.get_rocketpy_flight_binary(flight_id) return Response( - content=binary, + content=content, headers=headers, - media_type="application/octet-stream", + media_type="application/x-ipynb+json", status_code=200, ) @@ -210,6 +285,7 @@ async def update_flight_rocket( rocket=rocket, ) + @router.get("/{flight_id}/simulate") async def get_flight_simulation( flight_id: str, @@ -222,4 +298,4 @@ async def get_flight_simulation( ``` flight_id: Flight ID ``` """ with tracer.start_as_current_span("get_flight_simulation"): - return await controller.get_flight_simulation(flight_id) \ No newline at end of file + return await controller.get_flight_simulation(flight_id) diff --git a/src/routes/rocket.py b/src/routes/rocket.py index 54059b9..e65c846 100644 --- a/src/routes/rocket.py +++ b/src/routes/rocket.py @@ -42,6 +42,8 @@ async def create_rocket( """ with tracer.start_as_current_span("create_rocket"): return await controller.post_rocket(rocket) + + @router.post("/from-motor-reference", status_code=201) async def create_rocket_from_motor_reference( payload: RocketWithMotorReferenceRequest, @@ -114,6 +116,8 @@ async def update_rocket_from_motor_reference( return await controller.update_rocket_from_motor_reference( rocket_id, payload ) + + @router.delete("/{rocket_id}", status_code=204) async def delete_rocket( rocket_id: str, diff --git a/src/services/flight.py b/src/services/flight.py index e8f0fd1..e39e078 100644 --- a/src/services/flight.py +++ b/src/services/flight.py @@ -1,12 +1,32 @@ -from typing import Self +import json +from typing import Self, Tuple -import dill +import numpy as np from rocketpy.simulation.flight import Flight as RocketPyFlight +from rocketpy._encoders import RocketPyEncoder, RocketPyDecoder +from rocketpy.motors.solid_motor import SolidMotor +from rocketpy.motors.liquid_motor import LiquidMotor +from rocketpy.motors.hybrid_motor import HybridMotor +from rocketpy.rocket.aero_surface import ( + NoseCone as RocketPyNoseCone, + TrapezoidalFins as RocketPyTrapezoidalFins, + EllipticalFins as RocketPyEllipticalFins, + Tail as RocketPyTail, +) from src.services.environment import EnvironmentService from src.services.rocket import RocketService +from src.models.environment import EnvironmentModel +from src.models.motor import MotorModel, MotorKinds +from src.models.rocket import RocketModel from src.models.flight import FlightModel +from src.models.sub.aerosurfaces import ( + NoseCone, + Fins, + Tail, + Parachute, +) from src.views.flight import FlightSimulation from src.views.rocket import RocketSimulation from src.views.motor import MotorSimulation @@ -43,6 +63,37 @@ def from_flight_model(cls, flight: FlightModel) -> Self: ) return cls(flight=rocketpy_flight) + @classmethod + def from_rpy(cls, content: bytes) -> Self: + """ + Deserialize a JSON-based ``.rpy`` file into a FlightService. + + The ``.rpy`` format is RocketPy's native portable + serialization (plain JSON via ``RocketPyEncoder`` / + ``RocketPyDecoder``). It is architecture-, OS-, and + Python-version-agnostic. + + Args: + content: raw bytes of a ``.rpy`` JSON file. + + Returns: + FlightService wrapping the deserialized flight. + + Raises: + ValueError: If the payload is not valid ``.rpy`` JSON + or does not contain a Flight. + """ + data = json.loads(content) + simulation = data.get("simulation", data) + flight = json.loads( + json.dumps(simulation), + cls=RocketPyDecoder, + resimulate=False, + ) + if not isinstance(flight, RocketPyFlight): + raise ValueError("File does not contain a RocketPy Flight object") + return cls(flight=flight) + @property def flight(self) -> RocketPyFlight: return self._flight @@ -51,6 +102,266 @@ def flight(self) -> RocketPyFlight: def flight(self, flight: RocketPyFlight): self._flight = flight + def extract_models( + self, + ) -> Tuple[EnvironmentModel, MotorModel, RocketModel, FlightModel]: + """ + Decompose a live RocketPy Flight into the API model + hierarchy: Environment, Motor, Rocket, Flight. + + Returns: + (EnvironmentModel, MotorModel, RocketModel, FlightModel) + """ + env_model = self._extract_environment(self.flight.env) + motor_model = self._extract_motor(self.flight.rocket.motor) + rocket_model = self._extract_rocket(self.flight.rocket, motor_model) + flight_model = self._extract_flight( + self.flight, env_model, rocket_model + ) + return env_model, motor_model, rocket_model, flight_model + + # ------------------------------------------------------------------ + # Private extraction helpers + # ------------------------------------------------------------------ + + @staticmethod + def _extract_environment(env) -> EnvironmentModel: + return EnvironmentModel( + latitude=env.latitude, + longitude=env.longitude, + elevation=env.elevation, + atmospheric_model_type=env.atmospheric_model_type, + date=env.date, + ) + + @staticmethod + def _extract_motor(motor) -> MotorModel: + match motor: + case SolidMotor(): + kind = MotorKinds.SOLID + case HybridMotor(): + kind = MotorKinds.HYBRID + case LiquidMotor(): + kind = MotorKinds.LIQUID + case _: + kind = MotorKinds.GENERIC + + thrust = motor.thrust_source + match thrust: + case np.ndarray(): + thrust = thrust.tolist() + + data = { + "thrust_source": thrust, + "burn_time": motor.burn_duration, + "nozzle_radius": motor.nozzle_radius, + "dry_mass": motor.dry_mass, + "dry_inertia": ( + motor.dry_I_11, + motor.dry_I_22, + motor.dry_I_33, + ), + "center_of_dry_mass_position": (motor.center_of_dry_mass_position), + "motor_kind": kind, + "interpolation_method": motor.interpolate, + "coordinate_system_orientation": ( + motor.coordinate_system_orientation + ), + } + + match kind: + case MotorKinds.SOLID | MotorKinds.HYBRID: + data |= { + "grain_number": motor.grain_number, + "grain_density": motor.grain_density, + "grain_outer_radius": (motor.grain_outer_radius), + "grain_initial_inner_radius": ( + motor.grain_initial_inner_radius + ), + "grain_initial_height": (motor.grain_initial_height), + "grain_separation": motor.grain_separation, + "grains_center_of_mass_position": ( + motor.grains_center_of_mass_position + ), + "throat_radius": motor.throat_radius, + } + case MotorKinds.GENERIC: + data |= { + "chamber_radius": getattr(motor, "chamber_radius", None), + "chamber_height": getattr(motor, "chamber_height", None), + "chamber_position": getattr( + motor, "chamber_position", None + ), + "propellant_initial_mass": getattr( + motor, + "propellant_initial_mass", + None, + ), + "nozzle_position": getattr(motor, "nozzle_position", None), + } + + return MotorModel(**data) + + @staticmethod + def _drag_to_list(fn) -> list: + match getattr(fn, "source", None): + case np.ndarray() as arr: + return arr.tolist() + case _: + return [(0, 0)] + + @staticmethod + def _extract_rocket(rocket, motor_model: MotorModel) -> RocketModel: + nose = None + fins_list: list[Fins] = [] + tail = None + + for surface, position in rocket.aerodynamic_surfaces: + match position: + case (_, _, z): + pos_z = z + case [_, _, z]: + pos_z = z + case _: + pos_z = position + + match surface: + case RocketPyNoseCone(): + nose = NoseCone( + name=surface.name, + length=surface.length, + kind=surface.kind, + position=pos_z, + base_radius=surface.base_radius, + rocket_radius=surface.rocket_radius, + ) + case RocketPyTrapezoidalFins(): + fins_list.append( + Fins( + fins_kind="trapezoidal", + name=surface.name, + n=surface.n, + root_chord=surface.root_chord, + span=surface.span, + position=pos_z, + tip_chord=getattr(surface, "tip_chord", None), + cant_angle=getattr(surface, "cant_angle", None), + rocket_radius=surface.rocket_radius, + ) + ) + case RocketPyEllipticalFins(): + fins_list.append( + Fins( + fins_kind="elliptical", + name=surface.name, + n=surface.n, + root_chord=surface.root_chord, + span=surface.span, + position=pos_z, + rocket_radius=surface.rocket_radius, + ) + ) + case RocketPyTail(): + tail = Tail( + name=surface.name, + top_radius=surface.top_radius, + bottom_radius=surface.bottom_radius, + length=surface.length, + position=pos_z, + radius=surface.rocket_radius, + ) + + parachutes = ( + [ + Parachute( + name=p.name, + cd_s=p.cd_s, + trigger=p.trigger, + sampling_rate=p.sampling_rate, + lag=p.lag, + noise=p.noise, + ) + for p in rocket.parachutes + ] + if rocket.parachutes + else None + ) + + inertia = ( + rocket.I_11_without_motor, + rocket.I_22_without_motor, + rocket.I_33_without_motor, + ) + + default_fins = [ + Fins( + fins_kind="trapezoidal", + name="default", + n=0, + root_chord=0, + span=0, + position=0, + ) + ] + + return RocketModel( + motor=motor_model, + radius=rocket.radius, + mass=rocket.mass, + motor_position=rocket.motor_position, + center_of_mass_without_motor=(rocket.center_of_mass_without_motor), + inertia=inertia, + power_off_drag=FlightService._drag_to_list(rocket.power_off_drag), + power_on_drag=FlightService._drag_to_list(rocket.power_on_drag), + coordinate_system_orientation=( + rocket.coordinate_system_orientation + ), + nose=nose, + fins=fins_list or default_fins, + tail=tail, + parachutes=parachutes, + ) + + @staticmethod + def _extract_flight( + flight, + env: EnvironmentModel, + rocket: RocketModel, + ) -> FlightModel: + match getattr(flight, "equations_of_motion", "standard"): + case str() as eom: + pass + case _: + eom = "standard" + + optional = { + attr: val + for attr in ( + "max_time", + "max_time_step", + "min_time_step", + "rtol", + "atol", + ) + if (val := getattr(flight, attr, None)) is not None + } + + return FlightModel( + environment=env, + rocket=rocket, + rail_length=flight.rail_length, + time_overshoot=flight.time_overshoot, + terminate_on_apogee=flight.terminate_on_apogee, + equations_of_motion=eom, + inclination=flight.inclination, + heading=flight.heading, + **optional, + ) + + # ------------------------------------------------------------------ + # Simulation & binary + # ------------------------------------------------------------------ + def get_flight_simulation(self) -> FlightSimulation: """ Get the simulation of the flight. @@ -70,11 +381,89 @@ def get_flight_simulation(self) -> FlightSimulation: flight_simulation = FlightSimulation(**encoded_attributes) return flight_simulation - def get_flight_binary(self) -> bytes: + def get_flight_rpy(self) -> bytes: + """ + Get the portable JSON ``.rpy`` representation of the flight. + + Returns: + bytes (UTF-8 encoded JSON) + """ + return json.dumps( + {"simulation": self.flight}, + cls=RocketPyEncoder, + indent=2, + include_outputs=False, + ).encode() + + @staticmethod + def generate_notebook(flight_id: str) -> dict: """ - Get the binary representation of the flight. + Generate a Jupyter notebook dict for a given flight. + + The notebook loads the flight's ``.rpy`` file and calls + ``flight.all_info()``, giving power users a quick + playground. + + Args: + flight_id: Persisted flight identifier. Returns: - bytes + dict representing a valid .ipynb (nbformat 4). """ - return dill.dumps(self.flight) + rpy_filename = f"rocketpy_flight_{flight_id}.rpy" + cells = [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# RocketPy Flight Analysis\n", + "\n", + "This notebook was auto-generated by " + "**Infinity API**.\n", + "\n", + "It loads a serialised RocketPy `Flight` " + "object so you can inspect and extend the " + "analysis interactively.", + ], + }, + { + "cell_type": "code", + "execution_count": None, + "metadata": {}, + "outputs": [], + "source": [ + "from rocketpy.utilities import " "load_from_rpy\n", + "import matplotlib\n", + ], + }, + { + "cell_type": "code", + "execution_count": None, + "metadata": {}, + "outputs": [], + "source": [ + "flight = load_from_rpy(" + f'"{rpy_filename}", ' + "resimulate=False)\n", + "\n", + "flight.all_info()", + ], + }, + ] + notebook = { + "nbformat": 4, + "nbformat_minor": 5, + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3", + }, + "language_info": { + "name": "python", + "version": "3.12.0", + }, + }, + "cells": cells, + } + return notebook diff --git a/src/services/rocket.py b/src/services/rocket.py index 062d192..a22a2d4 100644 --- a/src/services/rocket.py +++ b/src/services/rocket.py @@ -188,7 +188,7 @@ def get_rocketpy_finset(fins: Fins, kind: str) -> RocketPyFins: for key, value in fins.get_additional_parameters().items() if key not in base_kwargs } - + match kind: case "trapezoidal": factory = RocketPyTrapezoidalFins diff --git a/src/views/flight.py b/src/views/flight.py index cb3dd93..50d52e1 100644 --- a/src/views/flight.py +++ b/src/views/flight.py @@ -150,6 +150,14 @@ class FlightCreated(ApiBaseView): flight_id: str +class FlightImported(ApiBaseView): + message: str = "Flight successfully imported from binary" + flight_id: str + rocket_id: str + motor_id: str + environment_id: str + + class FlightRetrieved(ApiBaseView): message: str = "Flight successfully retrieved" flight: FlightView diff --git a/tests/unit/test_routes/test_environments_route.py b/tests/unit/test_routes/test_environments_route.py index 45de696..d37c8e3 100644 --- a/tests/unit/test_routes/test_environments_route.py +++ b/tests/unit/test_routes/test_environments_route.py @@ -1,4 +1,4 @@ -from unittest.mock import patch, Mock, AsyncMock +from unittest.mock import patch, AsyncMock import json import pytest from fastapi.testclient import TestClient @@ -35,11 +35,11 @@ def mock_controller_instance(): mock_controller.delete_environment_by_id = AsyncMock() mock_controller.get_environment_simulation = AsyncMock() mock_controller.get_rocketpy_environment_binary = AsyncMock() - + mock_class.return_value = mock_controller - + yield mock_controller - + get_environment_controller.cache_clear() diff --git a/tests/unit/test_routes/test_flights_route.py b/tests/unit/test_routes/test_flights_route.py index d53ca54..82dae8b 100644 --- a/tests/unit/test_routes/test_flights_route.py +++ b/tests/unit/test_routes/test_flights_route.py @@ -1,4 +1,4 @@ -from unittest.mock import patch, Mock, AsyncMock +from unittest.mock import patch, AsyncMock import copy import json import pytest @@ -11,6 +11,7 @@ from src.views.rocket import RocketView from src.views.flight import ( FlightCreated, + FlightImported, FlightRetrieved, FlightSimulation, FlightView, @@ -53,18 +54,20 @@ def mock_controller_instance(): mock_controller.put_flight_by_id = AsyncMock() mock_controller.delete_flight_by_id = AsyncMock() mock_controller.get_flight_simulation = AsyncMock() - mock_controller.get_rocketpy_flight_binary = AsyncMock() + mock_controller.get_rocketpy_flight_rpy = AsyncMock() + mock_controller.import_flight_from_rpy = AsyncMock() + mock_controller.get_flight_notebook = AsyncMock() mock_controller.update_environment_by_flight_id = AsyncMock() mock_controller.update_rocket_by_flight_id = AsyncMock() mock_controller.create_flight_from_references = AsyncMock() mock_controller.update_flight_from_references = AsyncMock() - + mock_class.return_value = mock_controller - + get_flight_controller.cache_clear() - + yield mock_controller - + get_flight_controller.cache_clear() @@ -504,21 +507,22 @@ def test_get_flight_simulation_server_error(mock_controller_instance): assert response.json() == {'detail': 'Internal Server Error'} -def test_read_rocketpy_flight_binary(mock_controller_instance): - mock_controller_instance.get_rocketpy_flight_binary = AsyncMock( - return_value=b'rocketpy' +def test_read_rocketpy_flight_rpy(mock_controller_instance): + mock_controller_instance.get_rocketpy_flight_rpy = AsyncMock( + return_value=b'{"simulation": {}}', ) response = client.get('/flights/123/rocketpy') assert response.status_code == 200 - assert response.content == b'rocketpy' - assert response.headers['content-type'] == 'application/octet-stream' - mock_controller_instance.get_rocketpy_flight_binary.assert_called_once_with( + assert response.content == b'{"simulation": {}}' + assert response.headers['content-type'] == 'application/json' + assert '.rpy' in response.headers['content-disposition'] + mock_controller_instance.get_rocketpy_flight_rpy.assert_called_once_with( '123' ) -def test_read_rocketpy_flight_binary_not_found(mock_controller_instance): - mock_controller_instance.get_rocketpy_flight_binary.side_effect = ( +def test_read_rocketpy_flight_rpy_not_found(mock_controller_instance): + mock_controller_instance.get_rocketpy_flight_rpy.side_effect = ( HTTPException(status_code=status.HTTP_404_NOT_FOUND) ) response = client.get('/flights/123/rocketpy') @@ -526,10 +530,112 @@ def test_read_rocketpy_flight_binary_not_found(mock_controller_instance): assert response.json() == {'detail': 'Not Found'} -def test_read_rocketpy_flight_binary_server_error(mock_controller_instance): - mock_controller_instance.get_rocketpy_flight_binary.side_effect = ( +def test_read_rocketpy_flight_rpy_server_error(mock_controller_instance): + mock_controller_instance.get_rocketpy_flight_rpy.side_effect = ( HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) ) response = client.get('/flights/123/rocketpy') assert response.status_code == 500 assert response.json() == {'detail': 'Internal Server Error'} + + +# --- Issue #56: Import flight from .rpy --- + + +def test_import_flight_from_rpy(mock_controller_instance): + mock_controller_instance.import_flight_from_rpy = AsyncMock( + return_value=FlightImported( + flight_id='f1', + rocket_id='r1', + motor_id='m1', + environment_id='e1', + ) + ) + rpy_content = b'{"simulation": {}}' + response = client.post( + '/flights/upload', + files={ + 'file': ( + 'flight.rpy', + rpy_content, + 'application/json', + ) + }, + ) + assert response.status_code == 201 + body = response.json() + assert body['flight_id'] == 'f1' + assert body['rocket_id'] == 'r1' + assert body['motor_id'] == 'm1' + assert body['environment_id'] == 'e1' + assert 'imported' in body['message'].lower() + mock_controller_instance.import_flight_from_rpy.assert_called_once_with( + rpy_content + ) + + +def test_import_flight_from_rpy_invalid(mock_controller_instance): + mock_controller_instance.import_flight_from_rpy.side_effect = ( + HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail='Invalid .rpy file: bad data', + ) + ) + response = client.post( + '/flights/upload', + files={'file': ('bad.rpy', b'bad', 'application/json')}, + ) + assert response.status_code == 422 + + +def test_import_flight_from_rpy_server_error( + mock_controller_instance, +): + mock_controller_instance.import_flight_from_rpy.side_effect = ( + HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) + ) + response = client.post( + '/flights/upload', + files={'file': ('f.rpy', b'{}', 'application/json')}, + ) + assert response.status_code == 500 + + +# --- Issue #57: Export flight as notebook --- + + +def test_get_flight_notebook(mock_controller_instance): + notebook = { + 'nbformat': 4, + 'nbformat_minor': 5, + 'metadata': {}, + 'cells': [], + } + mock_controller_instance.get_flight_notebook = AsyncMock( + return_value=notebook + ) + response = client.get('/flights/123/notebook') + assert response.status_code == 200 + assert response.headers['content-type'] == 'application/x-ipynb+json' + assert 'flight_123.ipynb' in response.headers['content-disposition'] + body = json.loads(response.content) + assert body['nbformat'] == 4 + mock_controller_instance.get_flight_notebook.assert_called_once_with('123') + + +def test_get_flight_notebook_not_found(mock_controller_instance): + mock_controller_instance.get_flight_notebook.side_effect = HTTPException( + status_code=status.HTTP_404_NOT_FOUND + ) + response = client.get('/flights/999/notebook') + assert response.status_code == 404 + assert response.json() == {'detail': 'Not Found'} + + +def test_get_flight_notebook_server_error(mock_controller_instance): + mock_controller_instance.get_flight_notebook.side_effect = HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + response = client.get('/flights/123/notebook') + assert response.status_code == 500 + assert response.json() == {'detail': 'Internal Server Error'} diff --git a/tests/unit/test_routes/test_motors_route.py b/tests/unit/test_routes/test_motors_route.py index 552b94b..c4a01d2 100644 --- a/tests/unit/test_routes/test_motors_route.py +++ b/tests/unit/test_routes/test_motors_route.py @@ -1,4 +1,4 @@ -from unittest.mock import patch, AsyncMock, Mock +from unittest.mock import patch, AsyncMock import json import pytest from fastapi.testclient import TestClient @@ -35,13 +35,13 @@ def mock_controller_instance(): mock_controller.delete_motor_by_id = AsyncMock() mock_controller.get_motor_simulation = AsyncMock() mock_controller.get_rocketpy_motor_binary = AsyncMock() - + mock_class.return_value = mock_controller get_motor_controller.cache_clear() - + yield mock_controller - + get_motor_controller.cache_clear() diff --git a/tests/unit/test_routes/test_rockets_route.py b/tests/unit/test_routes/test_rockets_route.py index 6bf5e1d..8139f93 100644 --- a/tests/unit/test_routes/test_rockets_route.py +++ b/tests/unit/test_routes/test_rockets_route.py @@ -1,4 +1,4 @@ -from unittest.mock import patch, Mock, AsyncMock +from unittest.mock import patch, AsyncMock import copy import json import pytest @@ -86,13 +86,13 @@ def mock_controller_instance(): mock_controller.get_rocketpy_rocket_binary = AsyncMock() mock_controller.create_rocket_from_motor_reference = AsyncMock() mock_controller.update_rocket_from_motor_reference = AsyncMock() - + mock_class.return_value = mock_controller get_rocket_controller.cache_clear() - + yield mock_controller - + get_rocket_controller.cache_clear() From ee13c2f8f6dfd1f77c3aaa9453cd77cfacfb038f Mon Sep 17 00:00:00 2001 From: Gabriel Barberini Date: Sun, 1 Mar 2026 21:05:27 +0100 Subject: [PATCH 2/2] MNT: address PR review comments from CodeRabbit and Copilot - Fix implicit string concatenation in notebook source (Pylint W1404) - Update FlightImported.message to say ".rpy file" instead of "binary" - Add 10 MB upload size guard with HTTP 413 on POST /flights/upload - Extract tanks for LIQUID/HYBRID motors in _extract_motor to satisfy MotorModel validation (new _extract_tanks and _to_float helpers) - Add comment explaining default_fins schema fallback - Tighten test assertion on import success message - Add pre-yield cache_clear() in environments test fixture Made-with: Cursor --- src/routes/flight.py | 22 ++- src/services/flight.py | 125 +++++++++++++++--- src/views/flight.py | 2 +- .../test_routes/test_environments_route.py | 2 + tests/unit/test_routes/test_flights_route.py | 2 +- 5 files changed, 130 insertions(+), 23 deletions(-) diff --git a/src/routes/flight.py b/src/routes/flight.py index 822537d..8289b32 100644 --- a/src/routes/flight.py +++ b/src/routes/flight.py @@ -4,7 +4,14 @@ import json -from fastapi import APIRouter, Response, UploadFile, File +from fastapi import ( + APIRouter, + File, + HTTPException, + Response, + UploadFile, + status, +) from opentelemetry import trace from src.views.flight import ( @@ -30,6 +37,8 @@ tracer = trace.get_tracer(__name__) +MAX_RPY_UPLOAD_BYTES = 10 * 1024 * 1024 # 10 MB + @router.post("/", status_code=201) async def create_flight( @@ -186,7 +195,7 @@ async def get_rocketpy_flight_rpy( ) async def import_flight_from_rpy( file: UploadFile = File(...), - controller: FlightControllerDep = None, + controller: FlightControllerDep = None, # noqa: B008 ) -> FlightImported: """ Upload a ``.rpy`` JSON file containing a RocketPy Flight. @@ -194,13 +203,18 @@ async def import_flight_from_rpy( The file is deserialized and decomposed into its constituent objects (Environment, Motor, Rocket, Flight). Each object is persisted as a normal JSON model and the - corresponding IDs are returned. + corresponding IDs are returned. Maximum upload size is 10 MB. ## Args ``` file: .rpy JSON upload ``` """ with tracer.start_as_current_span("import_flight_from_rpy"): - content = await file.read() + content = await file.read(MAX_RPY_UPLOAD_BYTES + 1) + if len(content) > MAX_RPY_UPLOAD_BYTES: + raise HTTPException( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + detail="Uploaded .rpy file exceeds 10 MB limit.", + ) return await controller.import_flight_from_rpy(content) diff --git a/src/services/flight.py b/src/services/flight.py index e39e078..9cdd1ab 100644 --- a/src/services/flight.py +++ b/src/services/flight.py @@ -5,9 +5,15 @@ from rocketpy.simulation.flight import Flight as RocketPyFlight from rocketpy._encoders import RocketPyEncoder, RocketPyDecoder +from rocketpy.mathutils.function import Function from rocketpy.motors.solid_motor import SolidMotor from rocketpy.motors.liquid_motor import LiquidMotor from rocketpy.motors.hybrid_motor import HybridMotor +from rocketpy import ( + LevelBasedTank, + MassBasedTank, + UllageBasedTank, +) from rocketpy.rocket.aero_surface import ( NoseCone as RocketPyNoseCone, TrapezoidalFins as RocketPyTrapezoidalFins, @@ -27,6 +33,7 @@ Tail, Parachute, ) +from src.models.sub.tanks import MotorTank, TankFluids, TankKinds from src.views.flight import FlightSimulation from src.views.rocket import RocketSimulation from src.views.motor import MotorSimulation @@ -169,22 +176,27 @@ def _extract_motor(motor) -> MotorModel: ), } + grain_fields = { + "grain_number": motor.grain_number, + "grain_density": motor.grain_density, + "grain_outer_radius": motor.grain_outer_radius, + "grain_initial_inner_radius": (motor.grain_initial_inner_radius), + "grain_initial_height": motor.grain_initial_height, + "grain_separation": motor.grain_separation, + "grains_center_of_mass_position": ( + motor.grains_center_of_mass_position + ), + "throat_radius": motor.throat_radius, + } + match kind: - case MotorKinds.SOLID | MotorKinds.HYBRID: - data |= { - "grain_number": motor.grain_number, - "grain_density": motor.grain_density, - "grain_outer_radius": (motor.grain_outer_radius), - "grain_initial_inner_radius": ( - motor.grain_initial_inner_radius - ), - "grain_initial_height": (motor.grain_initial_height), - "grain_separation": motor.grain_separation, - "grains_center_of_mass_position": ( - motor.grains_center_of_mass_position - ), - "throat_radius": motor.throat_radius, - } + case MotorKinds.SOLID: + data |= grain_fields + case MotorKinds.HYBRID: + data |= grain_fields + data["tanks"] = FlightService._extract_tanks(motor) + case MotorKinds.LIQUID: + data["tanks"] = FlightService._extract_tanks(motor) case MotorKinds.GENERIC: data |= { "chamber_radius": getattr(motor, "chamber_radius", None), @@ -202,6 +214,83 @@ def _extract_motor(motor) -> MotorModel: return MotorModel(**data) + @staticmethod + def _to_float(value) -> float: + """Extract a plain float from a RocketPy Function or scalar.""" + match value: + case Function(): + return float(value(0)) + case _: + return float(value) + + @staticmethod + def _extract_tanks(motor) -> list[MotorTank]: + tanks: list[MotorTank] = [] + for entry in motor.positioned_tanks: + tank, position = entry["tank"], entry["position"] + + match tank: + case LevelBasedTank(): + tank_kind = TankKinds.LEVEL + case MassBasedTank(): + tank_kind = TankKinds.MASS + case UllageBasedTank(): + tank_kind = TankKinds.ULLAGE + case _: + tank_kind = TankKinds.MASS_FLOW + + geometry = [ + (bounds, float(func(0))) + for bounds, func in tank.geometry.geometry.items() + ] + + data: dict = { + "geometry": geometry, + "gas": TankFluids( + name=tank.gas.name, + density=tank.gas.density, + ), + "liquid": TankFluids( + name=tank.liquid.name, + density=tank.liquid.density, + ), + "flux_time": tank.flux_time, + "position": position, + "discretize": tank.discretize, + "tank_kind": tank_kind, + "name": tank.name, + } + + _f = FlightService._to_float + match tank_kind: + case TankKinds.LEVEL: + data["liquid_height"] = _f(tank.liquid_height) + case TankKinds.MASS: + data["liquid_mass"] = _f(tank.liquid_mass) + data["gas_mass"] = _f(tank.gas_mass) + case TankKinds.MASS_FLOW: + data |= { + "gas_mass_flow_rate_in": _f( + tank.gas_mass_flow_rate_in + ), + "gas_mass_flow_rate_out": _f( + tank.gas_mass_flow_rate_out + ), + "liquid_mass_flow_rate_in": _f( + tank.liquid_mass_flow_rate_in + ), + "liquid_mass_flow_rate_out": _f( + tank.liquid_mass_flow_rate_out + ), + "initial_liquid_mass": (tank.initial_liquid_mass), + "initial_gas_mass": (tank.initial_gas_mass), + } + case TankKinds.ULLAGE: + data["ullage"] = _f(tank.ullage) + + tanks.append(MotorTank(**data)) + return tanks + @staticmethod def _drag_to_list(fn) -> list: match getattr(fn, "source", None): @@ -293,6 +382,8 @@ def _extract_rocket(rocket, motor_model: MotorModel) -> RocketModel: rocket.I_33_without_motor, ) + # Schema requires at least one Fins entry; n=0 means + # no physical fins (safe for downstream aero calculations). default_fins = [ Fins( fins_kind="trapezoidal", @@ -359,7 +450,7 @@ def _extract_flight( ) # ------------------------------------------------------------------ - # Simulation & binary + # Simulation & export # ------------------------------------------------------------------ def get_flight_simulation(self) -> FlightSimulation: @@ -432,7 +523,7 @@ def generate_notebook(flight_id: str) -> dict: "metadata": {}, "outputs": [], "source": [ - "from rocketpy.utilities import " "load_from_rpy\n", + "from rocketpy.utilities import load_from_rpy\n", "import matplotlib\n", ], }, diff --git a/src/views/flight.py b/src/views/flight.py index 50d52e1..cc6f00a 100644 --- a/src/views/flight.py +++ b/src/views/flight.py @@ -151,7 +151,7 @@ class FlightCreated(ApiBaseView): class FlightImported(ApiBaseView): - message: str = "Flight successfully imported from binary" + message: str = "Flight successfully imported from .rpy file" flight_id: str rocket_id: str motor_id: str diff --git a/tests/unit/test_routes/test_environments_route.py b/tests/unit/test_routes/test_environments_route.py index d37c8e3..76bee71 100644 --- a/tests/unit/test_routes/test_environments_route.py +++ b/tests/unit/test_routes/test_environments_route.py @@ -38,6 +38,8 @@ def mock_controller_instance(): mock_class.return_value = mock_controller + get_environment_controller.cache_clear() + yield mock_controller get_environment_controller.cache_clear() diff --git a/tests/unit/test_routes/test_flights_route.py b/tests/unit/test_routes/test_flights_route.py index 82dae8b..ae062c2 100644 --- a/tests/unit/test_routes/test_flights_route.py +++ b/tests/unit/test_routes/test_flights_route.py @@ -568,7 +568,7 @@ def test_import_flight_from_rpy(mock_controller_instance): assert body['rocket_id'] == 'r1' assert body['motor_id'] == 'm1' assert body['environment_id'] == 'e1' - assert 'imported' in body['message'].lower() + assert body['message'] == "Flight successfully imported from .rpy file" mock_controller_instance.import_flight_from_rpy.assert_called_once_with( rpy_content )