Skip to content

Commit f0a5903

Browse files
committed
Merge remote-tracking branch 'origin/master' into enh/tank-geometry-fluid-density
# Conflicts: # src/services/motor.py # src/services/rocket.py # src/views/rocket.py
2 parents 73e0789 + cf325e4 commit f0a5903

8 files changed

Lines changed: 464 additions & 273 deletions

File tree

src/controllers/motor.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
ControllerBase,
33
controller_exception_handler,
44
)
5-
from src.views.motor import MotorSimulation
5+
from src.views.motor import MotorSimulation, MotorDrawingGeometryView
66
from src.models.motor import MotorModel
77
from src.services.motor import MotorService
88

@@ -57,3 +57,27 @@ async def get_motor_simulation(self, motor_id: str) -> MotorSimulation:
5757
motor = await self.get_motor_by_id(motor_id)
5858
motor_service = MotorService.from_motor_model(motor.motor)
5959
return motor_service.get_motor_simulation()
60+
61+
@controller_exception_handler
62+
async def get_motor_drawing_geometry(
63+
self, motor_id: str
64+
) -> MotorDrawingGeometryView:
65+
"""
66+
Build the motor-only drawing-geometry payload for a persisted motor.
67+
68+
Renders the motor at its own coordinate origin (motor_position=0,
69+
parent_csys=1) so the playground can show a motor in isolation.
70+
71+
Args:
72+
motor_id: str
73+
74+
Returns:
75+
views.MotorDrawingGeometryView
76+
77+
Raises:
78+
HTTP 404 Not Found: If the motor does not exist in the database.
79+
HTTP 422: If the motor has no drawable geometry.
80+
"""
81+
motor = await self.get_motor_by_id(motor_id)
82+
motor_service = MotorService.from_motor_model(motor.motor)
83+
return motor_service.get_drawing_geometry()

src/mcp/server.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from __future__ import annotations
44

55
from fastapi import FastAPI
6-
from fastmcp import FastMCP, settings
6+
from fastmcp import FastMCP
77

88

99
def build_mcp(app: FastAPI) -> FastMCP:
@@ -17,10 +17,9 @@ def build_mcp(app: FastAPI) -> FastMCP:
1717
FastMCP: The FastMCP instance corresponding to the provided FastAPI app.
1818
"""
1919

20-
if hasattr(app.state, 'mcp'):
20+
if hasattr(app.state, "mcp"):
2121
return app.state.mcp # type: ignore[attr-defined]
2222

23-
settings.experimental.enable_new_openapi_parser = True
2423
mcp = FastMCP.from_fastapi(app, name=app.title)
2524
app.state.mcp = mcp # type: ignore[attr-defined]
2625
return mcp

src/routes/motor.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
MotorSimulation,
1010
MotorCreated,
1111
MotorRetrieved,
12+
MotorDrawingGeometryView,
1213
)
1314
from src.models.motor import MotorModel
1415
from src.dependencies import MotorControllerDep
@@ -163,3 +164,24 @@ async def get_motor_simulation(
163164
"""
164165
with tracer.start_as_current_span("get_motor_simulation"):
165166
return await controller.get_motor_simulation(motor_id)
167+
168+
169+
@router.get("/{motor_id}/drawing-geometry")
170+
async def get_motor_drawing_geometry(
171+
motor_id: str,
172+
controller: MotorControllerDep,
173+
) -> MotorDrawingGeometryView:
174+
"""
175+
Returns motor-only drawing geometry so a frontend can render the
176+
motor in isolation. The payload mirrors what the motor portion of
177+
`rocketpy.Rocket.draw()` would produce, but at the motor's own
178+
coordinate origin rather than embedded inside a rocket.
179+
180+
Use `GET /rockets/{rocket_id}/drawing-geometry` instead when the
181+
motor should be shown inside a complete rocket.
182+
183+
## Args
184+
``` motor_id: Motor ID ```
185+
"""
186+
with tracer.start_as_current_span("get_motor_drawing_geometry"):
187+
return await controller.get_motor_drawing_geometry(motor_id)

src/services/motor.py

