Renewable Siting Constraints Explained
On this page
Problem statement
A siting study starts with a region and a list of rules: nothing within 500 m of a house, nothing in a protected area, nothing on a slope over 10%, nothing within 100 m of a motorway. Each rule looks modest. Applied together around Toledo they remove a third of the land at a 500 m housing setback and nearly three quarters at 2,000 m — the answer is far more sensitive to one or two numbers in the list than to the quality of any of the data.
The rules also come in two kinds that are easy to blur. Some are legal or physical and cannot be traded off. Others are preferences that can be, and treating a preference as an exclusion (or the reverse) changes the map more than any refinement of the data.
Quick answer
- Exclusion (hard) constraints remove land entirely: built-up areas and their setbacks, protected areas, water, infrastructure corridors, slopes a machine cannot work on.
- Suitability (soft) factors rank what is left: irradiance or wind speed, distance to the grid, slope within the workable range, land cover, visibility.
- The available area is what survives the union of the exclusions — not the region minus the sum of each one, because they overlap.
import geopandas as gpd, osmnx as ox, requests
ox.settings.overpass_url = "https://maps.mail.ru/osm/tools/overpass/api" # overpass-api.de mirror
ox.settings.overpass_rate_limit = False
from shapely.geometry import box
WEST, SOUTH, EAST, NORTH = -4.30, 39.70, -3.90, 40.00
CRS = "EPSG:25830" # ETRS89 / UTM 30N, metres
region = gpd.GeoSeries([box(WEST, SOUTH, EAST, NORTH)], crs=4326).to_crs(CRS)
def osm(tags, geom_types):
f = ox.features_from_bbox((WEST, SOUTH, EAST, NORTH), tags)
return f[f.geom_type.isin(geom_types)].to_crs(CRS)
def natura2000():
parts = []
for layer in (0, 1): # Habitats Directive, Birds Directive
url = ("https://bio.discomap.eea.europa.eu/arcgis/rest/services/"
f"ProtectedSites/Natura2000Sites/MapServer/{layer}/query")
gj = requests.get(url, params={
"geometry": f"{WEST},{SOUTH},{EAST},{NORTH}",
"geometryType": "esriGeometryEnvelope", "inSR": 4326, "outSR": 4326,
"spatialRel": "esriSpatialRelIntersects", "outFields": "SITECODE,SITENAME",
"f": "geojson"}, timeout=120).json()
parts.append(gpd.GeoDataFrame.from_features(gj["features"], crs=4326))
return gpd.pd.concat(parts).to_crs(CRS)
POLY = ["Polygon", "MultiPolygon"]
LINE = ["LineString", "MultiLineString"]
layers = {
"built-up": (osm({"landuse": ["residential", "industrial", "commercial", "retail"]}, POLY), 500),
"major road": (osm({"highway": ["motorway", "trunk", "primary"]}, LINE), 100),
"railway": (osm({"railway": "rail"}, LINE), 100),
"water": (osm({"natural": "water"}, POLY), 50),
"river": (osm({"waterway": "river"}, LINE), 100),
"Natura 2000": (natura2000(), 0),
}
total = region.area.iloc[0] / 1e6
masks = {}
for name, (g, setback) in layers.items():
zone = g.geometry.buffer(setback) if setback else g.geometry
masks[name] = zone.union_all().intersection(region.iloc[0])
print(f"{name:<12} {len(g):>5} features, setback {setback:>4} m, "
f"excludes {masks[name].area / 1e6:>6.1f} km²")
union = gpd.GeoSeries(list(masks.values()), crs=CRS).union_all()
print(f"\nregion {total:,.0f} km²; sum of exclusions "
f"{sum(m.area for m in masks.values()) / 1e6:,.0f} km²; "
f"union {union.area / 1e6:,.0f} km²; available {(total - union.area / 1e6):,.0f} km² "
f"({1 - union.area / 1e6 / total:.0%})")
built-up 287 features, setback 500 m, excludes 209.1 km²
major road 1257 features, setback 100 m, excludes 42.0 km²
railway 45 features, setback 100 m, excludes 8.7 km²
water 194 features, setback 50 m, excludes 23.5 km²
river 11 features, setback 100 m, excludes 20.4 km²
Natura 2000 5 features, setback 0 m, excludes 128.9 km²
region 1,140 km²; sum of exclusions 433 km²; union 378 km²; available 762 km² (67%)
The sum of the individual exclusions (433 km²) overstates the union (378 km²) because they overlap — roads run through towns, rivers run through protected areas. Report the union, and report which layer each excluded square metre is attributed to only if you have said in what order.
Rules first, metric CRS, buffer, union, clip
1. Write the rules down before touching data
Each rule needs a source, a setback in metres and a reason: a regulation, a technical limit, or a policy preference. That last column is what separates exclusions from suitability factors.
2. Put every layer in a metric projected CRS
Setbacks are in metres. A buffer of 500 applied in EPSG:4326 is 500 degrees and covers the planet; a buffer of 0.005 degrees is 555 m north–south and 427 m east–west at 40°N. Project first.
3. Buffer, then union, then clip
Buffer each layer by its setback, dissolve it, clip it to the region, and combine the layers with a union. The area that remains is the candidate land.
4. Measure the marginal effect of each rule
For each layer, compute how much land it removes on top of all the others. A rule that removes 20 km² alone may remove 4 km² at the margin because the land was excluded anyway — and that is the number that matters when somebody asks to relax it.
5. Test the sensitivity of the uncertain numbers
Setbacks from housing vary by country, region and technology, and often by a factor of four. Recompute the available area across the plausible range and publish the curve, not one point.
6. Keep suitability out of the exclusion mask
Distance to the grid, irradiance, and preferred land cover are costs, not prohibitions. Putting them in the exclusion mask as thresholds throws away the ranking and hides the trade-off.
What each constraint costs at the margin
The data used on this page
The region south-west of Madrid used across the siting guides, 4.30–3.90° W, 39.70–40.00° N, in ETRS89 / UTM 30N. The quick answer fetches each constraint layer itself: built-up land, major roads, railways, water and rivers from OpenStreetMap through OSMnx and the maps.mail.ru Overpass mirror, and Natura 2000 sites from the EEA's map service. The setup unpacks the OpenStreetMap answers as saved on 10 October 2026, so the excluded areas stay put while the map is edited.
import io
import os
import requests
import zipfile
# OpenStreetMap as Overpass returned it on 10 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-renewable-siting-constraints-explained-2026-10-10.zip", timeout=300)
reply.raise_for_status()
with zipfile.ZipFile(io.BytesIO(reply.content)) as archive:
archive.extractall("cache")
Example 1 — marginal effect of each layer
print(f"{'layer':<13}{'alone':>8}{'marginal':>10}")
for name, m in masks.items():
others = gpd.GeoSeries([v for k, v in masks.items() if k != name], crs=CRS).union_all()
marginal = union.area - others.area
print(f"{name:<13}{m.area / 1e6:>8.1f}{marginal / 1e6:>10.1f}")
layer alone marginal
built-up 209.1 182.2
major road 42.0 20.7
railway 8.7 3.0
water 23.5 3.9
river 20.4 4.0
Natura 2000 128.9 116.4
The marginal column is what each rule costs given the others. Water removes 23.5 km² on its own and 3.9 km² at the margin — most lakes and reservoirs are already inside a town setback or a Natura 2000 site. The gap between the two columns is where a negotiation over a rule should start.
Example 2 — sensitivity to the settlement setback
built, _ = layers["built-up"]
others = gpd.GeoSeries([v for k, v in masks.items() if k != "built-up"], crs=CRS).union_all()
print(f"{'setback':>8}{'excluded':>11}{'available':>11}")
for setback in (0, 250, 500, 1000, 1500, 2000):
zone = built.geometry.buffer(setback).union_all().intersection(region.iloc[0])
excl = zone.union(others).area / 1e6
print(f"{setback:>6} m{excl:>9.0f} km²{total - excl:>8.0f} km²")
setback excluded available
0 m 232 km² 907 km²
250 m 298 km² 841 km²
500 m 378 km² 762 km²
1000 m 548 km² 592 km²
1500 m 702 km² 438 km²
2000 m 828 km² 311 km²
Going from 500 m to 1,500 m — both values found in real regional rules — cuts the available land from 762 km² to 438 km². No improvement in data quality could move the answer that far. That is the curve to publish.
Example 3 — the setback in the wrong units
from shapely.geometry import LineString
lat, lon = 39.85, -4.10
lines = {"north–south road": LineString([(lon, lat), (lon, lat + 0.09)]),
"east–west road": LineString([(lon, lat), (lon + 0.117, lat)])}
for name, line in lines.items():
g = gpd.GeoSeries([line], crs=4326)
length = g.to_crs(CRS).length.iloc[0]
in_degrees = gpd.GeoSeries([line.buffer(0.0045)], crs=4326).to_crs(CRS).area.iloc[0]
in_metres = g.to_crs(CRS).buffer(500).area.iloc[0]
print(f"{name}: {length / 1000:.1f} km, 0.0045° buffer is "
f"{in_degrees / length / 2:.0f} m each side; 500 m buffer is "
f"{in_metres / length / 2:.0f} m each side")
north–south road: 10.0 km, 0.0045° buffer is 415 m each side; 500 m buffer is 539 m each side
east–west road: 10.0 km, 0.0045° buffer is 530 m each side; 500 m buffer is 539 m each side
0.0045° is 500 m of latitude, so somebody converting "500 m" by hand arrives at it. It is 500 m north and south of a feature and only 385 m east and west of it at 40°N (the figures above include the round end caps). A setback that is legally 500 m is silently 23% short on one axis — the kind of error that survives review because the total excluded area looks about right.
The union is not the sum
Why the union is not the sum
Constraints are spatially correlated. Towns sit on roads and rivers; protected areas follow valleys; railways follow rivers. Subtracting each exclusion from the total double-counts every overlap, and in a well-connected region the double-counting can make the available land appear to be negative.
Why marginal cost is the useful number
When a developer asks to reduce a setback, or a regulator proposes a new one, the question is what that change does given everything else. The answer is the marginal area, and it is often surprisingly small for a rule that looks severe in isolation.
Why OSM needs an authoritative partner
OpenStreetMap is excellent for roads, railways and built-up areas, and patchy for designations: it had a single protected-area relation in this region, while five Natura 2000 sites cover 129 km² of it — 11% of the region and the second-largest exclusion. Legal constraints should come from the body that holds the legal boundary.
Many small polygons, edges and legal setbacks
- Buffering many small polygons is slow; dissolve first where the layer allows, or rasterise and use a distance transform.
- Edge effects: features just outside the region still cast setbacks into it — fetch data for a region enlarged by the largest setback.
- Housing is better represented by buildings than by landuse polygons where building footprints are complete; isolated farmhouses often have no landuse polygon at all.
- Wind and solar need different rulebooks: turbines have noise, shadow flicker and tip-height setbacks that panels do not.
- Legal setbacks are sometimes measured from the plot boundary, not the building.
Internal links
- How to build a siting exclusion mask in Python — the same rules as a raster with slope
- How to build a solar suitability map — the soft factors on top of the mask
- Siting mask removes everything — the fix guide for units and unions
- Power density and land use explained — what the remaining km² can hold
- Buffer analysis in GeoPandas — buffer mechanics
- How to query the Overpass API from Python — fetching OSM layers directly
FAQ
What is the difference between an exclusion and a suitability factor?
An exclusion removes land from consideration entirely; a suitability factor ranks the land that remains.
Why can't I add up the excluded areas?
The constraints overlap. Only the area of their union is excluded.
What setback from housing should I use?
The one in the applicable regulation. Where there is none, publish the result across a range rather than picking one value.
Can I use OpenStreetMap for protected areas?
As a supplement at most. Use the designating authority's boundaries — Natura 2000 in the EU, WDPA globally.
Why do my buffers look like ellipses?
They were computed in degrees. Reproject to a metric CRS before buffering.
How do I know which rule matters most?
Compute each rule's marginal exclusion given all the others, and vary the uncertain setbacks.