镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion src/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,21 @@ async def validation_exception_handler(
):
exc_str = parse_error(exc)
logger.error("%s: %s", request, exc_str)
# Answer in FastAPI's standard `{"detail": [{loc, msg, type}]}` shape.
# `input` and `ctx` are dropped: `ctx` can carry raw exception objects
# that are not JSON serialisable (the reason this handler originally
# stringified the whole error), and `input` echoes the request body.
detail = [
{
"loc": list(error.get("loc", ())),
"msg": error.get("msg", ""),
"type": error.get("type", ""),
}
for error in exc.errors()
]
return JSONResponse(
content=exc_str, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY
content={"detail": detail},
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
)


Expand Down
11 changes: 11 additions & 0 deletions src/controllers/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,17 @@ async def wrapper(self, *args, **kwargs):
)
except HTTPException as e:
raise e from e
except ValueError as e:
# RocketPy raises ValueError for physically invalid input
# (overfilled tank, bad parachute trigger, inconsistent
# geometry). That is the client's design, not a server fault:
# return it as 422 with the message instead of a bare 500.
detail = " ".join(str(e).split())
logger.warning(f"{method.__name__}: Invalid input {detail}")
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=detail,
) from e
except Exception as e:
logger.exception(f"{method.__name__}: Unexpected error {e}")
raise HTTPException(
Expand Down
12 changes: 8 additions & 4 deletions src/models/flight.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,11 @@ class FlightModel(ApiBaseModel):
inclination: float = 90.0
heading: float = 0.0
# TODO: implement initial_solution
max_time: Optional[int] = None
# rocketpy.Flight types these as floats (the lince example uses
# max_time=399.35159); `int` rejected it as "not a valid integer".
max_time: Optional[float] = None
max_time_step: Optional[float] = None
min_time_step: Optional[int] = None
min_time_step: Optional[float] = None
rtol: Optional[float] = None
atol: Optional[float] = None
verbose: Optional[bool] = None
Expand Down Expand Up @@ -106,9 +108,11 @@ class FlightPartialModel(BaseModel):
equations_of_motion: Literal['standard', 'solid_propulsion'] = 'standard'
inclination: float = 90.0
heading: float = 0.0
max_time: Optional[int] = None
# rocketpy.Flight types these as floats (the lince example uses
# max_time=399.35159); `int` rejected it as "not a valid integer".
max_time: Optional[float] = None
max_time_step: Optional[float] = None
min_time_step: Optional[int] = None
min_time_step: Optional[float] = None
rtol: Optional[float] = None
atol: Optional[float] = None
verbose: Optional[bool] = None
Expand Down
21 changes: 6 additions & 15 deletions src/models/motor.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,21 +86,12 @@ def validate_motor_kind(self):
)
return self

@model_validator(mode='after')
def validate_dry_inertia_for_kind(self):
# RocketPy's SolidMotor/LiquidMotor/HybridMotor require dry_inertia with no default.
# Only GenericMotor accepts (0, 0, 0). Surface a clear error at the API boundary
# instead of letting RocketPy crash deep in construction.
if self.motor_kind != MotorKinds.GENERIC and self.dry_inertia == (
0,
0,
0,
):
raise ValueError(
f"dry_inertia is required for {self.motor_kind} motors "
f"and must be explicitly provided (cannot be (0, 0, 0))."
)
return self
# NOTE: dry_inertia == (0, 0, 0) is deliberately accepted for every motor
# kind. SolidMotor/LiquidMotor/HybridMotor take dry_inertia as a required
# argument but rocketpy itself accepts zeros (12 of the 17 official
# example notebooks pass exactly (0, 0, 0), usually with dry_mass=0). A
# validator that rejected it here blocked those designs at POST
# /motors/, /rockets/ and /flights/ while rocketpy would have flown them.

@staticmethod
def UPDATED():
Expand Down
7 changes: 5 additions & 2 deletions src/models/rocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,11 @@ class RocketModel(ApiBaseModel):
NAME: ClassVar = "rocket"
METHODS: ClassVar = ("POST", "GET", "PUT", "DELETE")

# Required parameters
motor: MotorModel
# A rocket may be created before its motor exists (jarvis saves the
# airframe first). `None` maps to rocketpy's `EmptyMotor`, which a
# Rocket carries by default: the rocket persists and draws, and the
# simulate endpoints answer 422 until a real motor is attached.
motor: Optional[MotorModel] = None
radius: float
mass: float
motor_position: float
Expand Down
33 changes: 30 additions & 3 deletions src/repositories/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

from fastapi import HTTPException, status
from bson import ObjectId
from bson.errors import InvalidId

