Skip to content

Commit 73e0789

Browse files
committed
feat(motor): tank geometry union + fluid density function + tank_kind guard
Paired API-side work for the jarvis tank/fluid migration (branch feat/tank-fluid-schema-migration in jarvis-ts). Mirrors what that repo now sends on the wire: Schema (src/models/sub/tanks.py): - MotorTank.geometry is a discriminated union on geometry_kind: * custom → legacy piecewise (TankGeometry) * cylindrical → CylindricalTank(radius, height, spherical_caps) * spherical → SphericalTank(radius) - TankFluids.density accepts float or List[(T_K, rho)] temperature samples; pressure dependence deferred. - New validate_tank_kind_fields model_validator mirrors the validate_dry_inertia_for_kind pattern from motor.py — rejects payloads whose tank_kind omits required kind-specific fields at the API boundary with a kind-named 422 instead of letting rocketpy crash deeper in construction. - discretize is now optional (defaults to 100). Service (src/services/motor.py): - _build_rocketpy_tank_geometry dispatches on geometry_kind to TankGeometry/CylindricalTank/SphericalTank. - _build_rocketpy_fluid instantiates a real rocketpy.Fluid and wraps sampled density in a 1D Function-of-temperature callable; scalars pass through. (Eliminates the duck-typed Pydantic-into-rocketpy pattern that worked by accident.) Inverse path (src/services/flight.py): - _extract_fluid_density collapses Function-valued density back to a scalar at rocketpy's reference state (273.15 K, 101325 Pa) — lossy round-trip is documented; samples-roundtrip not supported in this iteration. - Geometry inverse always emits the 'custom' segment list shape; all three rocketpy geometry subclasses expose a piecewise .geometry dict so one code path covers them uniformly. Tests: - test_motors_route.py: 8 new cases covering each geometry_kind, sampled density, invalid discriminator, and all four tank_kind guard paths (MASS / MASS_FLOW / LEVEL / ULLAGE missing sub-fields). - tests/unit/test_services/test_motor_service.py: new suite exercising the adapter end-to-end against real rocketpy for each geometry×variant combination plus sampled density roundtrip. Routes (src/routes/motor.py): - POST /motors docstring gained an example payload showing the discriminated geometry union and sampled density shape. Gitignore: - Added .context and .pylint.d/ to keep local tooling artifacts out of the tree. Full suite: 173/173 pass.
1 parent 5b545b0 commit 73e0789

8 files changed

Lines changed: 598 additions & 22 deletions

File tree

.gitignore

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,4 +160,10 @@ cython_debug/
160160
#.idea/
161161

162162
# VSCode config
163-
.vscode/
163+
.vscode/
164+
165+
# context specific ignores
166+
.context
167+
168+
# lint
169+
.pylint.d/

src/models/sub/tanks.py

Lines changed: 83 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from enum import Enum
2-
from typing import Optional, Tuple, List
3-
from pydantic import BaseModel
2+
from typing import Annotated, List, Literal, Optional, Tuple, Union
3+
4+
from pydantic import BaseModel, Field, model_validator
45

56

67
class TankKinds(str, Enum):
@@ -10,14 +11,75 @@ class TankKinds(str, Enum):
1011
ULLAGE: str = "ULLAGE"
1112

1213

14+
# Scalar density keeps the legacy behaviour (constant kg/m^3).
15+
# A list of (temperature_K, density_kg_per_m3) samples enables
16+
# temperature-dependent density — required for realistic LOX / N2O
17+
# modelling. Pressure dependence is out of scope for this iteration.
18+
DensityInput = Union[float, List[Tuple[float, float]]]
19+
20+
1321
class TankFluids(BaseModel):
1422
name: str
15-
density: float
23+
density: DensityInput
24+
25+
26+
# --- Tank geometry discriminated union ----------------------------------
27+
# RocketPy ships three concrete geometry classes. We mirror them as a
28+
# discriminated Pydantic union keyed on `geometry_kind`. `custom` is the
29+
# generic piecewise form (original API shape); `cylindrical` and
30+
# `spherical` map to `rocketpy.motors.CylindricalTank` and
31+
# `SphericalTank` respectively.
32+
33+
34+
class CustomTankGeometry(BaseModel):
35+
geometry_kind: Literal["custom"] = "custom"
36+
geometry: List[Tuple[Tuple[float, float], float]]
37+
38+
39+
class CylindricalTankGeometry(BaseModel):
40+
geometry_kind: Literal["cylindrical"] = "cylindrical"
41+
radius: float
42+
height: float
43+
spherical_caps: bool = False
44+
45+
46+
class SphericalTankGeometry(BaseModel):
47+
geometry_kind: Literal["spherical"] = "spherical"
48+
radius: float
49+
50+
51+
TankGeometryInput = Annotated[
52+
Union[
53+
CustomTankGeometry,
54+
CylindricalTankGeometry,
55+
SphericalTankGeometry,
56+
],
57+
Field(discriminator="geometry_kind"),
58+
]
59+
60+
61+
# Map tank_kind → tuple of MotorTank field names that rocketpy's
62+
# corresponding Tank subclass requires. The validator below rejects
63+
# payloads that omit any of them so the API returns 422 instead of
64+
# letting rocketpy crash during motor construction.
65+
_REQUIRED_FIELDS_BY_TANK_KIND = {
66+
TankKinds.MASS_FLOW: (
67+
"initial_liquid_mass",
68+
"initial_gas_mass",
69+
"liquid_mass_flow_rate_in",
70+
"liquid_mass_flow_rate_out",
71+
"gas_mass_flow_rate_in",
72+
"gas_mass_flow_rate_out",
73+
),
74+
TankKinds.LEVEL: ("liquid_height",),
75+
TankKinds.ULLAGE: ("ullage",),
76+
TankKinds.MASS: ("liquid_mass", "gas_mass"),
77+
}
1678