Lines changed: 261 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,31 @@
11
from typing import Self
22

33
import dill
4+
import numpy as np
45

5-
from rocketpy.motors.motor import GenericMotor, Motor as RocketPyMotor
6+
from rocketpy.motors.motor import Motor as RocketPyMotor
67
from rocketpy.motors.solid_motor import SolidMotor
78
from rocketpy.motors.liquid_motor import LiquidMotor
89
from rocketpy.motors.hybrid_motor import HybridMotor
910
from rocketpy import (
1011
CylindricalTank,
1112
Fluid,
1213
Function,
14+
SphericalTank,
15+
TankGeometry,
16+
)
17+
from rocketpy.motors import (
18+
EmptyMotor,
19+
GenericMotor,
1320
LevelBasedTank,
1421
MassBasedTank,
1522
MassFlowRateBasedTank,
16-
SphericalTank,
17-
TankGeometry,
1823
UllageBasedTank,
1924
)
2025

2126
from fastapi import HTTPException, status
2227

28+
from src import logger
2329
from src.models.sub.tanks import (
2430
CustomTankGeometry,
2531
CylindricalTankGeometry,
@@ -28,7 +34,12 @@
2834
TankKinds,
2935
)
3036
from src.models.motor import MotorKinds, MotorModel
31-
from src.views.motor import MotorSimulation
37+
from src.views.motor import MotorSimulation, MotorDrawingGeometryView
38+
from src.views.drawing import (
39+
DrawingBounds,
40+
MotorDrawingGeometry,
41+
MotorPatch,
42+
)
3243
from src.utils import collect_attributes
3344

3445

@@ -84,6 +95,60 @@ def density_callable(temperature, pressure): # noqa: ARG001
8495
return Fluid(name=fluids.name, density=density)
8596

8697

98+
# ---------------------------------------------------------------------------
99+
# Drawing-geometry helpers (module-level, shared by the motor and rocket
100+
# drawing paths). Kept private with the `_` prefix; consumers should go
101+
# through `MotorService.get_drawing_geometry` for the motor-only response
102+
# or through `RocketService.get_drawing_geometry` for the composed rocket
103+
# view (which delegates motor assembly here).
104+
# ---------------------------------------------------------------------------
105+
def _polygon_xy(patch) -> dict:
106+
"""Extract (x, y) coordinate lists from a matplotlib Polygon patch.
107+
108+
Rocketpy's `_MotorPlots` generator helpers return matplotlib `Polygon`
109+
objects; we only ever read `patch.xy` (an Nx2 numpy array) as a data
110+
carrier, never for rendering.
111+
"""
112+
xy = np.asarray(patch.xy)
113+
return {"x": xy[:, 0].tolist(), "y": xy[:, 1].tolist()}
114+
115+
116+
def _rebuild_polygon(x: list[float], y: list[float]):
117+
"""Rebuild a matplotlib `Polygon` from coordinate lists.
118+
119+
Used only so `_MotorPlots._generate_motor_region` can read `patch.xy`
120+
bounds when we assemble the motor outline.
121+
"""
122+
from matplotlib.patches import (
123+
Polygon,
124+
) # local import keeps service cold-start lean
125+
126+
return Polygon(np.column_stack([np.asarray(x), np.asarray(y)]))
127+
128+
129+
def _build_generic_chamber_patch(
130+
center_x: float, chamber_height: float, chamber_radius: float
131+
):
132+
"""Build a rectangular combustion-chamber polygon for a GenericMotor.
133+
134+
Mirrors the vertex order of
135+
`rocketpy.plots.motor_plots._generate_combustion_chamber` so the patch
136+
can flow through `_generate_motor_region` for outline computation
137+
identically to a SolidMotor chamber.
138+
"""
139+
from matplotlib.patches import (
140+
Polygon,
141+
) # local import keeps service cold-start lean
142+
143+
half_len = chamber_height / 2.0
144+
x = np.array([-half_len, half_len, half_len, -half_len])
145+
y = np.array(
146+
[chamber_radius, chamber_radius, -chamber_radius, -chamber_radius]
147+
)
148+
x = x + center_x
149+
return Polygon(np.column_stack([x, y]))
150+
151+
87152
class MotorService:
88153
_motor: RocketPyMotor
89154

