Skip to content

Commit 5b545b0

Browse files
committed
fix(motor): make burn_time optional; honour nozzle_position for non-generic kinds
Two related fixes surfaced during jarvis form refactor. 1. MotorModel.burn_time: float → Optional[float] = None. RocketPy's LiquidMotor / HybridMotor / SolidMotor all auto-detect burn_time from the thrust_source array span — forcing clients to supply it was wrong. GenericMotor still requires it; the service now raises a 422 at the API boundary when the GENERIC path receives None, instead of letting rocketpy error deeper in construction. 2. MotorService.from_motor_model: nozzle_position previously landed in motor_core only for the GenericMotor branch; Liquid / Hybrid / Solid silently ignored the user's value and took rocketpy's default of 0. Now forwarded via motor_core for every kind (conditionally, so a null still falls back to rocketpy's default). Removed the redundant nozzle_position kwarg on the GenericMotor constructor call. 3. Optional-forwarding convention: motor_core only carries burn_time / nozzle_position when the client actually supplied them, so rocketpy picks its own default otherwise instead of receiving None for number-typed args. Verified against real rocketpy: - LIQUID, burn_time=None → LiquidMotor auto-detects burn window - LIQUID, burn_time=2.0 → LiquidMotor builds with explicit window - LIQUID, nozzle_position=0.3 → LiquidMotor.nozzle_position == 0.3 - LIQUID, nozzle_position=None → LiquidMotor.nozzle_position == 0 - GENERIC, burn_time=None → 422 'burn_time is required for generic motors.' All 173 unit tests pass.
1 parent ff59ada commit 5b545b0

2 files changed

Lines changed: 91 additions & 10 deletions

File tree

src/models/motor.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,11 @@ class MotorModel(ApiBaseModel):
2020

2121
# Required parameters
2222
thrust_source: List[List[float]]
23-
burn_time: float
23+
# burn_time is optional for Liquid/Hybrid/Solid motors — rocketpy
24+
# auto-detects the burn window from the thrust_source array span.
25+
# GenericMotor still requires it; the motor service re-raises an
26+
# explicit error when the GENERIC path receives None.
27+
burn_time: Optional[float] = None
2428
nozzle_radius: float
2529
dry_mass: float
2630
dry_inertia: Tuple[float, float, float] = (0, 0, 0)

src/services/motor.py

Lines changed: 86 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,83 @@
77
from rocketpy.motors.liquid_motor import LiquidMotor
88
from rocketpy.motors.hybrid_motor import HybridMotor
99
from rocketpy import (
10+
CylindricalTank,
11+
Fluid,
12+
Function,
1013
LevelBasedTank,
1114
MassBasedTank,
1215
MassFlowRateBasedTank,
13-
UllageBasedTank,
16+
SphericalTank,
1417
TankGeometry,
18+
UllageBasedTank,
1519
)
1620

1721
from fastapi import HTTPException, status
1822

19-
from src.models.sub.tanks import TankKinds
23+
from src.models.sub.tanks import (
24+
CustomTankGeometry,
25+
CylindricalTankGeometry,
26+
SphericalTankGeometry,
27+
TankFluids,
28+
TankKinds,
29+
)
2030
from src.models.motor import MotorKinds, MotorModel
2131
from src.views.motor import MotorSimulation
2232
from src.utils import collect_attributes
2333

2434