1779

1880
class MotorTank(BaseModel):
1981
# Required parameters
20-
geometry: List[Tuple[Tuple[float, float], float]]
82+
geometry: TankGeometryInput
2183
gas: TankFluids
2284
liquid: TankFluids
2385
flux_time: Tuple[float, float]
@@ -48,3 +110,20 @@ class MotorTank(BaseModel):
48110

49111
# Computed parameters
50112
tank_kind: TankKinds = TankKinds.MASS_FLOW
113+
114+
@model_validator(mode='after')
115+
def validate_tank_kind_fields(self):
116+
# Mirrors the validate_dry_inertia_for_kind pattern used on
117+
# MotorModel: reject incoherent payloads at the API boundary
118+
# instead of letting rocketpy crash during Tank construction.
119+
missing = [
120+
field
121+
for field in _REQUIRED_FIELDS_BY_TANK_KIND[self.tank_kind]
122+
if getattr(self, field) is None
123+
]
124+
if missing:
125+
raise ValueError(
126+
f"tank_kind={self.tank_kind.value} requires: "
127+
f"{', '.join(missing)}"
128+
)
129+
return self

src/routes/motor.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,31 @@ async def create_motor(
3636
3737
## Args
3838
``` models.Motor JSON ```
39+
40+
For liquid/hybrid motors the `tanks` field supports three geometry
41+
kinds via the `geometry_kind` discriminator and a scalar-or-sampled
42+
fluid `density`:
43+
44+
```
45+
{
46+
"motor_kind": "LIQUID",
47+
...
48+
"tanks": [{
49+
"geometry": {
50+
"geometry_kind": "cylindrical", // or "spherical", "custom"
51+
"radius": 0.1, "height": 0.5
52+
},
53+
"liquid": {
54+
"name": "LOX",
55+
"density": [[90.0, 1141.0], [120.0, 1091.0]] // or scalar
56+
},
57+
"gas": {"name": "N2", "density": 1.2},
58+
"tank_kind": "LEVEL",
59+
"liquid_height": 0.25,
60+
...
61+
}]
62+
}
63+
```
3964
"""
4065
with tracer.start_as_current_span("create_motor"):
4166
return await controller.post_motor(motor)

src/services/flight.py

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,22 @@ def _to_float(value) -> float:
224224
case _:
225225
return float(value)
226226

227+
@staticmethod
228+
def _extract_fluid_density(fluid):
229+
"""Project a rocketpy Fluid's density back onto the API schema.
230+
231+
The API accepts either a scalar or a list of (T_K, density)
232+
samples. Rocketpy may store density as either a raw scalar or a
233+
``Function`` wrapping a 2D ``(T, P) -> density`` callable. A
234+
full sample round-trip is not supported in this iteration;
235+
Function-valued densities are collapsed to a scalar evaluated
236+
at rocketpy's default reference (273.15 K, 101325 Pa).
237+
"""
238+
density = fluid.density
239+
if isinstance(density, Function):
240+
return float(density(273.15, 101325))
241+
return density
242+
227243
@staticmethod
228244
def _extract_tanks(motor) -> list[MotorTank]:
229245
tanks: list[MotorTank] = []
@@ -240,20 +256,29 @@ def _extract_tanks(motor) -> list[MotorTank]:
240256
case _:
241257
tank_kind = TankKinds.MASS_FLOW
242258

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

248270
data: dict = {
249-
"geometry": geometry,
271+
"geometry": {
272+
"geometry_kind": "custom",
273+
"geometry": geometry_segments,
274+
},
250275
"gas": TankFluids(
251276
name=tank.gas.name,
252-
density=tank.gas.density,
277+
density=FlightService._extract_fluid_density(tank.gas),
253278
),
254279
"liquid": TankFluids(
255280
name=tank.liquid.name,
256-
density=tank.liquid.density,
281+
density=FlightService._extract_fluid_density(tank.liquid),
257282
),
258283
"flux_time": tank.flux_time,
259284
"position": position,

tests/unit/test_routes/conftest.py

Lines changed: 79 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -36,51 +36,117 @@ def stub_motor_dump():
3636

3737
@pytest.fixture
3838
def stub_tank_dump():
39+
# Base fixture defaults to MASS_FLOW (matches MotorTank's own default)
40+
# so the tank validates standalone. Sub-variant fixtures below switch
41+
# tank_kind and populate only the fields that variant requires.
3942
tank = MotorTank(
40-
geometry=[[(0, 0), 0]],
43+
geometry={
44+
'geometry_kind': 'custom',
45+
'geometry': [[(0, 0), 0]],
46+
},
4147
gas=TankFluids(name='gas', density=0),
4248
liquid=TankFluids(name='liquid', density=0),
4349
flux_time=(0, 0),
4450
position=0,
4551
discretize=0,
4652
name='tank',
53+
gas_mass_flow_rate_in=0,
54+
gas_mass_flow_rate_out=0,
55+
liquid_mass_flow_rate_in=0,
56+
liquid_mass_flow_rate_out=0,
57+
initial_liquid_mass=0,
58+
initial_gas_mass=0,
4759
)
4860
tank_json = tank.model_dump_json()
4961
return json.loads(tank_json)
5062

5163

5264
@pytest.fixture
53-
def stub_level_tank_dump(stub_tank_dump):
54-
stub_tank_dump.update({'tank_kind': TankKinds.LEVEL, 'liquid_height': 0})
65+
def stub_cylindrical_tank_dump(stub_tank_dump):
66+
stub_tank_dump['geometry'] = {
67+
'geometry_kind': 'cylindrical',
68+
'radius': 0.1,
69+
'height': 0.5,
70+
'spherical_caps': False,
71+
}
5572
return stub_tank_dump
5673

5774

5875
@pytest.fixture
59-
def stub_mass_flow_tank_dump(stub_tank_dump):
76+
def stub_spherical_tank_dump(stub_tank_dump):
77+
stub_tank_dump['geometry'] = {
78+
'geometry_kind': 'spherical',
79+
'radius': 0.2,
80+
}
81+
return stub_tank_dump
82+
83+
84+
@pytest.fixture
85+
def stub_tank_with_sampled_density_dump(stub_tank_dump):
86+
stub_tank_dump['liquid'] = {
87+
'name': 'LOX',
88+
'density': [[90.0, 1141.0], [120.0, 1091.0], [150.0, 1021.0]],
89+
}
90+
return stub_tank_dump
91+
92+
93+
@pytest.fixture
94+
def stub_level_tank_dump(stub_tank_dump):
95+
# Switch out of the MASS_FLOW defaults into LEVEL, clearing the
96+
# unused MASS_FLOW fields so the kind-specific validator passes.
6097
stub_tank_dump.update(
6198
{
62-
'tank_kind': TankKinds.MASS_FLOW,
63-
'gas_mass_flow_rate_in': 0,
64-
'gas_mass_flow_rate_out': 0,
65-
'liquid_mass_flow_rate_in': 0,
66-
'liquid_mass_flow_rate_out': 0,
67-
'initial_liquid_mass': 0,
68-
'initial_gas_mass': 0,
99+
'tank_kind': TankKinds.LEVEL,
100+
'liquid_height': 0,
101+
'gas_mass_flow_rate_in': None,
102+
'gas_mass_flow_rate_out': None,
103+
'liquid_mass_flow_rate_in': None,
104+
'liquid_mass_flow_rate_out': None,
105+
'initial_liquid_mass': None,
106+
'initial_gas_mass': None,
69107
}
70108
)
71109
return stub_tank_dump
72110

73111

112+
@pytest.fixture
113+
def stub_mass_flow_tank_dump(stub_tank_dump):
114+
# stub_tank_dump already includes all MASS_FLOW fields.
115+
stub_tank_dump['tank_kind'] = TankKinds.MASS_FLOW
116+
return stub_tank_dump
117+
118+
74119
@pytest.fixture
75120
def stub_ullage_tank_dump(stub_tank_dump):
76-
stub_tank_dump.update({'tank_kind': TankKinds.ULLAGE, 'ullage': 0})
121+
stub_tank_dump.update(
122+
{
123+
'tank_kind': TankKinds.ULLAGE,
124+
'ullage': 0,
125+
'gas_mass_flow_rate_in': None,
126+
'gas_mass_flow_rate_out': None,
127+
'liquid_mass_flow_rate_in': None,
128+
'liquid_mass_flow_rate_out': None,
129+
'initial_liquid_mass': None,
130+
'initial_gas_mass': None,
131+
}
132+
)
77133
return stub_tank_dump
78134

79135

80136
@pytest.fixture
81137
def stub_mass_tank_dump(stub_tank_dump):
82138
stub_tank_dump.update(
83-
{'tank_kind': TankKinds.MASS, 'liquid_mass': 0, 'gas_mass': 0}
139+
{
140+
'tank_kind': TankKinds.MASS,
141+
'liquid_mass': 0,
142+
'gas_mass': 0,
143+
'gas_mass_flow_rate_in': None,
144+
'gas_mass_flow_rate_out': None,
145+
'liquid_mass_flow_rate_in': None,
146+
'liquid_mass_flow_rate_out': None,
147+
'initial_liquid_mass': None,
148+
'initial_gas_mass': None,
149+
}
84150
)
85151
return stub_tank_dump
86152

0 commit comments

Comments
 (0)