Automating hillshade and slope analysis for wind turbine siting
When scaling terrain preprocessing for utility-scale wind development, automated hillshade and slope extraction fails at the intersection of memory limits, coordinate reference system (CRS) unit mismatches, and windowed raster boundary discontinuities. The symptom is consistent: a pipeline runs cleanly on a small test extent but, on a regional 1 m LiDAR or 30 m SRTM mosaic covering 500+ km², either crashes with MemoryError or writes artificial 90° slope cliffs along every tile seam. This page is the slope-and-aspect derivative stage of the broader Terrain & Shadow Analysis Pipelines workflow, and it isolates the root causes, surfaces them with a pre-flight check, and delivers a chunked, CRS-validated fix that is safe to run inside a CI/CD gate before a siting model ever consumes the output.
Scenario: MemoryError and 90° slope cliffs at tile seams
Two distinct failures present from the same batch script and break the same pipeline stage — the slope/aspect derivative step that feeds turbine micro-siting and access-road routing:
- On large mosaics,
numpy.gradientover a full in-memory array raisesMemoryError(or the kernel silently kills the worker) once the DEM exceeds available RAM on a cloud runner. - On naively tiled DEMs, slope drops to zero or spikes to a hard 90° wall along every internal tile boundary, because the derivative kernel never sees the neighbouring tile’s edge pixels.
Both must be solved together: chunking fixes the memory ceiling but introduces the seam artifact unless each window carries an overlap halo. The minimal failing pattern looks reasonable and is exactly what most teams ship first:
import rasterio
import numpy as np
# Fails on large DEMs: loads entire array, ignores CRS units, no window padding
with rasterio.open("dem_1m.tif") as src:
dem = src.read(1)
# Assumes 1:1 horizontal/vertical units (false for EPSG:4326)
grad_y, grad_x = np.gradient(dem.astype(np.float32))
slope_deg = np.degrees(np.arctan(np.sqrt(grad_x**2 + grad_y**2)))
This produces three immediate defects: MemoryError on DEMs larger than available RAM, slope cliffs at any tile boundary if later chunked without overlap, and physically wrong slope magnitudes whenever the horizontal units are degrees rather than metres.
Root-cause analysis
Three compounding causes drive the failure, each mapping to a specific stage of the fix:
- CRS unit mismatch. A geographic CRS such as EPSG:4326 expresses horizontal distance in degrees while elevation stays in metres, so the gradient ratios and are dimensionally invalid. Slope collapses toward zero or inflates with latitude. Computing slope on degree-spaced grids is the single most common silent error, which is why a coordinate reference system reprojection to a metric UTM zone (e.g. EPSG:32610) must happen before any derivative is taken — the same EPSG:4326 to projected-grid alignment discipline used across the resource-modeling pipeline.
- Windowed boundary artifacts. A 3×3 derivative kernel needs one ring of neighbouring pixels. A plain
rasteriowindow with no padding starves the edge rows and columns, so the kernel reads off-window zeros and emits seams. The defect is invisible at small scale and only appears once tiling kicks in. - Memory fragmentation. Reading the full DEM, then allocating
float64copies fornumpy.gradientandscipy.ndimageintermediates, multiplies peak RSS several times over the on-disk size and exhausts constrained runners.
The corrected slope formula, applied on a metric grid with cell size , is:
Pre-flight validation
Run a cheap check that surfaces the two structural root causes — non-metric CRS and a chunk size that will swap — before the expensive windowed pass starts. It opens only the header and metadata, so it is safe to call in a CI/CD gate.
import rasterio
import numpy as np
def preflight_dem(input_path: str, chunk_size: int = 2048,
max_window_bytes: int = 512 * 1024 * 1024) -> None:
"""Fail fast on the two structural causes of slope/hillshade failure."""
with rasterio.open(input_path) as src:
if not src.crs or not src.crs.is_projected:
raise ValueError(
f"DEM CRS {src.crs} is not projected. Reproject to a metric "
"UTM zone (e.g. EPSG:32610) before slope computation."
)
units = (src.crs.linear_units or "").lower()
if units not in ("metre", "meter", "m"):
raise ValueError(f"CRS linear unit '{units}' is not metres.")
# float64 working copy of one padded window is the memory hot spot
win_px = (chunk_size + 2) ** 2
est_bytes = win_px * np.dtype(np.float64).itemsize * 3 # dem + 2 gradients
if est_bytes > max_window_bytes:
raise ValueError(
f"chunk_size={chunk_size} needs ~{est_bytes // 1_048_576} MB/window; "
f"reduce to fit the {max_window_bytes // 1_048_576} MB budget."
)
print(f"OK: {src.crs} res={src.res} size={src.width}x{src.height}")
Fix implementation
The corrected pipeline enforces a projected CRS, reads each window with a one-pixel overlap halo (boundless=True pads off-extent pixels with NaN), computes slope and aspect from central differences scaled by the true ground cell size, trims the halo, and streams each tile straight to disk. Outputs are float32 slope with nodata=NaN and uint8 hillshade, both DEFLATE-compressed and internally tiled for downstream windowed reads.
import rasterio
import numpy as np
from rasterio.windows import Window
import logging
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
def compute_slope_aspect(dem_window: np.ndarray, cell_size_m: float):
"""Central-difference slope (deg) and aspect (deg, 0-360) on a metric grid."""
grad_y, grad_x = np.gradient(dem_window.astype(np.float64),
cell_size_m, cell_size_m)
slope_deg = np.degrees(np.arctan(np.sqrt(grad_x**2 + grad_y**2)))
aspect_deg = np.degrees(np.arctan2(grad_y, -grad_x)) % 360.0
return slope_deg, aspect_deg
def compute_hillshade(slope_deg, aspect_deg, sun_azimuth=315.0, sun_altitude=45.0):
"""Analytical hillshade, 0-255 uint8, for stakeholder visualization."""
slope_rad, aspect_rad = np.radians(slope_deg), np.radians(aspect_deg)
az_rad, alt_rad = np.radians(sun_azimuth), np.radians(sun_altitude)
hs = 255.0 * (
np.sin(alt_rad) * np.cos(slope_rad) +
np.cos(alt_rad) * np.sin(slope_rad) * np.cos(az_rad - aspect_rad)
)
return np.clip(np.nan_to_num(hs), 0, 255).astype(np.uint8)
def process_dem_chunked(input_path, slope_out, hillshade_out,
chunk_size=2048, overlap=1):
"""Memory-safe, seam-free slope + hillshade over a regional DEM."""
with rasterio.open(input_path) as src:
cell_size_m = src.res[0]
slope_meta = src.meta.copy()
slope_meta.update(driver="GTiff", dtype="float32", count=1,
nodata=np.nan, compress="deflate", tiled=True)
hs_meta = slope_meta.copy()
hs_meta.update(dtype="uint8", nodata=0)
with rasterio.open(slope_out, "w", **slope_meta) as dst_slope, \
rasterio.open(hillshade_out, "w", **hs_meta) as dst_hs:
for row in range(0, src.height, chunk_size):
for col in range(0, src.width, chunk_size):
padded = Window(col - overlap, row - overlap,
chunk_size + 2 * overlap,
chunk_size + 2 * overlap)
dem_chunk = src.read(1, window=padded, boundless=True,
fill_value=np.nan)
slope, aspect = compute_slope_aspect(dem_chunk, cell_size_m)
hs = compute_hillshade(slope, aspect)
slope_w = slope[overlap:-overlap, overlap:-overlap]
hs_w = hs[overlap:-overlap, overlap:-overlap]
write_win = Window(col, row, slope_w.shape[1], slope_w.shape[0])
dst_slope.write(slope_w.astype("float32"), 1, window=write_win)
dst_hs.write(hs_w, 1, window=write_win)
dst_slope.update_tags(sun_azimuth=315.0, sun_altitude=45.0,
cell_size_m=cell_size_m, source_crs=str(src.crs))
logging.info("slope + hillshade written for %s", input_path)
Fallback routing & performance tuning
When the default chunk_size still pressures a constrained runner, step down through these strategies rather than loading the full array:
- Shrink the window. Drop
chunk_sizeto1024or512; peak RSS scales with the window area, so halving the side quarters the working set while the one-pixel halo keeps seams gone. - Cap GDAL’s block cache. Export
GDAL_CACHEMAX=256(MB) sorasterioreads do not balloon the resident set on top of your NumPy arrays. - Pre-build a Cloud-Optimized GeoTIFF. Convert the source DEM to a COG with internal tiling and overviews so each
boundlesswindow is a cheap block read instead of a random-access scan of a striped TIFF. - Use a VRT for multi-tile mosaics. Wrap many adjacent DEM tiles in a
gdalbuildvrtvirtual raster and stream windows out-of-core, avoiding a physical merge into one oversized file. - Choose resampling deliberately if you downsample. Use
average(notbilinear) when reducing resolution before slope, so a single anomalous cell does not smear a false gradient across the kernel.
Downstream validation
Before slope and hillshade reach exclusion-zoning or grid capacity buffer analysis routing, assert structural integrity. This audit returns a pass/fail dict suitable for a CI/CD gate and catches the exact regressions this fix targets: wrong dtype, a non-projected output CRS, nodata bleed, and any residual seam.
import rasterio
import numpy as np
def audit_slope_raster(slope_path: str, max_internal_jump_deg: float = 5.0) -> dict:
"""CI/CD gate: band count, dtype, projected CRS, nodata bleed, seam check."""
report = {}
with rasterio.open(slope_path) as src:
report["band_count_ok"] = src.count == 1
report["dtype_ok"] = src.dtypes[0] == "float32"
report["crs_projected"] = bool(src.crs and src.crs.is_projected)
slope = src.read(1, masked=True)
report["nodata_is_nan"] = src.nodata is None or np.isnan(src.nodata)
report["value_range_ok"] = bool(slope.min() >= 0 and slope.max() <= 90)
# Seam check: large abrupt jumps along interior rows/cols flag bad overlap
d_row = np.abs(np.diff(slope.filled(np.nan), axis=0))
d_col = np.abs(np.diff(slope.filled(np.nan), axis=1))
worst = np.nanmax([np.nanmax(d_row), np.nanmax(d_col)])
report["max_internal_jump_deg"] = float(worst)
report["seam_free"] = bool(worst <= max_internal_jump_deg)
report["passed"] = all(v for k, v in report.items()
if isinstance(v, bool))
return report
A failing seam_free points back to insufficient overlap; a failing crs_projected means the reproject in coordinate reference systems for energy projects was skipped upstream. Log the returned dict alongside the update_tags provenance (sun azimuth, altitude, cell size, source CRS) so the terrain-loss figure stays defensible in permitting and project-finance review. With projected-CRS enforcement, overlap-aware windowing, and the audit gate in place, the slope and hillshade surfaces align pixel-for-pixel with the irradiance and wind grids they constrain and are ready for turbine micro-siting and interconnection studies.
Related
- Terrain & Shadow Analysis Pipelines — the parent workflow this slope/hillshade stage feeds.
- Calculating wind shear coefficients with Python — pairs slope-constrained sites with hub-height wind extrapolation.
- Stacking NASA POWER and PVGIS rasters in rasterio — the grid-alignment pattern slope outputs must match.
- Best practices for cleaning messy shapefiles in GeoPandas — preparing exclusion-zone vectors that consume these terrain derivatives.