Skip to content
Open
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
aaf5612
ENH: Add contextily as a dependency for Monte Carlo simulations in py…
C8H10O2 Dec 2, 2025
14cdb4a
ENH: Add background map functionality to Monte Carlo plots
C8H10O2 Dec 2, 2025
14f924c
DOC: Enhance documentation for _get_background_map method in Monte Ca…
C8H10O2 Dec 2, 2025
e7e8bfc
MNT: Move imageio import to conditional block in _MonteCarloPlots class
C8H10O2 Dec 2, 2025
3ca9159
TST: Add unit tests for ellipses background functionality in Monte Ca…
C8H10O2 Dec 2, 2025
30ddaa0
TST: Add integration test for Monte Carlo background map options at K…
C8H10O2 Dec 2, 2025
f026acc
DOC: Updated CHANGELOG.md
C8H10O2 Dec 2, 2025
8ccdfb7
STY: Reformat with black
C8H10O2 Dec 2, 2025
7036aec
MNT: Fix wrong usage for set_aspect in _MonteCarloPlots.ellipses_comp…
C8H10O2 Dec 2, 2025
408af47
MNT: Refactor _get_background_map in _MonteCarloPlots class
C8H10O2 Dec 3, 2025
2efc134
MNT: Move mercator_to_wgs84 from_MonteCarloPlots class to tools
C8H10O2 Dec 3, 2025
37d49aa
MNT: Decompose _get_background_map and move utilities to tools.py
C8H10O2 Dec 3, 2025
89f9bcf
BUG: Fix map alignment by enforcing standard Earth radius in plots
C8H10O2 Dec 3, 2025
c39f297
TST: Add Kennedy Space Center environment fixture
C8H10O2 Dec 3, 2025
02eb829
ENH: Improve error handling and documentation for automatically downl…
C8H10O2 Dec 3, 2025
f26be7c
TST: Enhance Monte Carlo background map unit tests
C8H10O2 Dec 3, 2025
bc309b4
TST: Refactor Monte Carlo plot tests with mock class
C8H10O2 Dec 4, 2025
0caa835
DOC: Add background map options to documentation
C8H10O2 Dec 4, 2025
5d9af92
DOC: Update Monte Carlo analysis notebook with background parameter
C8H10O2 Dec 4, 2025
150ee0e
REV: Remove background documents form mrs.rst
C8H10O2 Dec 4, 2025
93a0b3b
TST: Refactor Monte Carlo plot tests to remove cleanup function
C8H10O2 Dec 4, 2025
940c880
TST: Enhance Monte Carlo plot tests with file cleanup
C8H10O2 Dec 4, 2025
7178139
DOC: Update docstring for background map provider resolution
C8H10O2 Dec 4, 2025
d84bbfa
ENH: Improve error handling in Monte Carlo background map fetching
C8H10O2 Dec 5, 2025
551dafc
TST: Parameterize tests for bounds2img failure scenarios
C8H10O2 Dec 5, 2025
72c4a6e
TST: Parameterize background map option tests in Monte Carlo plots
C8H10O2 Dec 5, 2025
2e53b53
MNT: Formatting monte_carlo_class_usage.ipynb with ruff
C8H10O2 Dec 5, 2025
c9748c6
TST: Refactor imports in Monte Carlo plot tests for consistency
C8H10O2 Dec 5, 2025
4b65f03
TST: Skip tests requiring contextily in Monte Carlo plot tests
C8H10O2 Dec 5, 2025
bbb603f
TST: Update contextily import in Monte Carlo plot tests
C8H10O2 Dec 5, 2025
c6bae7f
Update contextily dependency in pyproject.toml and requirements-optio…
C8H10O2 Dec 7, 2025
e450d25
DOC: Update monte_carlo_class_usage.ipynb to note about contextily is…
C8H10O2 Dec 7, 2025
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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
Attention: The newest changes should be on top -->

### Added

