Skip to content

Commit 19f9539

Browse files
authored
Remove unnecessary array copies to reduce memory allocations (#933)
* Remove unnecessary array copies to reduce memory allocations - Replace zeros_like + fill NaN with empty + fill NaN in ngjit functions (aspect, slope, classify) to avoid a wasted zeroing pass - Drop redundant .copy() after cupy.asnumpy().astype() in erosion (asnumpy already produces a fresh numpy array) - Drop redundant .copy() after boolean fancy indexing in bump (fancy indexing always returns a copy) - Use .data instead of .values on numpy-backed DataArrays in viewshed and min_observable_height (.values forces numpy conversion) - Use np.asarray instead of np.array for barriers in pathfinding (avoids copying when input is already an ndarray) - Replace np.array(float64_slice, dtype=float64) with .copy() in viewshed (no dtype conversion needed) - Widen test_perlin_gpu tolerance to match fastmath precision (rtol 1e-4, atol 1e-6); consistent with test_perlin_dask_gpu * Add interactive example scripts Six standalone examples demonstrating cost distance, erosion, fire propagation, flood simulation, landslide risk, and watershed delineation.
1 parent 1a86ad1 commit 19f9539

File tree

15 files changed

+2370
-15
lines changed

15 files changed

+2370
-15
lines changed

examples/cost_distance_sim.py

Lines changed: 402 additions & 0 deletions
Large diffs are not rendered by default.

examples/erosion_simulation.py

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
"""
2+
Interactive Hydraulic Erosion Simulation -- Grand Canyon
3+
========================================================
4+
5+
Watch channels carve into real Grand Canyon terrain as simulated
6+
raindrops erode and deposit sediment. Uses a Copernicus 30m DEM tile
7+
downloaded from the public AWS bucket; falls back to synthetic terrain
8+
if rasterio is unavailable or the download fails.
9+
10+
Driven by the xrspatial erosion and terrain modules:
11+
12+
* **erode** -- particle-based hydraulic erosion (Numba-accelerated)
13+
* **slope** -- terrain slope for visual overlay
14+
* **generate_terrain** -- procedural fallback terrain
15+
16+
The simulation pre-computes a fully-eroded result, then animates by
17+
interpolating between the original and eroded surfaces. A diverging
18+
overlay highlights where material was removed (erosion) vs. deposited.
19+
20+
Controls
21+
--------
22+
* **Up / Down** -- step erosion forward / backward
23+
* **Space** -- pause / resume automatic progression
24+
* **R** -- reset to original terrain
25+
* **Q / Escape** -- quit
26+
27+
Requires: xarray, numpy, matplotlib, xrspatial (this repo)
28+
Optional: rasterio (for real DEM download)
29+
"""
30+
31+
from __future__ import annotations
32+
33+
import numpy as np
34+
import xarray as xr
35+
import matplotlib.pyplot as plt
36+
import matplotlib.colors as mcolors
37+
from matplotlib.animation import FuncAnimation
38+
39+
from xrspatial import generate_terrain, slope
40+
from xrspatial.erosion import erode
41+
42+
# -- Tunable parameters -----------------------------------------------------
43+
CELL_SIZE = 30.0 # metres per pixel
44+
EROSION_ITERATIONS = 100_000 # total droplets for the full erosion run
45+
ALPHA_STEP = 0.02 # manual step size (Up/Down keys)
46+
AUTO_SPEED = 0.003 # alpha increment per animation frame
47+
FPS = 30
48+
# ---------------------------------------------------------------------------
49+
50+
51+
def load_dem() -> xr.DataArray:
52+
"""Load a Copernicus 30m DEM subset covering part of the Grand Canyon.
53+
54+
Downloads a windowed region from the public AWS S3 bucket (no auth
55+
required). Falls back to synthetic terrain if rasterio is missing
56+
or the download fails.
57+
"""
58+
try:
59+
import rasterio
60+
from rasterio.windows import Window
61+
62+
url = (
63+
"https://copernicus-dem-30m.s3.amazonaws.com/"
64+
"Copernicus_DSM_COG_10_N36_00_W113_00_DEM/"
65+
"Copernicus_DSM_COG_10_N36_00_W113_00_DEM.tif"
66+
)
67+
68+
print("Downloading Grand Canyon DEM (Copernicus 30m) ...")
69+
with rasterio.open(url) as src:
70+
window = Window(col_off=1800, row_off=2400, width=400, height=300)
71+
data = src.read(1, window=window).astype(np.float64)
72+
nodata = src.nodata
73+
74+
if nodata is not None:
75+
data[data == nodata] = np.nan
76+
77+
h, w = data.shape
78+
dem = xr.DataArray(data, dims=["y", "x"], name="elevation")
79+
dem["y"] = np.linspace(h - 1, 0, h)
80+
dem["x"] = np.linspace(0, w - 1, w)
81+
82+
print(f" Loaded DEM: {dem.shape}, "
83+
f"elevation {np.nanmin(data):.0f} - {np.nanmax(data):.0f} m")
84+
return dem
85+
86+
except Exception as e:
87+
print(f"DEM download failed ({e}), using synthetic terrain")
88+
h, w = 300, 400
89+
xs = np.linspace(0, w * CELL_SIZE, w)
90+
ys = np.linspace(0, h * CELL_SIZE, h)
91+
template = xr.DataArray(
92+
np.zeros((h, w), dtype=np.float32),
93+
dims=["y", "x"],
94+
coords={"y": ys, "x": xs},
95+
)
96+
return generate_terrain(template, zfactor=400, seed=42)
97+
98+
99+
# -- Build the world --------------------------------------------------------
100+
elevation = load_dem()
101+
GRID_H, GRID_W = elevation.shape
102+
103+
print("Computing slope (original terrain) ...")
104+
slope_orig = slope(elevation)
105+
slope_orig_vals = np.nan_to_num(slope_orig.values, nan=0.0)
106+
107+
print(f"Running hydraulic erosion ({EROSION_ITERATIONS:,} droplets) ...")
108+
eroded = erode(elevation, iterations=EROSION_ITERATIONS, seed=42)
109+
110+
print("Computing slope (eroded terrain) ...")
111+
slope_eroded = slope(eroded)
112+
slope_eroded_vals = np.nan_to_num(slope_eroded.values, nan=0.0)
113+
114+
# Difference map: negative = material removed, positive = deposited
115+
elev_orig = elevation.values.astype(np.float64)
116+
elev_eroded = eroded.values.astype(np.float64)
117+
diff = elev_eroded - elev_orig
118+
119+
diff_abs_max = max(np.nanmax(np.abs(diff)), 0.01)
120+
print(f" Erosion range: {np.nanmin(diff):.2f} to {np.nanmax(diff):.2f} m")
121+
print(f" Net volume change: {np.nansum(diff) * CELL_SIZE**2:.0f} m^3")
122+
123+
# -- Simulation state -------------------------------------------------------
124+
alpha = 0.0 # 0 = original, 1 = fully eroded
125+
paused = False
126+
auto_play = True
127+
128+
# -- Visualisation -----------------------------------------------------------
129+
130+
# Diverging colourmap for erosion (red) vs deposition (blue)
131+
erosion_cmap = mcolors.LinearSegmentedColormap.from_list("erosion", [
132+
(0.0, (0.8, 0.2, 0.1)), # deep erosion (red)
133+
(0.3, (0.95, 0.5, 0.3)), # moderate erosion (orange)
134+
(0.5, (0.95, 0.95, 0.85)), # neutral (near-white)
135+
(0.7, (0.3, 0.55, 0.85)), # moderate deposition (light blue)
136+
(1.0, (0.1, 0.2, 0.7)), # heavy deposition (blue)
137+
])
138+
139+
fig, ax = plt.subplots(figsize=(12, 7))
140+
fig.patch.set_facecolor("black")
141+
ax.set_facecolor("black")
142+
ax.set_title(
143+
"Hydraulic Erosion | Up/Down: step | Space: pause "
144+
"| R: reset | Q: quit",
145+
color="white", fontsize=11,
146+
)
147+
ax.tick_params(colors="white")
148+
149+
# Terrain layer (interpolated between original and eroded)
150+
current_elev = elev_orig.copy()
151+
terrain_img = ax.imshow(
152+
current_elev, cmap=plt.cm.terrain, origin="lower",
153+
aspect="equal", interpolation="bilinear",
154+
)
155+
156+
# Erosion/deposition overlay
157+
erosion_data = np.full((GRID_H, GRID_W), np.nan, dtype=np.float32)
158+
erosion_img = ax.imshow(
159+
erosion_data, cmap=erosion_cmap, origin="lower", aspect="equal",
160+
vmin=-diff_abs_max, vmax=diff_abs_max, alpha=0.55, interpolation="bilinear",
161+
)
162+
163+
status_text = ax.text(
164+
0.01, 0.01, "", transform=ax.transAxes, color="cyan",
165+
fontsize=9, verticalalignment="bottom",
166+
bbox=dict(boxstyle="round,pad=0.3", facecolor="black", alpha=0.7),
167+
)
168+
169+
# Explanation blurb
170+
ax.text(
171+
0.99, 0.99,
172+
"Hydraulic erosion simulates raindrops landing on terrain,\n"
173+
"picking up sediment as they flow downhill, and depositing\n"
174+
"it where the water slows down. Over time this carves\n"
175+
"channels and builds up alluvial fans -- the same process\n"
176+
"that shaped the Grand Canyon over millions of years.\n"
177+
"\n"
178+
"Red/orange = material removed (channel cutting)\n"
179+
"Blue = material deposited (sediment accumulation)\n"
180+
"White = little change",
181+
transform=ax.transAxes, color="white", fontsize=8,
182+
verticalalignment="top", horizontalalignment="right",
183+
bbox=dict(boxstyle="round,pad=0.4", facecolor="black", alpha=0.6),
184+
)
185+
186+
187+
def update_frame(frame: int):
188+
"""Advance erosion interpolation and redraw."""
189+
global alpha
190+
191+
if auto_play and not paused:
192+
alpha = min(alpha + AUTO_SPEED, 1.0)
193+
194+
# Interpolate terrain between original and fully eroded
195+
current = elev_orig + alpha * diff
196+
terrain_img.set_data(current)
197+
198+
# Update colour limits to track changing elevation range
199+
vmin = np.nanmin(current)
200+
vmax = np.nanmax(current)
201+
terrain_img.set_clim(vmin=vmin, vmax=vmax)
202+
203+
# Erosion/deposition overlay -- scale by current alpha
204+
if alpha > 0:
205+
current_diff = alpha * diff
206+
erosion_img.set_data(current_diff)
207+
overlay_max = max(np.nanmax(np.abs(current_diff)), 0.01)
208+
erosion_img.set_clim(vmin=-overlay_max, vmax=overlay_max)
209+
erosion_img.set_alpha(min(0.55, alpha * 2))
210+
else:
211+
erosion_img.set_data(np.full((GRID_H, GRID_W), np.nan))
212+
erosion_img.set_alpha(0.0)
213+
214+
# Interpolate slope for stats
215+
current_slope = slope_orig_vals + alpha * (slope_eroded_vals - slope_orig_vals)
216+
217+
# Stats
218+
current_diff_vals = alpha * diff
219+
eroded_cells = np.sum(current_diff_vals < -0.01)
220+
deposited_cells = np.sum(current_diff_vals > 0.01)
221+
max_erosion = np.nanmin(current_diff_vals)
222+
max_deposition = np.nanmax(current_diff_vals)
223+
mean_slope = np.nanmean(current_slope)
224+
225+
state = "PAUSED" if paused else ("ERODING" if alpha < 1.0 else "COMPLETE")
226+
status_text.set_text(
227+
f"{state} | progress: {100 * alpha:.1f}% | "
228+
f"eroded: {eroded_cells:,} cells | deposited: {deposited_cells:,} cells | "
229+
f"max cut: {max_erosion:.2f} m | max fill: {max_deposition:.2f} m | "
230+
f"mean slope: {mean_slope:.1f} deg"
231+
)
232+
233+
return (terrain_img, erosion_img, status_text)
234+
235+
236+
def on_key(event):
237+
"""Keyboard: erosion step, pause, reset, quit."""
238+
global alpha, paused, auto_play
239+
240+
if event.key == "up":
241+
auto_play = False
242+
alpha = min(alpha + ALPHA_STEP, 1.0)
243+
elif event.key == "down":
244+
auto_play = False
245+
alpha = max(alpha - ALPHA_STEP, 0.0)
246+
elif event.key == " ":
247+
paused = not paused
248+
print(" Paused" if paused else " Resumed")
249+
elif event.key == "r":
250+
alpha = 0.0
251+
auto_play = True
252+
paused = False
253+
print(" Reset")
254+
elif event.key in ("q", "escape"):
255+
plt.close(fig)
256+
257+
258+
fig.canvas.mpl_connect("key_press_event", on_key)
259+
260+
anim = FuncAnimation(
261+
fig, update_frame, interval=1000 // FPS, blit=False, cache_frame_data=False,
262+
)
263+
264+
plt.tight_layout()
265+
print("\nReady -- erosion will progress automatically. "
266+
"Press Up/Down for manual control.\n")
267+
plt.show()

0 commit comments

Comments
 (0)