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
8 changes: 7 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -160,4 +160,10 @@ cython_debug/
#.idea/

# VSCode config
.vscode/
.vscode/

# context specific ignores
.context

# lint
.pylint.d/
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ pymongo>=4.15
jsonpickle
gunicorn
uvicorn
git+https://github.com/RocketPy-Team/RocketPy.git@develop
rocketpy
uptrace
opentelemetry.instrumentation.fastapi
opentelemetry.instrumentation.requests
Expand Down
27 changes: 26 additions & 1 deletion src/controllers/rocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@
ControllerBase,
controller_exception_handler,
)
from src.views.rocket import RocketSimulation, RocketCreated
from src.views.rocket import (
RocketSimulation,
RocketCreated,
RocketDrawingGeometry,
)
from src.models.motor import MotorModel
from src.models.rocket import (
RocketModel,
Expand Down Expand Up @@ -75,6 +79,27 @@ async def get_rocketpy_rocket_binary(self, rocket_id: str) -> bytes:
rocket_service = RocketService.from_rocket_model(rocket.rocket)
return rocket_service.get_rocket_binary()

@controller_exception_handler
async def get_rocket_drawing_geometry(
self, rocket_id: str
) -> RocketDrawingGeometry:
"""
Build the drawing geometry payload for a persisted rocket.

Args:
rocket_id: str

Returns:
views.RocketDrawingGeometry

Raises:
HTTP 404 Not Found: If the rocket does not exist in the database.
HTTP 422: If the rocket has no aerodynamic surfaces to draw.
"""
rocket = await self.get_rocket_by_id(rocket_id)
rocket_service = RocketService.from_rocket_model(rocket.rocket)
return rocket_service.get_drawing_geometry()

@controller_exception_handler
async def get_rocket_simulation(
self,
Expand Down
22 changes: 21 additions & 1 deletion src/models/motor.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@ class MotorModel(ApiBaseModel):

# Required parameters
thrust_source: List[List[float]]
burn_time: float
# burn_time is optional for Liquid/Hybrid/Solid motors — rocketpy
# auto-detects the burn window from the thrust_source array span.
# GenericMotor still requires it; the motor service re-raises an
# explicit error when the GENERIC path receives None.
burn_time: Optional[float] = None
nozzle_radius: float
dry_mass: float
dry_inertia: Tuple[float, float, float] = (0, 0, 0)
Expand Down Expand Up @@ -82,6 +86,22 @@ 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

@staticmethod
def UPDATED():
return
Expand Down
90 changes: 85 additions & 5 deletions src/models/sub/tanks.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from enum import Enum
from typing import Optional, Tuple, List
from pydantic import BaseModel
from typing import Annotated, List, Literal, Optional, Tuple, Union

from pydantic import BaseModel, Field, model_validator


class TankKinds(str, Enum):
Expand All @@ -10,19 +11,81 @@ class TankKinds(str, Enum):
ULLAGE: str = "ULLAGE"


# Scalar density keeps the legacy behaviour (constant kg/m^3).
# A list of (temperature_K, density_kg_per_m3) samples enables
# temperature-dependent density — required for realistic LOX / N2O
# modelling. Pressure dependence is out of scope for this iteration.
DensityInput = Union[float, List[Tuple[float, float]]]


class TankFluids(BaseModel):
name: str
density: float
density: DensityInput


# --- Tank geometry discriminated union ----------------------------------
# RocketPy ships three concrete geometry classes. We mirror them as a
# discriminated Pydantic union keyed on `geometry_kind`. `custom` is the
# generic piecewise form (original API shape); `cylindrical` and
# `spherical` map to `rocketpy.motors.CylindricalTank` and
# `SphericalTank` respectively.


class CustomTankGeometry(BaseModel):
geometry_kind: Literal["custom"] = "custom"
geometry: List[Tuple[Tuple[float, float], float]]


class CylindricalTankGeometry(BaseModel):
geometry_kind: Literal["cylindrical"] = "cylindrical"
radius: float
height: float
spherical_caps: bool = False


class SphericalTankGeometry(BaseModel):
geometry_kind: Literal["spherical"] = "spherical"
radius: float


TankGeometryInput = Annotated[
Union[
CustomTankGeometry,
CylindricalTankGeometry,
SphericalTankGeometry,
],
Field(discriminator="geometry_kind"),
]


# Map tank_kind → tuple of MotorTank field names that rocketpy's
# corresponding Tank subclass requires. The validator below rejects
# payloads that omit any of them so the API returns 422 instead of
# letting rocketpy crash during motor construction.
_REQUIRED_FIELDS_BY_TANK_KIND = {
TankKinds.MASS_FLOW: (
"initial_liquid_mass",
"initial_gas_mass",
"liquid_mass_flow_rate_in",
"liquid_mass_flow_rate_out",
"gas_mass_flow_rate_in",
"gas_mass_flow_rate_out",
),
TankKinds.LEVEL: ("liquid_height",),
TankKinds.ULLAGE: ("ullage",),
TankKinds.MASS: ("liquid_mass", "gas_mass"),
}


class MotorTank(BaseModel):
# Required parameters
geometry: List[Tuple[Tuple[float, float], float]]
geometry: TankGeometryInput
gas: TankFluids
liquid: TankFluids
flux_time: Tuple[float, float]
position: float
discretize: int
# discretize is optional in RocketPy's Tank classes (defaults to 100).
discretize: int = 100

# Level based tank parameters
liquid_height: Optional[float] = None
Expand All @@ -47,3 +110,20 @@ class MotorTank(BaseModel):

# Computed parameters
tank_kind: TankKinds = TankKinds.MASS_FLOW

@model_validator(mode='after')
def validate_tank_kind_fields(self):
# Mirrors the validate_dry_inertia_for_kind pattern used on
# MotorModel: reject incoherent payloads at the API boundary
# instead of letting rocketpy crash during Tank construction.
missing = [
field
for field in _REQUIRED_FIELDS_BY_TANK_KIND[self.tank_kind]
if getattr(self, field) is None
]
if missing:
raise ValueError(
f"tank_kind={self.tank_kind.value} requires: "
f"{', '.join(missing)}"
)
return self
25 changes: 25 additions & 0 deletions src/routes/motor.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,31 @@ async def create_motor(

## Args
``` models.Motor JSON ```

For liquid/hybrid motors the `tanks` field supports three geometry
kinds via the `geometry_kind` discriminator and a scalar-or-sampled
fluid `density`:

```
{
"motor_kind": "LIQUID",
...
"tanks": [{
"geometry": {
"geometry_kind": "cylindrical", // or "spherical", "custom"
"radius": 0.1, "height": 0.5
},
"liquid": {
"name": "LOX",
"density": [[90.0, 1141.0], [120.0, 1091.0]] // or scalar
},
"gas": {"name": "N2", "density": 1.2},
"tank_kind": "LEVEL",
"liquid_height": 0.25,
...
}]
}
```
"""
with tracer.start_as_current_span("create_motor"):
return await controller.post_motor(motor)
Expand Down
21 changes: 21 additions & 0 deletions src/routes/rocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
RocketSimulation,
RocketCreated,
RocketRetrieved,
RocketDrawingGeometry,
)
from src.models.rocket import (
RocketModel,
Expand Down Expand Up @@ -181,3 +182,23 @@ async def simulate_rocket(
"""
with tracer.start_as_current_span("get_rocket_simulation"):
return await controller.get_rocket_simulation(rocket_id)


@router.get("/{rocket_id}/drawing-geometry")
async def get_rocket_drawing_geometry(
rocket_id: str,
controller: RocketControllerDep,
) -> RocketDrawingGeometry:
"""
Returns structured drawing geometry for the rocket so that a frontend
can redraw exactly what rocketpy.Rocket.draw() would render.

Response contains shape coordinate arrays for each aerodynamic surface,
tube segments, motor polygons (nozzle, chamber, grains, tanks, outline),
rail-button positions, CG/CP at t=0, sensors, and overall drawing bounds.

## Args
``` rocket_id: Rocket ID ```
"""
with tracer.start_as_current_span("get_rocket_drawing_geometry"):
return await controller.get_rocket_drawing_geometry(rocket_id)
33 changes: 29 additions & 4 deletions src/services/flight.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,22 @@ def _to_float(value) -> float:
case _:
return float(value)

@staticmethod
def _extract_fluid_density(fluid):
"""Project a rocketpy Fluid's density back onto the API schema.

The API accepts either a scalar or a list of (T_K, density)
samples. Rocketpy may store density as either a raw scalar or a
``Function`` wrapping a 2D ``(T, P) -> density`` callable. A
full sample round-trip is not supported in this iteration;
Function-valued densities are collapsed to a scalar evaluated
at rocketpy's default reference (273.15 K, 101325 Pa).
"""
density = fluid.density
if isinstance(density, Function):
return float(density(273.15, 101325))
return density

@staticmethod
def _extract_tanks(motor) -> list[MotorTank]:
tanks: list[MotorTank] = []
Expand All @@ -240,20 +256,29 @@ def _extract_tanks(motor) -> list[MotorTank]:
case _:
tank_kind = TankKinds.MASS_FLOW

geometry = [
# Geometry round-trip is lossy: even if the client originally
# sent a cylindrical/spherical geometry, we discretise it back
# to the generic piecewise form on read. Every rocketpy tank
# geometry exposes its internal piecewise dict via
# `tank.geometry.geometry`, so this path covers all three
# geometry subclasses uniformly.
geometry_segments = [
(bounds, float(func(0)))
for bounds, func in tank.geometry.geometry.items()
]

data: dict = {
"geometry": geometry,
"geometry": {
"geometry_kind": "custom",
"geometry": geometry_segments,
},
"gas": TankFluids(
name=tank.gas.name,
density=tank.gas.density,
density=FlightService._extract_fluid_density(tank.gas),
),
"liquid": TankFluids(
name=tank.liquid.name,
density=tank.liquid.density,
density=FlightService._extract_fluid_density(tank.liquid),
),
"flux_time": tank.flux_time,
"position": position,
Expand Down
Loading
Loading