Skip to content
Merged
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
Binary file added docs/static/flight/trajectory_on_map.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
92 changes: 92 additions & 0 deletions docs/user/flight.rst
Original file line number Diff line number Diff line change
Expand Up @@ -730,6 +730,98 @@ are removed before the remaining arguments are forwarded to PyVista:
before any rendering begins, raising a :class:`ValueError` with a descriptive
message on invalid input.

Interactive Trajectory Map
~~~~~~~~~~~~~~~~~~~~~~~~~~

``flight.plots.trajectory_on_map()`` renders the flight **ground track** on a
real-world interactive map using `Folium
<https://python-visualization.github.io/folium/>`_. Unlike the 3D trajectory
plot, this one answers a range-safety question: *where on the actual terrain
did the rocket fly over, and where did it come down?*

The map ships with two selectable backgrounds — OpenStreetMap for roads and
place names, and Esri World Imagery for satellite view, which is what usually
matters when assessing a recovery field. Launch, apogee and landing sites are
marked automatically.

.. figure:: ../static/flight/trajectory_on_map.jpg
:align: center
:alt: Ground track of a simulated flight over satellite imagery

The ground track of a Calisto flight, with the launch site (green), the
apogee ground position (blue) and the landing site (red).

**Installation**

The ``folium`` dependency is not installed by default. Add the optional extra
before calling the method:

.. code-block:: bash

pip install rocketpy[maps]

If ``folium`` is not available when the method is called, RocketPy raises an
:class:`ImportError` with the above install command embedded in the message.

**Usage**

.. code-block:: python

# Quickstart: returns a folium.Map, which renders inline in Jupyter
flight.plots.trajectory_on_map()

# Save a self-contained HTML file you can open in any browser
flight.plots.trajectory_on_map(filename="trajectory.html")

# Range safety check with distance rings around the launch pad
flight.plots.trajectory_on_map(
filename="trajectory.html",
time_step=0.5, # resample the track to keep the file small
color="#ff7f0e", # ground track color, any CSS color
safety_radii=[2500, 5000], # circles in meters, centred on the pad
title="Calisto — Flight 01", # overlay title on top of the map
)

.. list-table::
:header-rows: 1
:widths: 25 75

* - Parameter
- Description
* - ``filename``
- Path of the HTML file to write. If None, nothing is saved and the map is
only returned. Default is None.
* - ``time_step``
- Sampling interval in seconds. If None, every integration step is drawn.
Otherwise the track is linearly interpolated over a uniform time grid,
mirroring ``Flight.export_kml``. Default is None.
* - ``color``
- Ground track color, as any CSS color string. Default is ``"#1f77b4"``.
* - ``safety_radii``
- Sequence of radii in meters, drawn as circles centred on the launch
site. Default is None.
* - ``title``
- Title rendered as an overlay on top of the map. Default is None.

.. figure:: ../static/flight/trajectory_on_map_safety_radii.jpg
:align: center
:alt: Range safety circles drawn around the launch site

``safety_radii=[2500, 5000]`` draws range safety circles around the launch
pad. The initial viewport widens so that the outermost circle stays in
frame, and the circles sit in their own layer so they can be toggled off.

.. note::

The apogee marker is omitted when the simulation never detected an apogee,
for example when the flight terminated on the rail.

.. seealso::

:ref:`flightusage` also offers ``flight.export_kml()`` for viewing the
full 3D trajectory in Google Earth, including altitude, which an
interactive 2D map cannot show.

Forces and Moments
~~~~~~~~~~~~~~~~~~

Expand Down
16 changes: 16 additions & 0 deletions docs/user/installation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,22 @@ Once installed, you can render animations from a :class:`rocketpy.Flight` object

See :ref:`flightusage` for full details and parameter descriptions.

**Interactive Maps** — render the flight ground track on a real-world
interactive map using `Folium <https://python-visualization.github.io/folium/>`_:

.. code-block:: shell

pip install rocketpy[maps]

Once installed, you can build a map from a :class:`rocketpy.Flight` object:

.. code-block:: python

# Open the result in a browser, or display it inline in Jupyter
flight.plots.trajectory_on_map(filename="trajectory.html")

See :ref:`flightusage` for full details and parameter descriptions.

**All extras** — install every optional dependency at once:

.. code-block:: shell
Expand Down
11 changes: 10 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,16 @@ animation = [
"imageio-ffmpeg>=0.5"
]