35+
def _build_rocketpy_tank_geometry(geometry):
36+
"""Convert an API geometry model into a rocketpy geometry object.
37+
38+
Dispatch mirrors the discriminated union in
39+
``src.models.sub.tanks.TankGeometryInput``.
40+
"""
41+
if isinstance(geometry, CylindricalTankGeometry):
42+
return CylindricalTank(
43+
radius=geometry.radius,
44+
height=geometry.height,
45+
spherical_caps=geometry.spherical_caps,
46+
)
47+
if isinstance(geometry, SphericalTankGeometry):
48+
return SphericalTank(radius=geometry.radius)
49+
if isinstance(geometry, CustomTankGeometry):
50+
return TankGeometry(geometry_dict=dict(geometry.geometry))
51+
raise ValueError(
52+
f"Unsupported tank geometry kind: {type(geometry).__name__}"
53+
)
54+
55+
56+
def _build_rocketpy_fluid(fluids: TankFluids) -> Fluid:
57+
"""Convert an API TankFluids into a rocketpy Fluid.
58+
59+
Scalar density is passed through (Fluid stores it as a constant).
60+
Sampled density is converted to a 1D Temperature → Density Function
61+
and wrapped in a ``(T, P)`` callable because rocketpy's Fluid expects
62+
density to be a function of both temperature and pressure. Pressure
63+
is ignored here intentionally; only temperature-dependent density
64+
is supported in this iteration.
65+
"""
66+
density = fluids.density
67+
if isinstance(density, list):
68+
temperature_to_density = Function(
69+
source=density,
70+
interpolation='linear',
71+
extrapolation='natural',
72+
inputs=['Temperature (K)'],
73+
outputs='Density (kg/m^3)',
74+
)
75+
76+
def density_callable(temperature, pressure): # noqa: ARG001
77+
# pylint: disable=unused-argument
78+
# Rocketpy's Fluid wraps this into a 2-input Function of
79+
# (T, P); pressure is accepted for signature compatibility
80+
# but intentionally ignored in this iteration.
81+
return temperature_to_density.get_value(temperature)
82+
83+
return Fluid(name=fluids.name, density=density_callable)
84+
return Fluid(name=fluids.name, density=density)
85+
86+
2587
class MotorService:
2688
_motor: RocketPyMotor
2789

@@ -45,7 +107,6 @@ def from_motor_model(cls, motor: MotorModel) -> Self:
45107

46108
motor_core = {
47109
"thrust_source": motor.thrust_source,
48-
"burn_time": motor.burn_time,
49110
"nozzle_radius": motor.nozzle_radius,
50111
"dry_mass": motor.dry_mass,
51112
"dry_inertia": motor.dry_inertia,
@@ -54,6 +115,13 @@ def from_motor_model(cls, motor: MotorModel) -> Self:
54115
"interpolation_method": motor.interpolation_method,
55116
"reshape_thrust_curve": reshape_thrust_curve,
56117
}
118+
# Only forward optional rocketpy args when the client supplied them.
119+
# Leaving them out lets rocketpy pick its own default (burn_time
120+
# auto-detected from thrust_source span; nozzle_position = 0).
121+
if motor.burn_time is not None:
122+
motor_core["burn_time"] = motor.burn_time
123+
if motor.nozzle_position is not None:
124+
motor_core["nozzle_position"] = motor.nozzle_position
57125

58126
match MotorKinds(motor.motor_kind):
59127
case MotorKinds.LIQUID:
@@ -103,25 +171,34 @@ def from_motor_model(cls, motor: MotorModel) -> Self:
103171
**optional_params,
104172
)
105173
case _:
174+
# GenericMotor requires burn_time even though it's optional
175+
# for the other motor kinds — surface the constraint at the
176+
# API boundary instead of letting rocketpy raise a
177+
# confusing stack trace deeper in construction.
178+
if motor.burn_time is None:
179+
raise HTTPException(
180+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
181+
detail="burn_time is required for generic motors.",
182+
)
183+
# nozzle_position is already forwarded via motor_core when
184+
# the client supplied it; GenericMotor's own default (0)
185+
# applies otherwise.
106186
rocketpy_motor = GenericMotor(
107187
**motor_core,
108188
chamber_radius=motor.chamber_radius,
109189
chamber_height=motor.chamber_height,
110190
chamber_position=motor.chamber_position,
111191
propellant_initial_mass=motor.propellant_initial_mass,
112-
nozzle_position=motor.nozzle_position,
113192
)
114193

115194
if motor.motor_kind not in (MotorKinds.SOLID, MotorKinds.GENERIC):
116195
for tank in motor.tanks or []:
117196
tank_core = {
118197
"name": tank.name,
119-
"geometry": TankGeometry(
120-
geometry_dict=dict(tank.geometry)
121-
),
198+
"geometry": _build_rocketpy_tank_geometry(tank.geometry),
122199
"flux_time": tank.flux_time,
123-
"gas": tank.gas,
124-
"liquid": tank.liquid,
200+
"gas": _build_rocketpy_fluid(tank.gas),
201+
"liquid": _build_rocketpy_fluid(tank.liquid),
125202
"discretize": tank.discretize,
126203
}
127204

0 commit comments

Comments
 (0)