@@ -261,3 +326,195 @@ def get_motor_binary(self) -> bytes:
261326
bytes
262327
"""
263328
return dill.dumps(self.motor)
329+
330+
# --------------------------------------------------------------------
331+
# Drawing geometry
332+
# --------------------------------------------------------------------
333+
def get_drawing_geometry(
334+
self,
335+
motor_position: float = 0.0,
336+
parent_csys: int = 1,
337+
) -> MotorDrawingGeometryView:
338+
"""Build a motor-only drawing-geometry response.
339+
340+
Defaults render the motor at its own coordinate origin
341+
(`motor_position=0`, `parent_csys=1`) so the payload can be used
342+
standalone in the playground's "motor-only" view. Callers that
343+
embed the motor into a rocket (see ``RocketService``) pass the
344+
rocket-level position + csys to align the motor inside the rocket
345+
frame.
346+
347+
Raises:
348+
HTTPException 422 if the motor has no drawable patches.
349+
"""
350+
motor_geometry, _nozzle_position = self.build_drawing_geometry(
351+
motor_position=motor_position, parent_csys=parent_csys
352+
)
353+
if motor_geometry is None:
354+
raise HTTPException(
355+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
356+
detail="Motor has no drawable geometry.",
357+
)
358+
# Fall back to the nozzle radius when the motor has no drawable
359+
# patches (e.g. EmptyMotor) so the bounds aren't a zero-height
360+
# line. Every real rocketpy motor class exposes nozzle_radius.
361+
fallback_radius = float(
362+
getattr(self._motor, "nozzle_radius", 0.0) or 0.0
363+
)
364+
return MotorDrawingGeometryView(
365+
motor=motor_geometry,
366+
coordinate_system_orientation=str(
367+
self._motor.coordinate_system_orientation
368+
),
369+
bounds=_compute_motor_bounds(motor_geometry, fallback_radius),
370+
)
371+
372+
def build_drawing_geometry(
373+
self,
374+
motor_position: float,
375+
parent_csys: int,
376+
) -> tuple[MotorDrawingGeometry | None, float]:
377+
"""Construct motor patches + the absolute nozzle x-position.
378+
379+
Returned as a tuple so the rocket service can use the nozzle
380+
position when extending body tubes to meet the motor. Standalone
381+
motor rendering can discard the second element.
382+
"""
383+
motor = self._motor
384+
total_csys = parent_csys * motor._csys
385+
nozzle_position = motor_position + motor.nozzle_position * total_csys
386+
387+
if isinstance(motor, EmptyMotor):
388+
return (
389+
MotorDrawingGeometry(
390+
type="empty",
391+
position=float(motor_position),
392+
nozzle_position=float(nozzle_position),
393+
patches=[],
394+
),
395+
float(nozzle_position),
396+
)
397+
398+
patches: list[MotorPatch] = []
399+
grains_cm_position: float | None = None
400+
motor_type = "generic"
401+
402+
if isinstance(motor, SolidMotor):
403+
motor_type = "solid"
404+
grains_cm_position = (
405+
motor_position
406+
+ motor.grains_center_of_mass_position * total_csys
407+
)
408+
chamber = motor.plots._generate_combustion_chamber(
409+
translate=(grains_cm_position, 0), label=None
410+
)
411+
patches.append(MotorPatch(role="chamber", **_polygon_xy(chamber)))
412+
for grain in motor.plots._generate_grains(
413+
translate=(grains_cm_position, 0)
414+
):
415+
patches.append(MotorPatch(role="grain", **_polygon_xy(grain)))
416+
elif isinstance(motor, HybridMotor):
417+
motor_type = "hybrid"
418+
grains_cm_position = (
419+
motor_position
420+
+ motor.grains_center_of_mass_position * total_csys
421+
)
422+
chamber = motor.plots._generate_combustion_chamber(
423+
translate=(grains_cm_position, 0), label=None
424+
)
425+
patches.append(MotorPatch(role="chamber", **_polygon_xy(chamber)))
426+
for grain in motor.plots._generate_grains(
427+
translate=(grains_cm_position, 0)
428+
):
429+
patches.append(MotorPatch(role="grain", **_polygon_xy(grain)))
430+
for tank, _center in motor.plots._generate_positioned_tanks(
431+
translate=(motor_position, 0), csys=total_csys
432+
):
433+
patches.append(MotorPatch(role="tank", **_polygon_xy(tank)))
434+
elif isinstance(motor, LiquidMotor):
435+
motor_type = "liquid"
436+
for tank, _center in motor.plots._generate_positioned_tanks(
437+
translate=(motor_position, 0), csys=total_csys
438+
):
439+
patches.append(MotorPatch(role="tank", **_polygon_xy(tank)))
440+
elif isinstance(motor, GenericMotor):
441+
# RocketPy's Rocket.draw() does not render a chamber for
442+
# GenericMotor — `_generate_combustion_chamber` depends on
443+
# grain fields GenericMotor lacks. We build an equivalent
444+
# rectangular chamber from the GenericMotor fields so users
445+
# see their chamber geometry in the playground.
446+
motor_type = "generic"
447+
chamber_center_x = (
448+
motor_position + motor.chamber_position * total_csys
449+
)
450+
chamber_patch = _build_generic_chamber_patch(
451+
center_x=chamber_center_x,
452+
chamber_height=motor.chamber_height,
453+
chamber_radius=motor.chamber_radius,
454+
)
455+
patches.append(
456+
MotorPatch(role="chamber", **_polygon_xy(chamber_patch))
457+
)
458+
459+
# Nozzle is always appended after the body so the motor-region
460+
# outline encompasses it, matching rocketpy.
461+
nozzle_patch = motor.plots._generate_nozzle(
462+
translate=(nozzle_position, 0), csys=parent_csys
463+
)
464+
patches.append(MotorPatch(role="nozzle", **_polygon_xy(nozzle_patch)))
465+
466+
# Motor-region outline. `_generate_motor_region` reads patch.xy
467+
# arrays, so we rebuild matplotlib Polygons once from our
468+
# coordinate copies. Any failure here is logged and dropped; the
469+
# outline is advisory, not load-bearing.
470+
try:
471+
mpl_patches = [_rebuild_polygon(p.x, p.y) for p in patches]
472+
outline_patch = motor.plots._generate_motor_region(
473+
list_of_patches=mpl_patches
474+
)
475+
patches.insert(
476+
0, MotorPatch(role="outline", **_polygon_xy(outline_patch))
477+
)
478+
except Exception as exc: # pragma: no cover - defensive
479+
logger.warning("Failed to generate motor outline patch: %s", exc)
480+
481+
return (
482+
MotorDrawingGeometry(
483+
type=motor_type,
484+
position=float(motor_position),
485+
nozzle_position=float(nozzle_position),
486+
grains_center_of_mass_position=(
487+
float(grains_cm_position)
488+
if grains_cm_position is not None
489+
else None
490+
),
491+
patches=patches,
492+
),
493+
float(nozzle_position),
494+
)
495+
496+
497+
def _compute_motor_bounds(
498+
motor: MotorDrawingGeometry, radius: float
499+
) -> DrawingBounds:
500+
"""Compute a tight bounding box for a motor-only drawing payload.
501+
502+
Mirrors the rocket-side bounds helper but limited to motor patches —
503+
callers who want rocket-wide bounds use the rocket service's own
504+
computation.
505+
"""
506+
xs: list[float] = []
507+
ys: list[float] = []
508+
for patch in motor.patches:
509+
xs += patch.x
510+
ys += patch.y
511+
if not xs:
512+
xs = [float(motor.position)]
513+
if not ys:
514+
ys = [-float(radius), float(radius)]
515+
return DrawingBounds(
516+
x_min=float(min(xs)),
517+
x_max=float(max(xs)),
518+
y_min=float(min(ys)),
519+
y_max=float(max(ys)),
520+
)

0 commit comments

Comments
 (0)