from src import logger
from src.secrets import Secrets
Expand Down Expand Up @@ -61,6 +62,10 @@ async def wrapper(self, *args, **kwargs):
f"{method.__name__} - Repository not initialized: {e}"
)
raise
except HTTPException:
# Deliberate client-facing errors raised inside a repository
# method (e.g. malformed id → 404) must not be re-wrapped as 500.
raise
except Exception as e:
logger.exception(
f"{method.__name__} - caught unexpected error: {e}"
Expand Down Expand Up @@ -258,13 +263,20 @@ async def insert(self, data: dict):
async def update_by_id(self, data: dict, *, data_id: str):
collection = self.get_collection()
assert self.model.model_validate(data)
await collection.update_one({"_id": ObjectId(data_id)}, {"$set": data})
object_id = self._object_id_or_404(data_id)
await collection.update_one({"_id": object_id}, {"$set": data})
return self

@repository_exception_handler
async def find_by_id(self, *, data_id: str):
collection = self.get_collection()
read_data = await collection.find_one({"_id": ObjectId(data_id)})
try:
object_id = ObjectId(data_id)
except InvalidId:
# A malformed id can never match a document; treat it exactly
# like a missing one so the controller answers 404, not 500.
return None
read_data = await collection.find_one({"_id": object_id})
if read_data:
parsed_model = self.model.model_validate(read_data)
parsed_model.set_id(str(read_data["_id"]))
Expand All @@ -274,9 +286,24 @@ async def find_by_id(self, *, data_id: str):
@repository_exception_handler
async def delete_by_id(self, *, data_id: str):
collection = self.get_collection()
await collection.delete_one({"_id": ObjectId(data_id)})
await collection.delete_one({"_id": self._object_id_or_404(data_id)})
return self

def _object_id_or_404(self, data_id: str) -> ObjectId:
"""Parse a path id, answering 404 for a malformed one.

The jarvis client was observed issuing PUT /rockets/undefined; bson
raises InvalidId for that, which the generic handler used to turn
into a 500 "Unexpected error ocurred".
"""
try:
return ObjectId(data_id)
except InvalidId as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"{self.model.NAME} not found",
) from exc

@repository_exception_handler
async def find_by_query(self, query: dict):
collection = self.get_collection()
Expand Down
8 changes: 4 additions & 4 deletions src/services/flight.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,9 @@ def from_flight_model(cls, flight: FlightModel) -> Self:
rocketpy_env = EnvironmentService.from_env_model(
flight.environment
).environment
rocketpy_rocket = RocketService.from_rocket_model(flight.rocket).rocket
rocket_service = RocketService.from_rocket_model(flight.rocket)
rocket_service.require_motor()
rocketpy_rocket = rocket_service.rocket
rocketpy_flight = RocketPyFlight(
rocket=rocketpy_rocket,
environment=rocketpy_env,
Expand Down Expand Up @@ -518,9 +520,7 @@ def get_flight_kml(self) -> bytes:
Returns:
bytes (UTF-8 encoded KML)
"""
with tempfile.NamedTemporaryFile(
suffix=".kml", delete=False
) as tmp:
with tempfile.NamedTemporaryFile(suffix=".kml", delete=False) as tmp:
tmp_path = tmp.name
try:
FlightDataExporter(self.flight).export_kml(file_name=tmp_path)
Expand Down
30 changes: 24 additions & 6 deletions src/services/motor.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,14 +236,32 @@ def from_motor_model(cls, motor: MotorModel) -> Self:
**optional_params,
)
case _:
# GenericMotor requires burn_time even though it's optional
# for the other motor kinds — surface the constraint at the
# API boundary instead of letting rocketpy raise a
# confusing stack trace deeper in construction.
if motor.burn_time is None:
# GenericMotor has no defaults for burn_time or the chamber
# fields. A None here does not fail at construction — it
# blows up later as `Function(None)` inside
# center_of_propellant_mass when the motor is drawn, added
# to a rocket, or flown. Surface the constraint at the API
# boundary as a 422 that names every missing field, in the
# same "requires: a, b" shape the tank_kind guard uses so
# clients can paint field-level errors.
generic_params = {
'burn_time': motor.burn_time,
'chamber_radius': motor.chamber_radius,
'chamber_height': motor.chamber_height,
'chamber_position': motor.chamber_position,
'propellant_initial_mass': motor.propellant_initial_mass,
}
missing = [
key
for key, value in generic_params.items()
if value is None
]
if missing:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="burn_time is required for generic motors.",
detail=(
"Generic motor requires: " + ', '.join(missing)
),
)
# nozzle_position is already forwarded via motor_core when
# the client supplied it; GenericMotor's own default (0)
Expand Down
66 changes: 60 additions & 6 deletions src/services/rocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
Tail as RocketPyTail,
)
from rocketpy.rocket.aero_surface.generic_surface import GenericSurface
from rocketpy.motors.empty_motor import EmptyMotor
from rocketpy.mathutils.function import Function

from fastapi import HTTPException, status

Expand All @@ -37,6 +39,40 @@
from src.utils import collect_attributes


def _drag_curve_function(points, coeff_name: str):
"""Build a Mach -> Cd Function the way rocketpy's CSV loader does.

The notebooks hand rocketpy a CSV *path*; `Rocket.__load_rocket_drag_csv`
turns it into a Function with linear interpolation and constant
extrapolation. Infinity's wire format forces an inline list, which
rocketpy routes through `Function(list)` with the default *spline*
interpolation. Duplicate Mach rows (present in five of the official
example drag curves) make that spline solve NaN and the whole rocket is
rejected with "Invalid list/tuple format for Drag Coefficient ...".

So: sort by Mach, keep the first row of any duplicate Mach, and build
the Function with the CSV loader's settings. rocketpy then wraps the
1-D Function into its 7-variable drag model exactly as for a CSV.
"""
# Dedupe in INPUT order (first row for a Mach wins, like a CSV reader
# would), then sort by Mach. Sorting first would order equal Mach rows
# by coefficient and silently pick the smallest one.
first_by_mach: dict[float, float] = {}
for mach, cd in points:
first_by_mach.setdefault(float(mach), float(cd))
rows = [[mach, cd] for mach, cd in sorted(first_by_mach.items())]
if len(rows) == 1:
# A single point is a constant coefficient.
return rows[0][1]
return Function(
rows,
inputs=['Mach Number'],
outputs=[coeff_name],
interpolation='linear',
extrapolation='constant',
)


class RocketService:
_rocket: RocketPyRocket

Expand All @@ -57,15 +93,22 @@ def from_rocket_model(cls, rocket: RocketModel) -> Self:
radius=rocket.radius,
mass=rocket.mass,
inertia=rocket.inertia,
power_off_drag=rocket.power_off_drag,
power_on_drag=rocket.power_on_drag,
power_off_drag=_drag_curve_function(
rocket.power_off_drag, 'Drag Coefficient with Power Off'
),
power_on_drag=_drag_curve_function(
rocket.power_on_drag, 'Drag Coefficient with Power On'
),
center_of_mass_without_motor=rocket.center_of_mass_without_motor,
coordinate_system_orientation=rocket.coordinate_system_orientation,
)
rocketpy_rocket.add_motor(
MotorService.from_motor_model(rocket.motor).motor,
rocket.motor_position,
)
# No motor yet: keep the EmptyMotor rocketpy constructs the Rocket
# with. Drawing works; the simulate paths refuse via require_motor.
if rocket.motor is not None:
rocketpy_rocket.add_motor(
MotorService.from_motor_model(rocket.motor).motor,
rocket.motor_position,
)

# RailButtons
if rocket.rail_buttons:
Expand Down Expand Up @@ -124,12 +167,23 @@ def get_rocket_simulation(self) -> RocketSimulation:
Returns:
RocketSimulation
"""
self.require_motor()
encoded_attributes = collect_attributes(
self.rocket, [RocketSimulation, MotorSimulation]
)
rocket_simulation = RocketSimulation(**encoded_attributes)
return rocket_simulation

def require_motor(self) -> None:
"""Refuse to simulate a rocket that still carries the EmptyMotor."""
if isinstance(self._rocket.motor, EmptyMotor):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=(
"Rocket has no motor yet. Save a motor before simulating."
),
)

