Skip to content

Commit b3ee795

Browse files
authored
Merge pull request #114 from GeoOcean/88-document-and-test-greensurge-and-ocsmesh
88 document and test greensurge and ocsmesh
2 parents 87c45c9 + 2eb10b5 commit b3ee795

File tree

1 file changed

+258
-0
lines changed

1 file changed

+258
-0
lines changed
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
import math
2+
from io import BytesIO
3+
from typing import Tuple
4+
5+
import cartopy.crs as ccrs
6+
import cartopy.feature as cfeature
7+
import matplotlib.pyplot as plt
8+
import requests
9+
from PIL import Image
10+
11+
from ..constants import EARTH_RADIUS
12+
13+
EARTH_RADIUS_M = EARTH_RADIUS * 1000 # Convert km to m
14+
15+
16+
def deg2num(lat: float, lon: float, zoom: int) -> Tuple[int, int]:
17+
"""
18+
Converts geographic coordinates to tile numbers for a given zoom level.
19+
20+
Parameters
21+
----------
22+
lat : float
23+
Latitude in degrees.
24+
lon : float
25+
Longitude in degrees.
26+
zoom : int
27+
Zoom level.
28+
29+
Returns
30+
-------
31+
xtile : int
32+
Tile number in x-direction.
33+
ytile : int
34+
Tile number in y-direction.
35+
"""
36+
37+
lat_rad = math.radians(lat)
38+
n = 2.0**zoom
39+
xtile = int((lon + 180.0) / 360.0 * n)
40+
ytile = int(
41+
(1.0 - math.log(math.tan(lat_rad) + 1 / math.cos(lat_rad)) / math.pi) / 2.0 * n
42+
)
43+
44+
return xtile, ytile
45+
46+
47+
def num2deg(xtile: int, ytile: int, zoom: int) -> Tuple[float, float]:
48+
"""
49+
Converts tile numbers back to geographic coordinates.
50+
51+
Parameters
52+
----------
53+
xtile : int
54+
Tile number in x-direction.
55+
ytile : int
56+
Tile number in y-direction.
57+
zoom : int
58+
Zoom level.
59+
60+
Returns
61+
-------
62+
lat : float
63+
Latitude in degrees.
64+
lon : float
65+
Longitude in degrees.
66+
"""
67+
68+
n = 2.0**zoom
69+
lon = xtile / n * 360.0 - 180.0
70+
lat_rad = math.atan(math.sinh(math.pi * (1 - 2 * ytile / n)))
71+
lat = math.degrees(lat_rad)
72+
73+
return lat, lon
74+
75+
76+
def lonlat_to_webmercator(lon: float, lat: float) -> Tuple[float, float]:
77+
"""
78+
Converts lon/lat to Web Mercator projection coordinates in meters.
79+
80+
Parameters
81+
----------
82+
lon : float
83+
Longitude in degrees.
84+
lat : float
85+
Latitude in degrees.
86+
87+
Returns
88+
-------
89+
x : float
90+
X coordinate in meters.
91+
y : float
92+
Y coordinate in meters.
93+
"""
94+
95+
x = EARTH_RADIUS_M * math.radians(lon)
96+
y = EARTH_RADIUS_M * math.log(math.tan((math.pi / 4) + math.radians(lat) / 2))
97+
98+
return x, y
99+
100+
101+
def tile_bounds_meters(
102+
x_start: int, y_start: int, x_end: int, y_end: int, zoom: int
103+
) -> Tuple[float, float, float, float]:
104+
"""
105+
Computes the bounding box of the tile region in Web Mercator meters.
106+
107+
Returns
108+
-------
109+
xmin, ymin, xmax, ymax : float
110+
Bounding box in meters (Web Mercator projection).
111+
"""
112+
113+
lat1, lon1 = num2deg(x_start, y_start, zoom)
114+
lat2, lon2 = num2deg(x_end + 1, y_end + 1, zoom)
115+
x1, y1 = lonlat_to_webmercator(lon1, lat2)
116+
x2, y2 = lonlat_to_webmercator(lon2, lat1)
117+
118+
return x1, y1, x2, y2
119+
120+
121+
def calculate_zoom(
122+
lon_min: float, lon_max: float, display_width_px: int = 1024, tile_size: int = 256
123+
) -> int:
124+
"""
125+
Automatically estimates an appropriate zoom level for the bounding box.
126+
127+
Returns
128+
-------
129+
zoom : int
130+
Estimated zoom level.
131+
"""
132+
133+
WORLD_MAP_WIDTH = 2 * math.pi * EARTH_RADIUS_M
134+
x1, _ = lonlat_to_webmercator(lon_min, 0)
135+
x2, _ = lonlat_to_webmercator(lon_max, 0)
136+
region_width_m = abs(x2 - x1)
137+
meters_per_pixel_desired = region_width_m / display_width_px
138+
zoom = math.log2(WORLD_MAP_WIDTH / (tile_size * meters_per_pixel_desired))
139+
140+
return int(round(zoom))
141+
142+
143+
def get_cartopy_scale(zoom: int) -> str:
144+
"""
145+
Select appropriate cartopy feature scale based on zoom level.
146+
147+
Parameters
148+
----------
149+
zoom : int
150+
Web Mercator zoom level.
151+
152+
Returns
153+
-------
154+
scale : str
155+
One of '110m', '50m', or '10m'.
156+
"""
157+
158+
if zoom >= 9:
159+
return "10m"
160+
elif zoom >= 6:
161+
return "50m"
162+
else:
163+
return "110m"
164+
165+
166+
def plot_usgs_raster_map(
167+
lat_min: float,
168+
lat_max: float,
169+
lon_min: float,
170+
lon_max: float,
171+
zoom: int = None,
172+
verbose: bool = True,
173+
mask_ocean: bool = False,
174+
add_features: bool = True,
175+
display_width_px: int = 1024,
176+
) -> Tuple[plt.Figure, plt.Axes]:
177+
"""
178+
Downloads and displays a USGS raster map for the given bounding box.
179+
180+
Parameters
181+
----------
182+
lat_min : float
183+
Minimum latitude of the region.
184+
lat_max : float
185+
Maximum latitude of the region.
186+
lon_min : float
187+
Minimum longitude of the region.
188+
lon_max : float
189+
Maximum longitude of the region.
190+
display_width_px : int, optional
191+
Approximate pixel width for display (default is 1024).
192+
"""
193+
194+
tile_size = 256
195+
if zoom is None:
196+
zoom = calculate_zoom(lon_min, lon_max, display_width_px, tile_size)
197+
if verbose:
198+
print(f"Auto-selected zoom level: {zoom}")
199+
200+
x_start, y_start = deg2num(lat_max, lon_min, zoom)
201+
x_end, y_end = deg2num(lat_min, lon_max, zoom)
202+
width = x_end - x_start + 1
203+
height = y_end - y_start + 1
204+
205+
map_img = Image.new("RGB", (width * tile_size, height * tile_size))
206+
tile_url = "https://basemap.nationalmap.gov/arcgis/rest/services/USGSImageryOnly/MapServer/tile/{z}/{y}/{x}"
207+
208+
for x in range(x_start, x_end + 1):
209+
for y in range(y_start, y_end + 1):
210+
url = tile_url.format(z=zoom, x=x, y=y)
211+
try:
212+
response = requests.get(url, timeout=10)
213+
tile = Image.open(BytesIO(response.content))
214+
map_img.paste(
215+
tile, ((x - x_start) * tile_size, (y - y_start) * tile_size)
216+
)
217+
except Exception as e:
218+
print(f"Error fetching tile {x},{y}: {e}")
219+
220+
xmin, ymin, xmax, ymax = tile_bounds_meters(x_start, y_start, x_end, y_end, zoom)
221+
222+
crs_tiles = ccrs.Mercator.GOOGLE
223+
fig = plt.figure(figsize=(12, 8))
224+
ax = plt.axes(projection=crs_tiles)
225+
ax.set_extent([xmin, xmax, ymin, ymax], crs=crs_tiles)
226+
227+
ax.imshow(
228+
map_img, origin="upper", extent=[xmin, xmax, ymin, ymax], transform=crs_tiles
229+
)
230+
scale = get_cartopy_scale(zoom)
231+
if verbose:
232+
print(f"Using Cartopy scale: {scale}")
233+
234+
if add_features:
235+
ax.add_feature(cfeature.BORDERS.with_scale(scale), linewidth=0.8)
236+
ax.add_feature(cfeature.COASTLINE.with_scale(scale))
237+
ax.add_feature(cfeature.STATES.with_scale(scale), linewidth=0.5)
238+
239+
if mask_ocean:
240+
ax.add_feature(cfeature.OCEAN.with_scale(scale), facecolor="w", zorder=3)
241+
242+
return fig, ax
243+
244+
245+
if __name__ == "__main__":
246+
lat_min, lat_max = 35.406, 54.372
247+
lon_min, lon_max = -13.148, 12.325
248+
fig, ax = plot_usgs_raster_map(
249+
lat_min,
250+
lat_max,
251+
lon_min,
252+
lon_max,
253+
zoom=None,
254+
verbose=True,
255+
add_features=False,
256+
mask_ocean=True,
257+
)
258+
fig.show()

0 commit comments

Comments
 (0)