How to Build a Siting Exclusion Mask in Python
On this page
Problem statement
Vector buffers are the natural first way to apply siting rules, and they stop scaling as soon as a rule depends on terrain. Slope comes from a DEM, and a DEM is a raster; so does aspect, irradiance and every other suitability factor that follows. The practical representation of an exclusion mask is therefore a raster on a fixed metric grid: one cell per 25 m, true where a plant may go.
A raster mask also makes the next steps trivial — area is a cell count, overlaps are boolean algebra, and patches too small to build on can be removed with connected-component labelling.
Quick answer
Put every constraint on one grid, as a distance or a value, and combine them with boolean operators:
import numpy as np, geopandas as gpd, osmnx as ox, rasterio, requests
from rasterio import features
from rasterio.transform import from_origin
from rasterio.warp import reproject, Resampling
from scipy import ndimage
from shapely.geometry import box
ox.settings.http_user_agent = "spatialworkflow-examples/1.0"
WEST, SOUTH, EAST, NORTH = -4.30, 39.70, -3.90, 40.00
CRS, RES = "EPSG:25830", 25.0
region = gpd.GeoSeries([box(WEST, SOUTH, EAST, NORTH)], crs=4326).to_crs(CRS)
x0, y0, x1, y1 = (np.floor(b / RES) * RES for b in region.total_bounds)
width, height = int((x1 - x0) / RES), int((y1 - y0) / RES)
transform = from_origin(x0, y1, RES, RES)
inside = features.rasterize(region, out_shape=(height, width), transform=transform) == 1
def burn(geoms):
"""1 where a feature is, 0 elsewhere, on the common grid."""
return features.rasterize(((g, 1) for g in geoms), out_shape=(height, width),
transform=transform, fill=0, all_touched=True).astype(bool)
def distance_m(mask):
"""Distance in metres from every cell to the nearest True cell."""
return ndimage.distance_transform_edt(~mask) * RES
def osm(tags):
return ox.features_from_bbox((WEST - 0.03, SOUTH - 0.03, EAST + 0.03, NORTH + 0.03),
tags).to_crs(CRS).geometry
built_g = osm({"landuse": ["residential", "industrial", "commercial", "retail"]})
roads_g = osm({"highway": ["motorway", "trunk", "primary"]})
water_g = osm({"natural": "water"})
built, roads, water = burn(built_g), burn(roads_g), burn(water_g)
print(f"grid: {width} × {height} cells of {RES:.0f} m, {inside.sum() * RES**2 / 1e6:,.0f} km² inside")
excluded = (distance_m(built) < 500) | (distance_m(roads) < 100) | (distance_m(water) < 50)
print(f"excluded by settlements, roads and water: "
f"{(excluded & inside).sum() * RES**2 / 1e6:,.0f} km²")
grid: 1386 × 1349 cells of 25 m, 1,140 km² inside
excluded by settlements, roads and water: 253 km²
The distance transform replaces thousands of buffer operations with one pass over the grid. It sees features just outside the region only where the grid extends beyond the region: the OSM query is padded by more than the largest setback, but burn draws only inside the grid, which here is the bounding box of the projected region. Where that matters, burn onto a grid widened by the largest setback and crop, as the distance-to-grid guide does for substations that can be kilometres outside.
One grid, padded fetches, distance transforms, slope
1. Define one grid and snap to it
Choose the CRS, the resolution and the origin once, and put every layer on that grid. Snapping the origin to a multiple of the resolution keeps later layers — irradiance, land cover — aligned without resampling artefacts.
2. Pad the data fetch by the largest setback
A town 400 m outside the region still excludes land inside it. Download features for the region plus a margin, and clip at the end.
3. Rasterise features, then take distances
rasterize with all_touched=True burns every cell a line crosses; the Euclidean distance transform then gives the distance from each cell to the nearest burned one. A setback becomes a threshold on distance.
4. Derive slope from a DEM on the same grid
Reproject the DEM onto the grid (bilinear), compute the gradient in metres per metre, and express it in percent or degrees — whichever the rule uses.
5. Combine, then remove small patches
OR the exclusions together, invert to get the candidates, and drop connected patches smaller than a buildable plant — a few hectares for solar.
6. Save the mask as a GeoTIFF with its grid
Every suitability layer that follows reads the same transform. Store the mask as uint8 with a nodata value for outside the region.
A full exclusion mask from fetched data
The data used on this page
Everything is fetched by the page's own code: OpenStreetMap settlements, main roads, water, power lines through OSMnx; the Copernicus GLO-30 DEM from its public bucket; and Natura 2000 sites from the EEA's map service, for a region south-west of Madrid, 4.30–3.90° W, 39.70–40.00° N. The setup points OSMnx at an Overpass mirror, because the main Overpass server was refusing connections when this page was run, and unpacks the mirror's answers as saved on 9 October 2026: the water layer alone gained a feature between two runs.
import io
import os
import osmnx as ox
import requests
import zipfile
# OpenStreetMap as Overpass returned it on 9 October 2026, saved as osmnx's own cache so the counts
# below do not drift as OSM is edited; delete the cache folder to query live instead
if not os.path.exists("cache"):
reply = requests.get("https://data.spatialworkflow.io/osm-cache-siting-exclusion-mask-python-2026-10-09.zip", timeout=300)
reply.raise_for_status()
with zipfile.ZipFile(io.BytesIO(reply.content)) as archive:
archive.extractall("cache")
ox.settings.overpass_url = "https://maps.mail.ru/osm/tools/overpass/api"
ox.settings.requests_timeout = 600
Example 1 — slope from the Copernicus DEM
import rasterio.merge
tiles = [f"https://copernicus-dem-30m.s3.amazonaws.com/Copernicus_DSM_COG_10_N39_00_{t}_00_DEM/"
f"Copernicus_DSM_COG_10_N39_00_{t}_00_DEM.tif" for t in ("W005", "W004")]
srcs = [rasterio.open(t) for t in tiles]
mosaic, mosaic_transform = rasterio.merge.merge(
srcs, bounds=(WEST - 0.01, SOUTH - 0.01, EAST + 0.01, NORTH + 0.01))
dem = np.full((height, width), np.nan, dtype="float32")
reproject(mosaic[0], dem, src_transform=mosaic_transform, src_crs=srcs[0].crs,
dst_transform=transform, dst_crs=CRS, resampling=Resampling.bilinear)
dz_dy, dz_dx = np.gradient(dem, RES)
slope_pct = np.hypot(dz_dx, dz_dy) * 100
print(f"elevation {np.nanmin(dem[inside]):.0f}–{np.nanmax(dem[inside]):.0f} m")
for limit in (5, 10, 15):
share = (slope_pct[inside] > limit).mean()
print(f"slope over {limit:>2}%: {share:.1%} of the region")
elevation 407–1078 m
slope over 5%: 48.1% of the region
slope over 10%: 19.4% of the region
slope over 15%: 8.8% of the region
Four fifths of the region is under 10%, and half of it under 5%. A 10% limit is typical for fixed-tilt solar; single-axis trackers usually need less. Copernicus GLO-30 is a surface model, so a little of the steep ground is the edge of buildings and tree rows rather than terrain.
Example 2 — the full mask and its patches
def natura2000():
parts = []
for layer in (0, 1):
gj = requests.get("https://bio.discomap.eea.europa.eu/arcgis/rest/services/"
f"ProtectedSites/Natura2000Sites/MapServer/{layer}/query", params={
"geometry": f"{WEST},{SOUTH},{EAST},{NORTH}", "geometryType": "esriGeometryEnvelope",
"inSR": 4326, "outSR": 4326, "spatialRel": "esriSpatialRelIntersects",
"outFields": "SITECODE", "f": "geojson"}, timeout=120).json()
parts.append(gpd.GeoDataFrame.from_features(gj["features"], crs=4326))
return gpd.pd.concat(parts).to_crs(CRS).geometry
natura = natura2000()
protected = features.rasterize(((g, 1) for g in natura), out_shape=(height, width),
transform=transform, fill=0).astype(bool)
steep = slope_pct > 10
exclusion = excluded | protected | steep | ~inside
candidate = ~exclusion
labels, n = ndimage.label(candidate)
sizes = np.bincount(labels.ravel()) * RES**2 / 1e4 # hectares
keep = sizes >= 10
keep[0] = False
buildable = keep[labels]
km2 = lambda m: m.sum() * RES**2 / 1e6
print(f"candidate land: {km2(candidate):,.0f} km² in {n:,} patches")
print(f"patches of 10 ha or more: {keep.sum():,}, {km2(buildable):,.0f} km²")
print(f"lost to fragmentation: {km2(candidate) - km2(buildable):,.0f} km² "
f"in {n - keep.sum():,} slivers")
candidate land: 612 km² in 5,655 patches
patches of 10 ha or more: 108, 593 km²
lost to fragmentation: 19 km² in 5,547 slivers
5,547 of the 5,655 patches are too small to hold a plant: slivers between road buffers, corners cut off by a water setback. Together they are 19 km² that a raw eligible-land figure would count as usable, while 108 patches hold almost all the rest.
Example 3 — write it out, and check against the vector result
profile = {"driver": "GTiff", "dtype": "uint8", "count": 1, "width": width,
"height": height, "crs": CRS, "transform": transform, "nodata": 255,
"compress": "deflate"}
out = np.where(inside, buildable.astype("uint8"), 255).astype("uint8")
with rasterio.open("siting_mask.tif", "w", **profile) as dst:
dst.write(out, 1)
no_slope = (excluded | protected) & inside
print(f"without slope or patch filters: {km2(inside) - km2(no_slope):,.0f} km² available")
setbacks = gpd.GeoSeries(list(built_g.buffer(500)) + list(roads_g.buffer(100)) +
list(water_g.buffer(50)) + list(natura), crs=CRS)
area = region.iloc[0]
vector_km2 = (area.area - setbacks.union_all().intersection(area).area) / 1e6
print(f"vector buffers on the same four layers: {vector_km2:,.0f} km² available")
print(f"from {len(roads_g):,} road features, {len(built_g):,} land-use polygons, "
f"{len(water_g):,} water features and {len(natura):,} Natura 2000 sites")
without slope or patch filters: 767 km² available
vector buffers on the same four layers: 768 km² available
from 1,383 road features, 352 land-use polygons, 239 water features and 5 Natura 2000 sites
The raster and vector routes agree to within a square kilometre: 767 against 768 km² on identical inputs. The raster side is the smaller because all_touched rasterisation errs towards exclusion by up to a cell along every feature. Renewable siting constraints explained builds the vector version with more layers — railways and rivers — so its total is not directly comparable.
Distance transforms rather than buffers
Why a distance transform instead of buffers
Buffering 1,383 road features and 352 land-use polygons and unioning the results is slow and produces heavy geometries. Rasterising once and computing the Euclidean distance transform is linear in the number of cells, and a change of setback is a new threshold, not a new geometry operation — which is what makes sensitivity analysis cheap.
Why all_touched=True
The default rasterisation burns only cells whose centre is inside a polygon or on a line's path, so a thin road can disappear between cell centres. Burning every touched cell errs on the side of exclusion by up to one cell width, which is the right direction for a constraint.
Why patch size is a constraint
A plant needs a contiguous site for its fence, internal roads and connection. A 2 ha field wedged between two setbacks is eligible land that nobody will build on, and leaving it in the total inflates the technical potential.
Resolution, DEM type and memory
- Resolution: 25 m is fine for regional siting; setbacks under 50 m or narrow features need finer cells or a vector pass.
- DEM type: Copernicus GLO-30 is a surface model — forests and buildings raise it — so slopes at forest edges can be exaggerated.
- Slope rules are sometimes in degrees: 10% is 5.7°.
- Memory: the distance transform needs a float array the size of the grid; for a country, tile it with an overlap equal to the largest setback.
- Aspect belongs in the suitability score, not in the mask, unless a rule explicitly forbids north-facing ground.
Internal links
- Renewable siting constraints explained — the rules and their overlaps
- How to build a solar suitability map — the scores that go on top of this mask
- Siting mask removes everything — the fix guide
- How to calculate slope and aspect from a DEM — the terrain step in detail
- How to rasterize a vector layer in Python — burning features onto a grid
- Raster and vector do not align — why every layer shares one grid
FAQ
How do I create an exclusion mask for solar or wind siting?
Rasterise each constraint layer onto one metric grid, threshold distances and slopes by the rules, OR the results, and invert.
Should I use vector buffers or rasters?
Rasters once terrain or any other gridded factor is involved; vectors for a few simple layers or very precise setbacks.
What resolution should the grid have?
About a quarter of the smallest setback you care about. 25 m suits regional siting.
How do I remove patches that are too small?
Label connected components with scipy.ndimage.label, measure each one's area, and drop those below the minimum site size.
Why does my raster mask exclude more than my vector buffers?
all_touched=True rasterisation and cell-size rounding err towards exclusion by up to one cell.
Where do I get slope for the mask?
From a DEM such as Copernicus GLO-30, reprojected to the mask's grid before computing the gradient.