all = ["rocketpy[env-analysis]", "rocketpy[monte-carlo]", "rocketpy[animation]"]
maps = [
"folium>=0.14",
]

all = [
"rocketpy[env-analysis]",
"rocketpy[monte-carlo]",
"rocketpy[animation]",
"rocketpy[maps]",
]


[tool.coverage.report]
Expand Down
245 changes: 245 additions & 0 deletions rocketpy/plots/flight_plots.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# pylint: disable=too-many-lines

import html
import logging
import os
import time
Expand Down Expand Up @@ -144,6 +145,250 @@ def trajectory_3d(self, *, filename=None): # pylint: disable=too-many-statement
ax1.set_box_aspect(None, zoom=0.95) # 95% for label adjustment
show_or_save_plot(filename)

# Background tile layers offered on every trajectory map. OpenStreetMap
# gives readable roads and place names; the Esri imagery layer is what
# actually matters for a rocket, since recovery fields, tree lines and
# water are only visible on satellite imagery.
_MAP_TILE_LAYERS = (
{
"tiles": "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
"attr": "OpenStreetMap",
"name": "OpenStreetMap",
},
{
"tiles": (
"https://server.arcgisonline.com/ArcGIS/rest/services/"
"World_Imagery/MapServer/tile/{z}/{y}/{x}.png"
),
"attr": (
"Tiles &copy; Esri &mdash; Source: Esri, i-cubed, USDA, USGS, "
"AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the "
"GIS User Community"
),
"name": "Esri Satellite",
},
)

def trajectory_on_map(
self,
*,
filename=None,
time_step=None,
color="#1f77b4",
safety_radii=None,
title=None,
):
"""Create an interactive Folium map of the flight ground track.

Draws the ground track from ``flight.latitude`` / ``flight.longitude``
over selectable OpenStreetMap and satellite imagery layers, and marks
the launch site, the apogee ground position and the landing site.
Requires the optional ``folium`` dependency
(``pip install folium`` or ``pip install rocketpy[maps]``).

Parameters
----------
filename : str, optional
Path to save the map as a self-contained HTML file. If None, the
map is not written to disk. Default is None.
time_step : float, optional
Time step, in seconds, used to sample the trajectory. If None, all
integration time steps are used. Otherwise the ground track is
resampled by linear interpolation, which keeps the HTML file small
for long flights. Default is None.
color : str, optional
Color of the ground track, as any CSS color string. Default is
``"#1f77b4"``.
safety_radii : Sequence[float], optional
Radii, in meters, of circles drawn around the launch site. Useful
to check the trajectory against range safety limits, e.g.
``[2500, 5000, 10000]``. If None, no circles are drawn. Default is
None.
title : str, optional
Title rendered as an overlay on top of the map. If None, no title
is drawn. Default is None.

Returns
-------
folium.Map
The interactive map object. In Jupyter, displaying the return
value renders the map.

Raises
------
ValueError
If the flight has no latitude/longitude samples to plot.

Examples
--------
>>> flight.plots.trajectory_on_map( # doctest: +SKIP
... filename="trajectory.html",
... safety_radii=[2500, 5000],
... title="Flight 01",
... )
"""
folium = import_optional_dependency("folium")

latitudes, longitudes = self.__sample_ground_track(time_step)
path = list(zip(latitudes.tolist(), longitudes.tolist()))
if not path:
raise ValueError("Flight has no latitude/longitude samples to plot.")

launch, landing = path[0], path[-1]
center = [
float(0.5 * (launch[0] + landing[0])),
float(0.5 * (launch[1] + landing[1])),
]

# tiles=None so that the two layers below are the only backgrounds and
# both show up in the layer control.
flight_map = folium.Map(
location=center, zoom_start=13, tiles=None, control_scale=True
)
for layer in self._MAP_TILE_LAYERS:
folium.TileLayer(control=True, **layer).add_to(flight_map)

folium.PolyLine(
locations=path,
color=color,
weight=3,
opacity=0.85,
tooltip="Flight trajectory",
).add_to(flight_map)

for location, label, icon_color in self.__trajectory_markers(launch, landing):
folium.Marker(
location=location,
popup=label,
tooltip=label,
icon=folium.Icon(color=icon_color),
).add_to(flight_map)

if safety_radii:
self.__add_safety_circles(folium, flight_map, launch, safety_radii)

if title:
self.__add_map_title(folium, flight_map, title)

