Skip to content
Open
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
22 changes: 22 additions & 0 deletions src/controllers/flight.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,28 @@ async def get_rocketpy_flight_rpy(
flight_service = FlightService.from_flight_model(flight.flight)
return flight_service.get_flight_rpy()

@controller_exception_handler
async def get_flight_kml(
self,
flight_id: str,
) -> bytes:
"""
Get the flight trajectory as a KML file.

Args:
flight_id: str

Returns:
bytes (KML XML)

Raises:
HTTP 404 Not Found: If the flight is not found
in the database.
"""
flight = await self.get_flight_by_id(flight_id)
flight_service = FlightService.from_flight_model(flight.flight)
return flight_service.get_flight_kml()

@controller_exception_handler
async def get_flight_simulation(
self,
Expand Down
36 changes: 36 additions & 0 deletions src/routes/flight.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,42 @@ async def update_flight_rocket(
)


@router.get(
"/{flight_id}/kml",
responses={
200: {
"description": "KML trajectory file download",
"content": {"application/vnd.google-earth.kml+xml": {}},
}
},
status_code=200,
response_class=Response,
)
async def get_flight_kml(
flight_id: str,
controller: FlightControllerDep,
):
"""
Export a flight trajectory as a KML file for Google Earth.

## Args
``` flight_id: str ```
"""
with tracer.start_as_current_span("get_flight_kml"):
kml = await controller.get_flight_kml(flight_id)
headers = {
"Content-Disposition": (
f'attachment; filename="flight_{flight_id}.kml"'
),
}
return Response(
content=kml,
headers=headers,
media_type="application/vnd.google-earth.kml+xml",
status_code=200,
)


@router.get("/{flight_id}/simulate")
async def get_flight_simulation(
flight_id: str,
Expand Down
22 changes: 22 additions & 0 deletions src/services/flight.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import json
import os
import tempfile
from typing import Self, Tuple

import numpy as np

from rocketpy.simulation.flight import Flight as RocketPyFlight
from rocketpy.simulation.flight_data_exporter import FlightDataExporter
from rocketpy._encoders import RocketPyEncoder, RocketPyDecoder
from rocketpy.mathutils.function import Function
from rocketpy.motors.solid_motor import SolidMotor
Expand Down Expand Up @@ -499,6 +502,25 @@ def get_flight_simulation(self) -> FlightSimulation:
flight_simulation = FlightSimulation(**encoded_attributes)
return flight_simulation

def get_flight_kml(self) -> bytes:
"""
Get the flight trajectory as a KML file for Google Earth.

Returns:
bytes (UTF-8 encoded KML)
"""
with tempfile.NamedTemporaryFile(
suffix=".kml", delete=False
) as tmp:
tmp_path = tmp.name
try:
FlightDataExporter(self._flight).export_kml(file_name=tmp_path)
with open(tmp_path, "rb") as fh:
return fh.read()
finally:
if os.path.exists(tmp_path):
os.unlink(tmp_path)

def get_flight_rpy(self) -> bytes:
"""
Get the portable JSON ``.rpy`` representation of the flight.
Expand Down
33 changes: 33 additions & 0 deletions tests/unit/test_routes/test_flights_route.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ def mock_controller_instance():
mock_controller.get_rocketpy_flight_rpy = AsyncMock()
mock_controller.import_flight_from_rpy = AsyncMock()
mock_controller.get_flight_notebook = AsyncMock()
mock_controller.get_flight_kml = AsyncMock()
mock_controller.update_environment_by_flight_id = AsyncMock()
mock_controller.update_rocket_by_flight_id = AsyncMock()
mock_controller.create_flight_from_references = AsyncMock()
Expand Down Expand Up @@ -539,6 +540,38 @@ def test_read_rocketpy_flight_rpy_server_error(mock_controller_instance):
assert response.json() == {'detail': 'Internal Server Error'}


def test_read_flight_kml(mock_controller_instance):
kml_bytes = b'<?xml version="1.0" encoding="UTF-8"?><kml></kml>'
mock_controller_instance.get_flight_kml = AsyncMock(return_value=kml_bytes)
response = client.get('/flights/123/kml')
assert response.status_code == 200
assert response.content == kml_bytes
assert (
response.headers['content-type']
== 'application/vnd.google-earth.kml+xml'
)
assert 'flight_123.kml' in response.headers['content-disposition']
mock_controller_instance.get_flight_kml.assert_called_once_with('123')


def test_read_flight_kml_not_found(mock_controller_instance):
mock_controller_instance.get_flight_kml.side_effect = HTTPException(
status_code=status.HTTP_404_NOT_FOUND
)
response = client.get('/flights/123/kml')
assert response.status_code == 404
assert response.json() == {'detail': 'Not Found'}


def test_read_flight_kml_server_error(mock_controller_instance):
mock_controller_instance.get_flight_kml.side_effect = HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
)
response = client.get('/flights/123/kml')
assert response.status_code == 500
assert response.json() == {'detail': 'Internal Server Error'}


# --- Issue #56: Import flight from .rpy ---


Expand Down
10 changes: 5 additions & 5 deletions tests/unit/test_routes/test_motors_route.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@ def test_create_liquid_motor_sampled_density(


def test_create_motor_invalid_geometry_kind(
stub_motor_dump, stub_tank_dump, mock_controller_instance
stub_motor_dump, stub_tank_dump
):
stub_tank_dump['geometry'] = {
'geometry_kind': 'pyramid',
Expand All @@ -263,7 +263,7 @@ def test_create_motor_invalid_geometry_kind(


def test_create_motor_mass_kind_missing_fields(
stub_motor_dump, stub_tank_dump, mock_controller_instance
stub_motor_dump, stub_tank_dump
):
# stub_tank_dump defaults to MASS_FLOW with all required fields
# populated; switching to MASS without adding liquid_mass/gas_mass
Expand All @@ -280,7 +280,7 @@ def test_create_motor_mass_kind_missing_fields(


def test_create_motor_level_kind_missing_liquid_height(
stub_motor_dump, stub_tank_dump, mock_controller_instance
stub_motor_dump, stub_tank_dump
):
stub_tank_dump['tank_kind'] = 'LEVEL'
stub_motor_dump.update(
Expand All @@ -292,7 +292,7 @@ def test_create_motor_level_kind_missing_liquid_height(


def test_create_motor_ullage_kind_missing_ullage(
stub_motor_dump, stub_tank_dump, mock_controller_instance
stub_motor_dump, stub_tank_dump
):
stub_tank_dump['tank_kind'] = 'ULLAGE'
stub_motor_dump.update(
Expand All @@ -304,7 +304,7 @@ def test_create_motor_ullage_kind_missing_ullage(


def test_create_motor_mass_flow_kind_missing_flow_rates(
stub_motor_dump, mock_controller_instance
stub_motor_dump
):
# Build a tank payload with MASS_FLOW kind but no flow-rate fields
# so the guard rejects it.
Expand Down
Loading