def get_rocket_binary(self) -> bytes:
"""
Get the binary representation of the rocket.
Expand Down
2 changes: 1 addition & 1 deletion src/views/rocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ class RocketSimulation(MotorSimulation):

class RocketView(RocketModel):
rocket_id: Optional[str] = None
motor: MotorView
motor: Optional[MotorView] = None


class RocketDrawingGeometry(ApiBaseView):
Expand Down
24 changes: 23 additions & 1 deletion tests/unit/test_controllers/test_controller_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,10 @@ async def method(self, model, *args, **kwargs):

@pytest.mark.asyncio
async def test_controller_exception_handler_unexpected_exception(stub_model):
# ValueError is now a deliberate 422 (see the test below); use a
# RuntimeError as the representative programming error.
async def method(self, model, *args, **kwargs):
raise ValueError('Test Error')
raise RuntimeError('Test Error')

wrapped_method = controller_exception_handler(method)
with patch('src.controllers.interface.logger') as mock_logger:
Expand Down Expand Up @@ -225,3 +227,23 @@ async def test_controller_interface_delete_model(stub_controller, stub_model):
)
== 'Deleted'
)


@pytest.mark.asyncio
async def test_controller_exception_handler_value_error_is_422(stub_model):
# RocketPy raises ValueError for physically invalid input (overfilled
# tank, bad parachute trigger, ...). That is a client error, not a
# server fault: surface the message as 422 instead of a bare 500.
async def method(self, model, *args, **kwargs):
raise ValueError(
"The tank 'Tank' is overfilled. The gas volume is greater than "
"the total volume of the tank.\n\t\tTry increasing the tank height."
)

wrapped_method = controller_exception_handler(method)
with patch('src.controllers.interface.logger') as mock_logger:
with pytest.raises(HTTPException) as exc:
await wrapped_method(None, stub_model)
assert exc.value.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
assert exc.value.detail.startswith("The tank 'Tank' is overfilled")
mock_logger.warning.assert_called_once()
Loading
Loading