folium.LayerControl(collapsed=False).add_to(flight_map)

bounds = self.__map_bounds(latitudes, longitudes, launch, safety_radii)
if bounds is not None:
# Pad the viewport so that the launch and landing pins, which are
# anchored at the very edge of the bounding box, are not clipped by
# the border of the map.
flight_map.fit_bounds(bounds, padding=(30, 30))

if filename is not None:
flight_map.save(filename)
logger.info("File %s saved with success!", filename)

return flight_map

def __sample_ground_track(self, time_step):
"""Return the (latitude, longitude) arrays of the ground track.

When ``time_step`` is None the raw integration steps are used, mirroring
the behaviour of ``Flight.export_kml``. Otherwise the coordinates are
linearly interpolated over a uniform time grid.
"""
flight = self.flight
if time_step is None:
return (
np.asarray(flight.latitude[:, 1], dtype=float),
np.asarray(flight.longitude[:, 1], dtype=float),
)
time_points = np.arange(flight.t_initial, flight.t_final + time_step, time_step)
return (
np.array([flight.latitude.get_value_opt(t) for t in time_points]),
np.array([flight.longitude.get_value_opt(t) for t in time_points]),
)

def __trajectory_markers(self, launch, landing):
"""Yield the (location, label, color) of each trajectory marker.

The apogee marker is skipped when apogee was never detected, since
``Flight.apogee_time`` then keeps its initial value of zero and would
place the marker on top of the launch site.
"""
flight = self.flight
yield launch, "Launch", "green"
if flight.apogee_time > flight.t_initial:
apogee = (
flight.latitude.get_value_opt(flight.apogee_time),
flight.longitude.get_value_opt(flight.apogee_time),
)
yield (
apogee,
f"Apogee ({flight.apogee - flight.env.elevation:.0f} m AGL)",
("blue"),
)
yield landing, "Landing", "red"

@staticmethod
def __map_bounds(latitudes, longitudes, launch, safety_radii):
"""Return the ``[[south, west], [north, east]]`` box the map opens on.

The box always contains the ground track. When safety circles were
requested it is widened to contain them too, otherwise the largest ring
would sit outside the initial viewport and the user would have to zoom
out to find it. Returns None when the track degenerates to a single
point, in which case the caller should keep the default zoom.
"""
south, north = float(np.min(latitudes)), float(np.max(latitudes))
west, east = float(np.min(longitudes)), float(np.max(longitudes))

if safety_radii:
# Equirectangular approximation, which is plenty for framing a map:
# one degree of latitude is ~111.32 km, and one degree of longitude
# shrinks by cos(latitude).
radius = max(float(r) for r in safety_radii)
delta_lat = radius / 111320.0
delta_lon = delta_lat / max(np.cos(np.radians(launch[0])), 1e-6)
south, north = (
min(south, launch[0] - delta_lat),
max(north, launch[0] + delta_lat),
)
west, east = (
min(west, launch[1] - delta_lon),
max(east, launch[1] + delta_lon),
)

if abs(north - south) <= 1e-12 and abs(east - west) <= 1e-12:
return None
return [[south, west], [north, east]]

@staticmethod
def __add_safety_circles(folium, flight_map, launch, safety_radii):
"""Draw range safety circles centred on the launch site.

They live in their own feature group so that the layer control can
toggle them without hiding the trajectory.
"""
safety_group = folium.FeatureGroup(name="Safety radii")
for radius in safety_radii:
folium.Circle(
location=launch,
radius=float(radius),
color="orange",
tooltip=f"R{float(radius):.0f} m",
fill=False,
).add_to(safety_group)
safety_group.add_to(flight_map)

@staticmethod
def __add_map_title(folium, flight_map, title):
"""Render ``title`` as a floating overlay on top of the map."""
title_html = (
'<div style="position: fixed; top: 10px; left: 50%;'
" transform: translate(-50%, 0); width: 70vw; max-width: 600px;"
" background-color: rgba(255, 255, 255, 0.7); border-radius: 6px;"
' padding: 5px; z-index: 9999;">'
f'<h3 align="center" style="font-size: 20px; margin: 0; color: black;">'
f"<b>{html.escape(str(title))}</b></h3></div>"
)
flight_map.get_root().html.add_child(folium.Element(title_html))

def _resolve_animation_model_path(self, file_name):
"""Resolve model path, defaulting to the built-in STL when omitted."""
if file_name is not None:
Expand Down
Loading
Loading