Skip to content

Commit a25fd25

Browse files
authored
Enh/automatically download images (#896)
* ENH: Add contextily as a dependency for Monte Carlo simulations in pyproject.toml and requirements-optional.txt * ENH: Add background map functionality to Monte Carlo plots - Introduced a new method `_get_background_map` to fetch and display background maps using contextily. - Added support for various map types: "satellite", "street", "terrain", and custom contextily providers. - Updated `ellipses` and comparison methods to integrate background maps, enhancing visualization capabilities. - Included error handling and warnings for missing dependencies and attributes. - See issue #890 * DOC: Enhance documentation for _get_background_map method in Monte Carlo plots - Added detailed docstring for the _get_background_map method, outlining parameters, return values, and background map options. - Clarified usage of background types including "satellite", "street", "terrain", and contextily providers. * MNT: Move imageio import to conditional block in _MonteCarloPlots class - Moved the import of imageio to be conditional upon the presence of an image, improving dependency management. - This change ensures that imageio is only imported when necessary, optimizing performance and reducing unnecessary imports. * TST: Add unit tests for ellipses background functionality in Monte Carlo plots - Introduced a new test file to validate the behavior of the `ellipses` method with various background options including None, satellite, street, terrain, and custom providers. - Implemented tests to ensure proper error handling when the MonteCarlo object lacks necessary attributes. - Added checks for warnings when contextily is not installed and verified that images take precedence over backgrounds. * TST: Add integration test for Monte Carlo background map options at Kennedy Space Center - Introduced a new test script to visualize and validate various background map options for Monte Carlo simulations at Kennedy Space Center. - The script generates simulated data and tests background options including None, satellite, street, terrain, and custom providers, saving the results as images. * DOC: Updated CHANGELOG.md * STY: Reformat with black * MNT: Fix wrong usage for set_aspect in _MonteCarloPlots.ellipses_comparison * MNT: Refactor _get_background_map in _MonteCarloPlots class - Removed unnecessary else statements and improved indentation issues. * MNT: Move mercator_to_wgs84 from_MonteCarloPlots class to tools - Removed the local definition of mercator_to_wgs84 and replaced it with an import from tools. - Updated calls to mercator_to_wgs84 to include the earth_radius parameter for improved accuracy. * MNT: Decompose _get_background_map and move utilities to tools.py - Refactors `_get_background_map` into modular functions and extracts shared utility logic into `tools.py`. This significantly improves maintainability and separation of concerns. * BUG: Fix map alignment by enforcing standard Earth radius in plots - Previously, `_get_environment_coordinates` retrieved the Earth radius directly from the environment object. When simulations utilized a custom or local Earth radius for high-precision physics, this value was incorrectly applied to the map projection calculations. - Since background map providers (like Contextily) rely on the standard Web Mercator projection (based on ~6,378,137m), using a non-standard radius caused the background map to be shifted or scaled incorrectly relative to the scatter plot data. - This change forces the plotting module to use the standard WGS84 radius. This ensures visual accuracy for map overlays without affecting the physical integrity of the simulation data itself. * TST: Add Kennedy Space Center environment fixture - Add example_kennedy_env fixture to replace create_kennedy_environment helper function in tests. This follows the same pattern as other environment fixtures and improves test consistency. - Make test_all_background_options() a a parametrized test. - Remove main from test * ENH: Improve error handling and documentation for automatically download background - Enhanced error messages for missing or invalid map providers, providing clearer guidance on potential issues. - Removed warnings for missing contextily library and replaced them with exceptions to enforce required dependencies. - Updated docstring to include raised exceptions and clarify the function's behavior when fetching background maps. * TST: Enhance Monte Carlo background map unit tests - Refactored tests to improve error handling for missing environment attributes and invalid map providers. - Replaced warnings with exceptions for missing dependencies, ensuring stricter validation. - Added assertions to verify error messages for better clarity on issues encountered during background map fetching. - Introduced new tests for handling network errors and invalid map provider scenarios. * TST: Refactor Monte Carlo plot tests with mock class - Introduced a MockMonteCarlo class to simulate Monte Carlo data for testing background map options without real simulations. - Updated tests to utilize the MockMonteCarlo, improving clarity and maintainability. - Simplified environment handling in tests by creating a SimpleEnvironment class for latitude and longitude attributes. - Enhanced error handling and assertions in tests for various background map scenarios. * DOC: Add background map options to documentation - Updated the user documentation for the Monte Carlo simulations to include details on the new ``background`` parameter for displaying various map types. - Provided examples for using satellite, street, and terrain maps, along with instructions for listing available providers. * DOC: Update Monte Carlo analysis notebook with background parameter * REV: Remove background documents form mrs.rst * TST: Refactor Monte Carlo plot tests to remove cleanup function - Since we switched to MockMonteCarlo for testing. Since it does not inherit MonteCarlo.__init__(), no files that need cleanup are produced. - Removed the `_post_test_file_cleanup` function to streamline test cases. - Updated tests to directly handle file cleanup for test_ellipses_background_save. * TST: Enhance Monte Carlo plot tests with file cleanup - Added file cleanup functionality to the Monte Carlo plot tests to ensure generated files are removed after execution. * DOC: Update docstring for background map provider resolution - Enhanced the docstring for the background map provider function to include details on potential ValueError exceptions. * ENH: Improve error handling in Monte Carlo background map fetching - Enhanced exception handling for various error scenarios when fetching background map tiles, including invalid coordinates, network issues, and image data errors. - Added detailed error messages to guide users on potential causes and solutions for encountered issues. * TST: Parameterize tests for bounds2img failure scenarios - Introduced parameterized tests to cover various exception types raised during background map fetching, including ValueError, ConnectionError, and RuntimeError. - Enhanced assertions to verify specific error messages, improving clarity on the nature of failures encountered. - Updated the test for bounds2img to handle different network and image data errors, ensuring comprehensive coverage of edge cases. * TST: Parameterize background map option tests in Monte Carlo plots - Introduced parameterization for the `test_all_background_options` function to streamline testing of various background map options. - Enhanced the test structure by removing hardcoded background options and utilizing pytest's parameterization feature for improved clarity and maintainability. - Updated docstring to reflect new parameters and their usage in the test. * MNT: Formatting monte_carlo_class_usage.ipynb with ruff * TST: Refactor imports in Monte Carlo plot tests for consistency - Moved import statements for numpy and rocketpy.tools to the top of the test functions to adhere to best practices and improve readability. - Added pylint disable comments for imports outside of the top-level to maintain code quality standards. * TST: Skip tests requiring contextily in Monte Carlo plot tests - Added pytest.importorskip for contextily in both integration and unit test files to ensure tests are only run if the required library is installed. * TST: Update contextily import in Monte Carlo plot tests - Replaced direct import of contextily with pytest.importorskip to ensure tests are skipped if the library is not available, enhancing test robustness. * Update contextily dependency in pyproject.toml and requirements-optional.txt - Modified contextily dependency in pyproject.toml to conditionally require it for Python versions below 3.14. * DOC: Update monte_carlo_class_usage.ipynb to note about contextily issue on python 3.14
1 parent eaa9181 commit a25fd25

File tree

8 files changed

+1471
-93
lines changed

8 files changed

+1471
-93
lines changed

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
3131
Attention: The newest changes should be on top -->
3232

3333
### Added
34-
34+
- ENH: Add background map auto download functionality to Monte Carlo plots [#896](https://github.com/RocketPy-Team/RocketPy/pull/896)
3535
- MNT: net thrust addition to 3 dof in flight class [#907] (https://github.com/RocketPy-Team/RocketPy/pull/907)
3636
- ENH: 3-dof lateral motion improvement [#883](https://github.com/RocketPy-Team/RocketPy/pull/883)
3737
- ENH: Add multi-dimensional drag coefficient support (Cd as function of M, Re, α) [#875](https://github.com/RocketPy-Team/RocketPy/pull/875)

docs/notebooks/monte_carlo_analysis/monte_carlo_class_usage.ipynb

Lines changed: 516 additions & 87 deletions
Large diffs are not rendered by default.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ monte-carlo = [
6565
"multiprocess>=0.70",
6666
"statsmodels",
6767
"prettytable",
68+
"contextily>=1.0.0; python_version < '3.14'",
6869
]
6970

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

rocketpy/plots/monte_carlo_plots.py

Lines changed: 226 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
11
from pathlib import Path
2+
import urllib
23

4+
from PIL import UnidentifiedImageError
35
import matplotlib.pyplot as plt
46
import numpy as np
57
from matplotlib.transforms import offset_copy
68

7-
from ..tools import generate_monte_carlo_ellipses, import_optional_dependency
9+
from ..tools import (
10+
convert_local_extent_to_wgs84,
11+
convert_mercator_extent_to_local,
12+
generate_monte_carlo_ellipses,
13+
import_optional_dependency,
14+
)
815
from .plot_helpers import show_or_save_plot
916

1017

@@ -14,10 +21,194 @@ class _MonteCarloPlots:
1421
def __init__(self, monte_carlo):
1522
self.monte_carlo = monte_carlo
1623

24+
def _get_environment_coordinates(self):
25+
"""Get origin coordinates and earth radius from the environment.
26+
27+
Returns
28+
-------
29+
tuple[float, float, float]
30+
A tuple containing (origin_lat, origin_lon, earth_radius).
31+
32+
Raises
33+
------
34+
ValueError
35+
If MonteCarlo object doesn't have an environment attribute, or if
36+
environment doesn't have latitude and longitude attributes.
37+
"""
38+
if not hasattr(self.monte_carlo, "environment"):
39+
raise ValueError(
40+
"MonteCarlo object must have an 'environment' attribute "
41+
"for automatically fetching the background map."
42+
)
43+
env = self.monte_carlo.environment
44+
if not hasattr(env, "latitude") or not hasattr(env, "longitude"):
45+
raise ValueError(
46+
"Environment must have 'latitude' and 'longitude' attributes "
47+
"for automatically fetching the background map."
48+
)
49+
50+
# Handle both StochasticEnvironment (which stores as lists) and
51+
# Environment (which stores as scalars)
52+
origin_lat = env.latitude
53+
origin_lon = env.longitude
54+
if isinstance(origin_lat, (list, tuple)):
55+
origin_lat = origin_lat[0]
56+
if isinstance(origin_lon, (list, tuple)):
57+
origin_lon = origin_lon[0]
58+
59+
# We enforce the standard WGS84 Earth radius (approx. 6,378,137 m) for
60+
# visualization purposes. Background map providers (e.g., via Contextily)
61+
# typically use Web Mercator (EPSG:3857), which assumes this standard radius.
62+
# Using a custom local radius here—even if used in the physics simulation—would
63+
# cause projection mismatches, resulting in the map being offset or scaled
64+
# incorrectly relative to the data points.
65+
earth_radius = 6378137.0
66+
67+
return origin_lat, origin_lon, earth_radius
68+
69+
def _resolve_map_provider(self, background, contextily):
70+
"""Resolve the map provider string to a contextily provider object.
71+
72+
Parameters
73+
----------
74+
background : str
75+
Type of background map. Options: "satellite", "street", "terrain",
76+
or any contextily provider name (e.g., "CartoDB.Positron").
77+
contextily : module
78+
The contextily module.
79+
80+
Returns
81+
-------
82+
object
83+
The resolved contextily provider object.
84+
85+
Raises
86+
------
87+
ValueError
88+
If the map provider string cannot be resolved in contextily.providers.
89+
This may occur if the provider name is invalid. Check the provider name
90+
or use one of the built-in options: 'satellite', 'street', or 'terrain'.
91+
"""
92+
if background == "satellite":
93+
map_provider = "Esri.WorldImagery"
94+
elif background == "street":
95+
map_provider = "OpenStreetMap.Mapnik"
96+
elif background == "terrain":
97+
map_provider = "Esri.WorldTopoMap"
98+
else:
99+
map_provider = background
100+
101+
# Attempt to resolve provider string (e.g., "Esri.WorldImagery") to object
102+
source_provider = map_provider
103+
if isinstance(map_provider, str):
104+
try:
105+
p = contextily.providers
106+
for key in map_provider.split("."):
107+
p = p[key]
108+
source_provider = p
109+
except (KeyError, AttributeError) as e:
110+
raise ValueError(
111+
f"Invalid map provider '{background}'. "
112+
f"The provider '{map_provider}' could not be found in contextily.providers. "
113+
f"Please check the provider name or use one of the built-in options: "
114+
f"'satellite', 'street', or 'terrain'."
115+
) from e
116+
117+
return source_provider
118+
119+
def _get_background_map(self, background, xlim, ylim):
120+
"""
121+
Helper method to get the background map for the Monte Carlo analysis.
122+
123+
Parameters
124+
----------
125+
background : str, optional
126+
Type of background map to automatically download and display.
127+
Options: "satellite" (uses Esri.WorldImagery)
128+
"street" (uses OpenStreetMap.Mapnik)
129+
"terrain" (uses Esri.WorldTopoMap)
130+
or any contextily provider name (e.g., "CartoDB.Positron").
131+
xlim : tuple
132+
Limits of the x-axis. Default is (-3000, 3000). Values in meters.
133+
ylim : tuple
134+
Limits of the y-axis. Default is (-3000, 3000). Values in meters.
135+
136+
Returns
137+
-------
138+
bg : ndarray
139+
Image as a 3D array of RGB values
140+
extent : tuple
141+
Bounding box [minX, maxX, minY, maxY] of the returned image
142+
143+
Raises
144+
------
145+
ImportError
146+
If the contextily library is not installed.
147+
RuntimeError
148+
If unable to fetch the background map from the provider.
149+
"""
150+
if background is None:
151+
return None, None
152+
153+
contextily = import_optional_dependency("contextily")
154+
155+
origin_lat, origin_lon, earth_radius = self._get_environment_coordinates()
156+
source_provider = self._resolve_map_provider(background, contextily)
157+
local_extent = [xlim[0], xlim[1], ylim[0], ylim[1]]
158+
west, south, east, north = convert_local_extent_to_wgs84(
159+
local_extent, origin_lat, origin_lon, earth_radius
160+
)
161+
162+
try:
163+
bg, mercator_extent = contextily.bounds2img(
164+
west, south, east, north, source=source_provider, ll=True
165+
)
166+
except ValueError as e:
167+
raise ValueError(
168+
f"Input coordinates or zoom level are invalid.\n"
169+
f" - Provided bounds: W={west:.6f}, S={south:.6f}, E={east:.6f}, N={north:.6f}\n"
170+
f" - Provider: {source_provider}\n"
171+
f" - Tip: Ensure West < East and South < North.\n"
172+
f" - Tip: Ensure coordinates are within Web Mercator limits (approx +/-85 lat).\n"
173+
f"Original error: {str(e)}"
174+
) from e
175+
176+
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError) as e:
177+
raise ConnectionError(
178+
f"Network error while fetching tiles from provider '{background}'.\n"
179+
f" - Provider: {source_provider}\n"
180+
f" - Status: Check your internet connection.\n"
181+
f" - The tile server might be down or blocking requests (rate limited).\n"
182+
f" - Original error: {str(e)}"
183+
) from e
184+
185+
except UnidentifiedImageError as e:
186+
raise RuntimeError(
187+
f"The provider '{background}' returned invalid image data.\n"
188+
f" - Provider: {source_provider}\n"
189+
f" - Cause: This often happens when the API requires a key/token that is missing or invalid.\n"
190+
f" - Result: The server likely returned an HTML error page instead of a PNG/JPG."
191+
f" - Original error: {str(e)}"
192+
) from e
193+
194+
except Exception as e:
195+
raise RuntimeError(
196+
f"An unexpected error occurred while generating the map.\n"
197+
f" - Bounds: {west:.6f}, {south:.6f}, {east:.6f}, {north:.6f}\n"
198+
f" - Provider: {source_provider}\n"
199+
f" - Error Detail: {str(e)}"
200+
) from e
201+
local_extent = convert_mercator_extent_to_local(
202+
mercator_extent, origin_lat, origin_lon, earth_radius
203+
)
204+
205+
return bg, local_extent
206+
17207
# pylint: disable=too-many-statements
18208
def ellipses(
19209
self,
20210
image=None,
211+
background=None,
21212
actual_landing_point=None,
22213
perimeter_size=3000,
23214
xlim=(-3000, 3000),
@@ -31,6 +222,14 @@ def ellipses(
31222
----------
32223
image : str, optional
33224
Path to the background image, usually a map of the launch site.
225+
If both `image` and `background` are provided, `image` takes precedence.
226+
background : str, optional
227+
Type of background map to automatically download and display.
228+
Options: "satellite" (uses Esri.WorldImagery)
229+
"street" (uses OpenStreetMap.Mapnik)
230+
"terrain" (uses Esri.WorldTopoMap)
231+
or any contextily provider name (e.g., "CartoDB.Positron").
232+
If both `image` and `background` are provided, `image` takes precedence.
34233
actual_landing_point : tuple, optional
35234
Actual landing point of the rocket in (x, y) meters.
36235
perimeter_size : int, optional
@@ -48,17 +247,20 @@ def ellipses(
48247
None
49248
"""
50249

51-
imageio = import_optional_dependency("imageio")
52-
53250
# Import background map
54251
if image is not None:
252+
imageio = import_optional_dependency("imageio")
55253
try:
56254
img = imageio.imread(image)
57255
except FileNotFoundError as e:
58256
raise FileNotFoundError(
59257
"The image file was not found. Please check the path."
60258
) from e
61259

260+
bg, local_extent = None, None
261+
if image is None and background is not None:
262+
bg, local_extent = self._get_background_map(background, xlim, ylim)
263+
62264
try:
63265
apogee_x = np.array(self.monte_carlo.results["apogee_x"])
64266
apogee_y = np.array(self.monte_carlo.results["apogee_y"])
@@ -132,7 +334,6 @@ def ellipses(
132334
ax.text(0, 1, "North", va="top", ha="left", transform=north_south_offset)
133335
ax.set_ylabel("Y (m)")
134336
ax.set_xlabel("X (m)")
135-
136337
# Add background image to plot
137338
# TODO: In the future, integrate with other libraries to plot the map (e.g. cartopy, ee, etc.)
138339
# You can translate the basemap by changing dx and dy (in meters)
@@ -150,10 +351,15 @@ def ellipses(
150351
],
151352
)
152353

354+
elif bg is not None and local_extent is not None:
355+
plt.imshow(bg, extent=local_extent, zorder=0, interpolation="bilinear")
356+
153357
plt.axhline(0, color="black", linewidth=0.5)
154358
plt.axvline(0, color="black", linewidth=0.5)
155359
plt.xlim(*xlim)
156360
plt.ylim(*ylim)
361+
# Set equal aspect ratio to ensure consistent display regardless of background
362+
ax.set_aspect("equal")
157363

158364
if save:
159365
plt.savefig(
@@ -294,6 +500,7 @@ def ellipses_comparison(
294500
self,
295501
other_monte_carlo,
296502
image=None,
503+
background=None,
297504
perimeter_size=3000,
298505
xlim=(-3000, 3000),
299506
ylim=(-3000, 3000),
@@ -308,6 +515,13 @@ def ellipses_comparison(
308515
MonteCarlo object which the current one will be compared to.
309516
image : str, optional
310517
Path to the background image, usually a map of the launch site.
518+
background : str, optional
519+
Type of background map to automatically download and display.
520+
Options: "satellite" (uses Esri.WorldImagery)
521+
"street" (uses OpenStreetMap.Mapnik)
522+
"terrain" (uses Esri.WorldTopoMap)
523+
or any contextily provider name (e.g., "CartoDB.Positron").
524+
If both `image` and `background` are provided, `image` takes precedence.
311525
perimeter_size : int, optional
312526
Size of the perimeter to be plotted. Default is 3000.
313527
xlim : tuple, optional
@@ -322,17 +536,21 @@ def ellipses_comparison(
322536
-------
323537
None
324538
"""
325-
imageio = import_optional_dependency("imageio")
326539

327540
# Import background map
328541
if image is not None:
542+
imageio = import_optional_dependency("imageio")
329543
try:
330544
img = imageio.imread(image)
331545
except FileNotFoundError as e: # pragma no cover
332546
raise FileNotFoundError(
333547
"The image file was not found. Please check the path."
334548
) from e
335549

550+
bg, local_extent = None, None
551+
if image is None and background is not None:
552+
bg, local_extent = self._get_background_map(background, xlim, ylim)
553+
336554
try:
337555
original_apogee_x = np.array(self.monte_carlo.results["apogee_x"])
338556
original_apogee_y = np.array(self.monte_carlo.results["apogee_y"])
@@ -453,11 +671,14 @@ def ellipses_comparison(
453671
perimeter_size - dy,
454672
],
455673
)
674+
elif bg is not None and local_extent is not None:
675+
plt.imshow(bg, extent=local_extent, zorder=0, interpolation="bilinear")
456676

457677
plt.axhline(0, color="black", linewidth=0.5)
458678
plt.axvline(0, color="black", linewidth=0.5)
459679
plt.xlim(*xlim)
460680
plt.ylim(*ylim)
681+
ax.set_aspect("equal")
461682

462683
if save:
463684
plt.savefig(

0 commit comments

Comments
 (0)