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.

Typical siting rules sorted into hard exclusions and soft suitability factors, with the reason for each. the reason decides the kindrulekindreason500 m from housingexclusionregulationNatura 2000 sitesexclusionlawwater, roads + setbackexclusionphysical, safetyslope over 10%exclusionmachinery limitdistance to substationfactorcostslope 0–10%, aspectfactoryield, earthworksirradiancefactoryielda preference written as an exclusion hides the trade-off it should exposeIf it can be traded against cost, it is a factor spatialworkflow.io
The reason behind a rule decides whether it removes land or ranks it.

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.

Available land in the 1,140 km² Toledo study region against the settlement setback. available km² by housing setback0 m907 km²250 m841500 m7621,000 m5921,500 m4382,000 m311 — 27% of the region0500 km²1000 km²Every other rule held fixed: roads, rail, water, rivers, Natura 2000 spatialworkflow.io
One number in the rulebook moves the answer more than every other refinement combined.

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.

Overlapping exclusion buffers around a town, a road and a protected area, whose union is smaller than the sum of their areas. the union, not the sumeach rule alone209 + 42 + 129 + …combinedsum 433 km², union 378 km²Towns sit on roads and inside valleys: overlaps are the rule, so subtract the union once spatialworkflow.io
A road through a town inside a protected area is excluded three times over, and counted once.

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.

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.