From 2f5262811604ef40828343ee97d291cc8e90835f Mon Sep 17 00:00:00 2001 From: Joshua Terranova Date: Mon, 10 Aug 2026 18:46:40 -0700 Subject: [PATCH 1/3] ENH: add Folium interactive flight trajectory map (#963) --- pyproject.toml | 11 +++- rocketpy/plots/flight_plots.py | 70 ++++++++++++++++++++ tests/unit/test_flight_trajectory_map.py | 84 ++++++++++++++++++++++++ 3 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 tests/unit/test_flight_trajectory_map.py diff --git a/pyproject.toml b/pyproject.toml index 456441f33..9133029e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/rocketpy/plots/flight_plots.py b/rocketpy/plots/flight_plots.py index 6dd5e8802..df5713565 100644 --- a/rocketpy/plots/flight_plots.py +++ b/rocketpy/plots/flight_plots.py @@ -144,6 +144,76 @@ 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) + def trajectory_on_map(self, *, filename=None): + """Create an interactive Folium map of the flight trajectory. + + Draws the ground-track path from ``flight.latitude`` / + ``flight.longitude`` and marks the launch and landing sites. + Requires the optional ``folium`` dependency + (``pip install folium`` or ``pip install rocketpy[maps]``). + + Parameters + ---------- + filename : str | None, optional + Path to save the map as an HTML file. If None, the map is not + written to disk. Default is None. + + Returns + ------- + folium.Map + The interactive map object. In Jupyter, displaying the return + value renders the map. + """ + folium = import_optional_dependency("folium") + flight = self.flight + + latitudes = np.asarray(flight.latitude[:, 1], dtype=float) + longitudes = np.asarray(flight.longitude[:, 1], dtype=float) + path = list(zip(latitudes.tolist(), longitudes.tolist())) + if not path: + raise ValueError("Flight has no latitude/longitude samples to plot.") + + launch = path[0] + landing = path[-1] + center = [ + float(0.5 * (launch[0] + landing[0])), + float(0.5 * (launch[1] + landing[1])), + ] + + flight_map = folium.Map(location=center, zoom_start=13) + folium.PolyLine( + locations=path, + color="#1f77b4", + weight=3, + opacity=0.85, + tooltip="Flight trajectory", + ).add_to(flight_map) + folium.Marker( + location=launch, + popup="Launch", + tooltip="Launch", + icon=folium.Icon(color="green"), + ).add_to(flight_map) + folium.Marker( + location=landing, + popup="Landing", + tooltip="Landing", + icon=folium.Icon(color="red"), + ).add_to(flight_map) + + south = float(np.min(latitudes)) + north = float(np.max(latitudes)) + west = float(np.min(longitudes)) + east = float(np.max(longitudes)) + if abs(north - south) > 1e-12 or abs(east - west) > 1e-12: + flight_map.fit_bounds([[south, west], [north, east]]) + + if filename is not None: + flight_map.save(filename) + logger.info("File %s saved with success!", filename) + + return flight_map + 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: diff --git a/tests/unit/test_flight_trajectory_map.py b/tests/unit/test_flight_trajectory_map.py new file mode 100644 index 000000000..33eb687de --- /dev/null +++ b/tests/unit/test_flight_trajectory_map.py @@ -0,0 +1,84 @@ +"""Tests for optional Folium flight trajectory maps.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from rocketpy.plots.flight_plots import _FlightPlots + + +def test_trajectory_on_map_requires_folium(flight_calisto_robust): + """Missing folium should raise a clear ImportError via optional import.""" + with patch( + "rocketpy.plots.flight_plots.import_optional_dependency", + side_effect=ImportError( + "folium is an optional dependency and is not installed.\n" + "\t\tUse 'pip install folium' to install it or " + "'pip install rocketpy[all]' to install all optional dependencies." + ), + ): + with pytest.raises(ImportError, match="folium"): + flight_calisto_robust.plots.trajectory_on_map() + + +def test_trajectory_on_map_builds_map_with_mocked_folium(flight_calisto_robust): + """Map construction should add a path and launch/landing markers.""" + mock_folium = MagicMock() + mock_map = MagicMock() + mock_folium.Map.return_value = mock_map + mock_polyline = MagicMock() + mock_folium.PolyLine.return_value = mock_polyline + mock_marker = MagicMock() + mock_folium.Marker.return_value = mock_marker + + with patch( + "rocketpy.plots.flight_plots.import_optional_dependency", + return_value=mock_folium, + ): + result = flight_calisto_robust.plots.trajectory_on_map() + + assert result is mock_map + mock_folium.Map.assert_called_once() + mock_folium.PolyLine.assert_called_once() + assert mock_folium.Marker.call_count == 2 + mock_polyline.add_to.assert_called_once_with(mock_map) + assert mock_marker.add_to.call_count == 2 + mock_map.save.assert_not_called() + + +def test_trajectory_on_map_saves_html_with_mocked_folium( + flight_calisto_robust, tmp_path +): + """filename= should call Map.save with the requested path.""" + mock_folium = MagicMock() + mock_map = MagicMock() + mock_folium.Map.return_value = mock_map + mock_folium.PolyLine.return_value = MagicMock() + mock_folium.Marker.return_value = MagicMock() + out = tmp_path / "trajectory.html" + + with patch( + "rocketpy.plots.flight_plots.import_optional_dependency", + return_value=mock_folium, + ): + result = flight_calisto_robust.plots.trajectory_on_map(filename=str(out)) + + assert result is mock_map + mock_map.save.assert_called_once_with(str(out)) + + +def test_trajectory_on_map_creates_html_file(flight_calisto_robust, tmp_path): + """With folium installed, save an HTML map and return a Map instance.""" + folium = pytest.importorskip("folium") + + out = tmp_path / "trajectory.html" + result = flight_calisto_robust.plots.trajectory_on_map(filename=str(out)) + + assert isinstance(result, folium.Map) + assert isinstance(flight_calisto_robust.plots, _FlightPlots) + assert out.is_file() + assert out.stat().st_size > 0 + html = out.read_text(encoding="utf-8") + assert "leaflet" in html.lower() + assert "Launch" in html + assert "Landing" in html From c48a85bf3602167d54963e785b73412f45b68002 Mon Sep 17 00:00:00 2001 From: Gui-FernandesBR Date: Sun, 13 Sep 2026 23:32:46 -0300 Subject: [PATCH 2/3] ENH: enrich Folium trajectory map with the EuRoC-Dev map tooling Carries over the conventions from the team's internal rocketfolium module (EuRoC-Dev) into Flight.plots.trajectory_on_map, and fills the gaps that kept PR #1132 in draft. Map rendering: - Add OpenStreetMap and Esri World Imagery background layers behind a LayerControl. Satellite imagery is what actually answers the recovery question (terrain, tree lines, water), which plain OSM cannot show. - Mark the apogee ground position, labelled with apogee AGL, between the launch and landing markers. Skipped when apogee was never detected, since Flight.apogee_time then keeps its initial value of zero. - Add optional range safety circles around the launch pad, in their own feature group so the layer control can toggle them. The initial viewport widens to contain them, otherwise the largest ring would open off screen and the parameter would be useless. - Add an optional overlay title. The text is HTML-escaped before being injected into the map root. API: - Add time_step, for parity with Flight.export_kml: the ground track is resampled by linear interpolation instead of drawing every integration step, which keeps the HTML small for long flights. - Add color, so the track can be recoloured without post-processing. Docs: - Document the maps extra in installation.rst, next to the animation extra it mirrors. - Add an "Interactive Trajectory Map" section to the Flight user guide, with the parameter table and a cross-reference to export_kml for the 3D case that a 2D map cannot cover. Verified: 10/10 unit tests pass (including the real-folium HTML export), ruff clean, pylint 10.00/10, and sphinx-build -W builds with zero warnings. The rendered map was checked in a browser. Co-Authored-By: Claude Opus 5 (1M context) --- docs/user/flight.rst | 77 ++++++++ docs/user/installation.rst | 16 ++ rocketpy/plots/flight_plots.py | 236 ++++++++++++++++++++--- tests/unit/test_flight_trajectory_map.py | 162 +++++++++++++--- 4 files changed, 432 insertions(+), 59 deletions(-) diff --git a/docs/user/flight.rst b/docs/user/flight.rst index 173541727..634dfe308 100644 --- a/docs/user/flight.rst +++ b/docs/user/flight.rst @@ -730,6 +730,83 @@ 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 +`_. 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. + +**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. + +.. 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 ~~~~~~~~~~~~~~~~~~ diff --git a/docs/user/installation.rst b/docs/user/installation.rst index 4325b390f..7563417cb 100644 --- a/docs/user/installation.rst +++ b/docs/user/installation.rst @@ -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 `_: + +.. 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 diff --git a/rocketpy/plots/flight_plots.py b/rocketpy/plots/flight_plots.py index 62bfda3d4..d8f2f4db8 100644 --- a/rocketpy/plots/flight_plots.py +++ b/rocketpy/plots/flight_plots.py @@ -1,5 +1,6 @@ # pylint: disable=too-many-lines +import html import logging import os import time @@ -144,69 +145,136 @@ 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) - def trajectory_on_map(self, *, filename=None): - """Create an interactive Folium map of the flight trajectory. + # 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 © Esri — 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 path from ``flight.latitude`` / - ``flight.longitude`` and marks the launch and landing sites. + 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 | None, optional - Path to save the map as an HTML file. If None, the map is not - written to disk. Default is None. + 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") - flight = self.flight - latitudes = np.asarray(flight.latitude[:, 1], dtype=float) - longitudes = np.asarray(flight.longitude[:, 1], dtype=float) + 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 = path[0] - landing = path[-1] + launch, landing = path[0], path[-1] center = [ float(0.5 * (launch[0] + landing[0])), float(0.5 * (launch[1] + landing[1])), ] - flight_map = folium.Map(location=center, zoom_start=13) + # 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="#1f77b4", + color=color, weight=3, opacity=0.85, tooltip="Flight trajectory", ).add_to(flight_map) - folium.Marker( - location=launch, - popup="Launch", - tooltip="Launch", - icon=folium.Icon(color="green"), - ).add_to(flight_map) - folium.Marker( - location=landing, - popup="Landing", - tooltip="Landing", - icon=folium.Icon(color="red"), - ).add_to(flight_map) - south = float(np.min(latitudes)) - north = float(np.max(latitudes)) - west = float(np.min(longitudes)) - east = float(np.max(longitudes)) - if abs(north - south) > 1e-12 or abs(east - west) > 1e-12: - flight_map.fit_bounds([[south, west], [north, east]]) + 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: + flight_map.fit_bounds(bounds) if filename is not None: flight_map.save(filename) @@ -214,6 +282,110 @@ def trajectory_on_map(self, *, filename=None): 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 = ( + '
' + f'

' + f"{html.escape(str(title))}

" + ) + 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: diff --git a/tests/unit/test_flight_trajectory_map.py b/tests/unit/test_flight_trajectory_map.py index 33eb687de..0316bd958 100644 --- a/tests/unit/test_flight_trajectory_map.py +++ b/tests/unit/test_flight_trajectory_map.py @@ -7,6 +7,27 @@ from rocketpy.plots.flight_plots import _FlightPlots +def mocked_folium(): + """Build a MagicMock standing in for the folium module.""" + folium = MagicMock() + folium.Map.return_value = MagicMock() + folium.PolyLine.return_value = MagicMock() + folium.Marker.return_value = MagicMock() + folium.TileLayer.return_value = MagicMock() + folium.Circle.return_value = MagicMock() + folium.FeatureGroup.return_value = MagicMock() + folium.LayerControl.return_value = MagicMock() + return folium + + +def patch_folium(folium): + """Patch the optional import so that ``folium`` is the injected mock.""" + return patch( + "rocketpy.plots.flight_plots.import_optional_dependency", + return_value=folium, + ) + + def test_trajectory_on_map_requires_folium(flight_calisto_robust): """Missing folium should raise a clear ImportError via optional import.""" with patch( @@ -22,45 +43,127 @@ def test_trajectory_on_map_requires_folium(flight_calisto_robust): def test_trajectory_on_map_builds_map_with_mocked_folium(flight_calisto_robust): - """Map construction should add a path and launch/landing markers.""" - mock_folium = MagicMock() - mock_map = MagicMock() - mock_folium.Map.return_value = mock_map - mock_polyline = MagicMock() - mock_folium.PolyLine.return_value = mock_polyline - mock_marker = MagicMock() - mock_folium.Marker.return_value = mock_marker + """Map construction should add a path, tiles and the flight markers.""" + folium = mocked_folium() + mock_map = folium.Map.return_value - with patch( - "rocketpy.plots.flight_plots.import_optional_dependency", - return_value=mock_folium, - ): + with patch_folium(folium): result = flight_calisto_robust.plots.trajectory_on_map() assert result is mock_map - mock_folium.Map.assert_called_once() - mock_folium.PolyLine.assert_called_once() - assert mock_folium.Marker.call_count == 2 - mock_polyline.add_to.assert_called_once_with(mock_map) - assert mock_marker.add_to.call_count == 2 + folium.Map.assert_called_once() + folium.PolyLine.assert_called_once() + folium.PolyLine.return_value.add_to.assert_called_once_with(mock_map) + # Launch, apogee and landing markers. + assert folium.Marker.call_count == 3 + assert folium.Marker.return_value.add_to.call_count == 3 + # OpenStreetMap and Esri satellite backgrounds, plus a control to swap them. + assert folium.TileLayer.call_count == 2 + folium.LayerControl.assert_called_once() + # No safety circles and no title unless explicitly requested. + folium.Circle.assert_not_called() + mock_map.get_root.assert_not_called() mock_map.save.assert_not_called() +def test_trajectory_on_map_marks_apogee_between_launch_and_landing( + flight_calisto_robust, +): + """The apogee marker should sit between the launch and landing markers.""" + folium = mocked_folium() + + with patch_folium(folium): + flight_calisto_robust.plots.trajectory_on_map() + + labels = [call.kwargs["tooltip"] for call in folium.Marker.call_args_list] + assert labels[0] == "Launch" + assert labels[1].startswith("Apogee") + assert labels[-1] == "Landing" + + +def test_trajectory_on_map_skips_apogee_when_not_detected(flight_calisto_robust): + """A flight without a detected apogee should only get two markers.""" + folium = mocked_folium() + + with patch_folium(folium): + with patch.object(flight_calisto_robust, "apogee_time", 0): + flight_calisto_robust.plots.trajectory_on_map() + + labels = [call.kwargs["tooltip"] for call in folium.Marker.call_args_list] + assert labels == ["Launch", "Landing"] + + +def test_trajectory_on_map_draws_safety_radii(flight_calisto_robust): + """safety_radii= should draw one circle per radius on the launch site.""" + folium = mocked_folium() + + with patch_folium(folium): + flight_calisto_robust.plots.trajectory_on_map(safety_radii=[2500, 5000]) + + assert folium.Circle.call_count == 2 + radii = [call.kwargs["radius"] for call in folium.Circle.call_args_list] + assert radii == [2500.0, 5000.0] + folium.FeatureGroup.assert_called_once_with(name="Safety radii") + + +def test_trajectory_on_map_frames_safety_radii(flight_calisto_robust): + """The initial viewport should widen to contain the requested circles.""" + folium = mocked_folium() + mock_map = folium.Map.return_value + + with patch_folium(folium): + flight_calisto_robust.plots.trajectory_on_map() + (track_bounds,) = mock_map.fit_bounds.call_args.args + + folium = mocked_folium() + mock_map = folium.Map.return_value + with patch_folium(folium): + flight_calisto_robust.plots.trajectory_on_map(safety_radii=[5000]) + (widened,) = mock_map.fit_bounds.call_args.args + + (track_sw, track_ne), (wide_sw, wide_ne) = track_bounds, widened + assert wide_sw[0] < track_sw[0] and wide_sw[1] < track_sw[1] + assert wide_ne[0] > track_ne[0] and wide_ne[1] > track_ne[1] + # 5 km is roughly 0.045 degrees of latitude. + assert 0.08 < (wide_ne[0] - wide_sw[0]) < 0.12 + + +def test_trajectory_on_map_escapes_title(flight_calisto_robust): + """A title should be injected as HTML with its markup escaped.""" + folium = mocked_folium() + + with patch_folium(folium): + flight_calisto_robust.plots.trajectory_on_map(title="") + + folium.Element.assert_called_once() + title_html = folium.Element.call_args.args[0] + assert "