- ENH: Add background map auto download functionality to Monte Carlo plots [#896](https://github.com/RocketPy-Team/RocketPy/pull/896)
- MNT: net thrust addition to 3 dof in flight class [#907] (https://github.com/RocketPy-Team/RocketPy/pull/907)
- ENH: 3-dof lateral motion improvement [#883](https://github.com/RocketPy-Team/RocketPy/pull/883)
- ENH: Add multi-dimensional drag coefficient support (Cd as function of M, Re, α) [#875](https://github.com/RocketPy-Team/RocketPy/pull/875)
Expand Down
597 changes: 510 additions & 87 deletions docs/notebooks/monte_carlo_analysis/monte_carlo_class_usage.ipynb

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ monte-carlo = [
"multiprocess>=0.70",
"statsmodels",
"prettytable",
"contextily>=1.0.0",
]

all = ["rocketpy[env-analysis]", "rocketpy[monte-carlo]"]
Expand Down
3 changes: 2 additions & 1 deletion requirements-optional.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ timezonefinder
imageio
multiprocess>=0.70
statsmodels
prettytable
prettytable
contextily>=1.0.0
231 changes: 226 additions & 5 deletions rocketpy/plots/monte_carlo_plots.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
from pathlib import Path
import urllib

from PIL import UnidentifiedImageError
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.transforms import offset_copy

from ..tools import generate_monte_carlo_ellipses, import_optional_dependency
from ..tools import (
convert_local_extent_to_wgs84,
convert_mercator_extent_to_local,
generate_monte_carlo_ellipses,
import_optional_dependency,
)
from .plot_helpers import show_or_save_plot


Expand All @@ -14,10 +21,194 @@ class _MonteCarloPlots:
def __init__(self, monte_carlo):
self.monte_carlo = monte_carlo

def _get_environment_coordinates(self):
"""Get origin coordinates and earth radius from the environment.

Returns
-------
tuple[float, float, float]
A tuple containing (origin_lat, origin_lon, earth_radius).

Raises
------
ValueError
If MonteCarlo object doesn't have an environment attribute, or if
environment doesn't have latitude and longitude attributes.
"""
if not hasattr(self.monte_carlo, "environment"):
raise ValueError(
"MonteCarlo object must have an 'environment' attribute "
"for automatically fetching the background map."
)
env = self.monte_carlo.environment
if not hasattr(env, "latitude") or not hasattr(env, "longitude"):
raise ValueError(
"Environment must have 'latitude' and 'longitude' attributes "
"for automatically fetching the background map."
)

# Handle both StochasticEnvironment (which stores as lists) and
# Environment (which stores as scalars)
origin_lat = env.latitude
origin_lon = env.longitude
if isinstance(origin_lat, (list, tuple)):
origin_lat = origin_lat[0]
if isinstance(origin_lon, (list, tuple)):
origin_lon = origin_lon[0]

# We enforce the standard WGS84 Earth radius (approx. 6,378,137 m) for
# visualization purposes. Background map providers (e.g., via Contextily)
# typically use Web Mercator (EPSG:3857), which assumes this standard radius.
# Using a custom local radius here—even if used in the physics simulation—would
# cause projection mismatches, resulting in the map being offset or scaled
# incorrectly relative to the data points.
earth_radius = 6378137.0

return origin_lat, origin_lon, earth_radius

def _resolve_map_provider(self, background, contextily):
"""Resolve the map provider string to a contextily provider object.

Parameters
----------
background : str
Type of background map. Options: "satellite", "street", "terrain",
or any contextily provider name (e.g., "CartoDB.Positron").
contextily : module
The contextily module.

Returns
-------
object
The resolved contextily provider object.

Raises
------
ValueError
If the map provider string cannot be resolved in contextily.providers.
This may occur if the provider name is invalid. Check the provider name
or use one of the built-in options: 'satellite', 'street', or 'terrain'.
"""
if background == "satellite":
map_provider = "Esri.WorldImagery"
elif background == "street":
map_provider = "OpenStreetMap.Mapnik"
elif background == "terrain":
map_provider = "Esri.WorldTopoMap"
else:
map_provider = background

# Attempt to resolve provider string (e.g., "Esri.WorldImagery") to object
source_provider = map_provider
if isinstance(map_provider, str):
try:
p = contextily.providers
for key in map_provider.split("."):
p = p[key]
source_provider = p
except (KeyError, AttributeError) as e:
raise ValueError(
f"Invalid map provider '{background}'. "
f"The provider '{map_provider}' could not be found in contextily.providers. "
f"Please check the provider name or use one of the built-in options: "
f"'satellite', 'street', or 'terrain'."
) from e

return source_provider

def _get_background_map(self, background, xlim, ylim):
"""
Helper method to get the background map for the Monte Carlo analysis.

Parameters
----------
background : str, optional
Type of background map to automatically download and display.
Options: "satellite" (uses Esri.WorldImagery)
"street" (uses OpenStreetMap.Mapnik)
"terrain" (uses Esri.WorldTopoMap)
or any contextily provider name (e.g., "CartoDB.Positron").
xlim : tuple
Limits of the x-axis. Default is (-3000, 3000). Values in meters.
ylim : tuple
Limits of the y-axis. Default is (-3000, 3000). Values in meters.

Returns
-------
bg : ndarray
Image as a 3D array of RGB values
extent : tuple
Bounding box [minX, maxX, minY, maxY] of the returned image

Raises
------
ImportError
If the contextily library is not installed.
RuntimeError
If unable to fetch the background map from the provider.
"""
if background is None:
return None, None

contextily = import_optional_dependency("contextily")

origin_lat, origin_lon, earth_radius = self._get_environment_coordinates()
source_provider = self._resolve_map_provider(background, contextily)
local_extent = [xlim[0], xlim[1], ylim[0], ylim[1]]
west, south, east, north = convert_local_extent_to_wgs84(
local_extent, origin_lat, origin_lon, earth_radius
)

try:
bg, mercator_extent = contextily.bounds2img(
west, south, east, north, source=source_provider, ll=True
)
except ValueError as e:
raise ValueError(
f"Input coordinates or zoom level are invalid.\n"
f" - Provided bounds: W={west:.6f}, S={south:.6f}, E={east:.6f}, N={north:.6f}\n"
f" - Provider: {source_provider}\n"
f" - Tip: Ensure West < East and South < North.\n"
f" - Tip: Ensure coordinates are within Web Mercator limits (approx +/-85 lat).\n"
f"Original error: {str(e)}"
) from e

except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError) as e:
raise ConnectionError(
f"Network error while fetching tiles from provider '{background}'.\n"
f" - Provider: {source_provider}\n"
f" - Status: Check your internet connection.\n"
f" - The tile server might be down or blocking requests (rate limited).\n"
f" - Original error: {str(e)}"
) from e

except UnidentifiedImageError as e:
raise RuntimeError(
f"The provider '{background}' returned invalid image data.\n"
f" - Provider: {source_provider}\n"
f" - Cause: This often happens when the API requires a key/token that is missing or invalid.\n"
f" - Result: The server likely returned an HTML error page instead of a PNG/JPG."
f" - Original error: {str(e)}"
) from e

except Exception as e:
raise RuntimeError(
f"An unexpected error occurred while generating the map.\n"
f" - Bounds: {west:.6f}, {south:.6f}, {east:.6f}, {north:.6f}\n"
f" - Provider: {source_provider}\n"
f" - Error Detail: {str(e)}"
) from e
local_extent = convert_mercator_extent_to_local(
mercator_extent, origin_lat, origin_lon, earth_radius
)

return bg, local_extent

# pylint: disable=too-many-statements
def ellipses(
self,
image=None,
background=None,
actual_landing_point=None,
perimeter_size=3000,
xlim=(-3000, 3000),
Expand All @@ -31,6 +222,14 @@ def ellipses(
----------
image : str, optional
Path to the background image, usually a map of the launch site.
If both `image` and `background` are provided, `image` takes precedence.
background : str, optional
Type of background map to automatically download and display.
Options: "satellite" (uses Esri.WorldImagery)
"street" (uses OpenStreetMap.Mapnik)
"terrain" (uses Esri.WorldTopoMap)
or any contextily provider name (e.g., "CartoDB.Positron").
If both `image` and `background` are provided, `image` takes precedence.
actual_landing_point : tuple, optional
Actual landing point of the rocket in (x, y) meters.
perimeter_size : int, optional
Expand All @@ -48,17 +247,20 @@ def ellipses(
None
"""

imageio = import_optional_dependency("imageio")

# Import background map
if image is not None:
imageio = import_optional_dependency("imageio")
try:
img = imageio.imread(image)
except FileNotFoundError as e:
raise FileNotFoundError(
"The image file was not found. Please check the path."
) from e

bg, local_extent = None, None
if image is None and background is not None:
bg, local_extent = self._get_background_map(background, xlim, ylim)

try:
apogee_x = np.array(self.monte_carlo.results["apogee_x"])
apogee_y = np.array(self.monte_carlo.results["apogee_y"])
Expand Down Expand Up @@ -132,7 +334,6 @@ def ellipses(
ax.text(0, 1, "North", va="top", ha="left", transform=north_south_offset)
ax.set_ylabel("Y (m)")
ax.set_xlabel("X (m)")

# Add background image to plot
# TODO: In the future, integrate with other libraries to plot the map (e.g. cartopy, ee, etc.)
# You can translate the basemap by changing dx and dy (in meters)
Expand All @@ -150,10 +351,15 @@ def ellipses(
],
)

elif bg is not None and local_extent is not None:
plt.imshow(bg, extent=local_extent, zorder=0, interpolation="bilinear")

plt.axhline(0, color="black", linewidth=0.5)
plt.axvline(0, color="black", linewidth=0.5)
plt.xlim(*xlim)
plt.ylim(*ylim)
# Set equal aspect ratio to ensure consistent display regardless of background
ax.set_aspect("equal")

if save:
plt.savefig(
Expand Down Expand Up @@ -294,6 +500,7 @@ def ellipses_comparison(
self,
other_monte_carlo,
image=None,
background=None,
perimeter_size=3000,
xlim=(-3000, 3000),
ylim=(-3000, 3000),
Expand All @@ -308,6 +515,13 @@ def ellipses_comparison(
MonteCarlo object which the current one will be compared to.
image : str, optional
Path to the background image, usually a map of the launch site.
background : str, optional
Type of background map to automatically download and display.
Options: "satellite" (uses Esri.WorldImagery)
"street" (uses OpenStreetMap.Mapnik)
"terrain" (uses Esri.WorldTopoMap)
or any contextily provider name (e.g., "CartoDB.Positron").
If both `image` and `background` are provided, `image` takes precedence.
perimeter_size : int, optional
Size of the perimeter to be plotted. Default is 3000.
xlim : tuple, optional
Expand All @@ -322,17 +536,21 @@ def ellipses_comparison(
-------
None
"""
imageio = import_optional_dependency("imageio")

# Import background map
if image is not None:
imageio = import_optional_dependency("imageio")
try:
img = imageio.imread(image)
except FileNotFoundError as e: # pragma no cover
raise FileNotFoundError(
"The image file was not found. Please check the path."
) from e

bg, local_extent = None, None
if image is None and background is not None:
bg, local_extent = self._get_background_map(background, xlim, ylim)

try:
original_apogee_x = np.array(self.monte_carlo.results["apogee_x"])
original_apogee_y = np.array(self.monte_carlo.results["apogee_y"])
Expand Down Expand Up @@ -453,11 +671,14 @@ def ellipses_comparison(
perimeter_size - dy,
],
)
elif bg is not None and local_extent is not None:
plt.imshow(bg, extent=local_extent, zorder=0, interpolation="bilinear")

plt.axhline(0, color="black", linewidth=0.5)
plt.axvline(0, color="black", linewidth=0.5)
plt.xlim(*xlim)
plt.ylim(*ylim)
ax.set_aspect("equal")

if save:
plt.savefig(
Expand Down
Loading
Loading