Generating Turbine Layouts with Spacing Constraints in Shapely
The scenario: a layout script places 26 turbines inside the buildable mask, every position passes the 4-diameter spacing test, and the civil engineer rejects six of them because a crane cannot be assembled within 40 metres of the wetland edge. The geometry was correct and the constraint set was incomplete. This page builds the placement stage of wind farm layout and wake modeling, with the constraints a layout actually has to satisfy.
Root-cause analysis
Three modelling gaps produce layouts that pass their own tests and fail review.
- Point-in-mask instead of pad-in-mask. A turbine is not a point: it needs a crane pad, a rotor swept area and an access road. Testing whether the tower centre falls inside the buildable polygon accepts positions whose pad or rotor tip does not, and the error concentrates along the exclusion boundary — which is exactly where a greedy placer wants to put turbines, because that is where the wind is unobstructed.
- Circular spacing on a directional site. A single minimum separation treats every bearing as equally important. On a site where one sector carries a fifth of the hours and a third of the energy, that wastes crosswind space and under-spaces along the prevailing axis at the same time.
- Greedy placement without a resource sort. Placing in file order fills the mask from wherever the first candidate happened to be. Sorting candidates by hub-height wind speed first costs nothing and produces layouts that are consistently one to two percent better in energy terms.
Pre-flight validation
Erode the buildable mask by the pad radius before any placement runs, and check that what remains can hold the intended turbine count at the intended spacing. That second check is the one that saves a wasted optimisation run.
import geopandas as gpd
import numpy as np
def preflight_layout_capacity(
buildable: gpd.GeoSeries,
*,
rotor_diameter_m: float,
pad_radius_m: float,
min_spacing_d: float,
) -> dict:
"""Can this mask hold a layout at all, and roughly how many turbines?"""
eroded = buildable.buffer(-pad_radius_m)
eroded = eroded[~eroded.is_empty]
if eroded.empty:
raise ValueError(
f"no buildable area survives a {pad_radius_m} m pad erosion — "
"the mask is narrower than the machine"
)
area_m2 = float(eroded.area.sum())
# A hexagonal packing at spacing s covers about s^2 * sqrt(3)/2 per turbine.
s = min_spacing_d * rotor_diameter_m
theoretical = int(area_m2 / (s * s * np.sqrt(3) / 2))
return {
"eroded_area_ha": area_m2 / 10_000.0,
"pieces": int(len(eroded.explode(index_parts=False))),
"theoretical_turbines": theoretical,
"practical_turbines": int(theoretical * 0.65), # edges and shape cost ~a third
}
The 0.65 factor is empirical and worth keeping honest: perfect hexagonal packing assumes an infinite plane, and a real mask with an irregular boundary and internal holes loses roughly a third of the theoretical count to edge effects.
Fix implementation
import geopandas as gpd
import numpy as np
from shapely.geometry import Point
def place_turbines(
buildable: gpd.GeoSeries,
candidates: gpd.GeoDataFrame,
*,
rotor_diameter_m: float,
pad_radius_m: float = 40.0,
cross_spacing_d: float = 3.2,
down_spacing_d: float = 9.0,
prevailing_deg: float | None = None,
max_turbines: int | None = None,
) -> gpd.GeoDataFrame:
"""Greedy, resource-sorted placement under an elliptical spacing rule."""
area = buildable.buffer(-pad_radius_m).union_all()
if area.is_empty:
raise ValueError("buildable area does not survive pad erosion")
inside = candidates[candidates.geometry.within(area)].copy()
inside = inside.sort_values("wind_speed_ms", ascending=False)
r_cross = cross_spacing_d * rotor_diameter_m
r_down = down_spacing_d * rotor_diameter_m
theta = np.radians(prevailing_deg) if prevailing_deg is not None else None
placed: list[tuple[float, float]] = []
keep: list[int] = []
for idx, row in inside.iterrows():
x, y = row.geometry.x, row.geometry.y
if placed:
px = np.fromiter((p[0] for p in placed), dtype=float)
py = np.fromiter((p[1] for p in placed), dtype=float)
dx, dy = x - px, y - py
if theta is None:
blocked = np.hypot(dx, dy) < r_cross
else:
u = dx * np.sin(theta) + dy * np.cos(theta) # along the wind
v = dx * np.cos(theta) - dy * np.sin(theta) # across it
blocked = ((u / r_down) ** 2 + (v / r_cross) ** 2) < 1.0
if blocked.any():
continue
placed.append((x, y))
keep.append(idx)
if max_turbines and len(keep) >= max_turbines:
break
out = inside.loc[keep].copy()
out["turbine_id"] = [f"T{i + 1:03d}" for i in range(len(out))]
out["pad_radius_m"] = pad_radius_m
return out.set_geometry("geometry")
The ellipse is the substance. Rotating the separation vector into wind-aligned coordinates costs one sine and one cosine per comparison and lets the layout pack tightly across the prevailing axis while staying generous along it — which is what the wake physics asks for and what a circular rule cannot express.
Fallback routing and performance tuning
- Vectorise the distance test. Comparing against all placed turbines with NumPy rather than a Python loop keeps placement linear in practice up to a few hundred turbines.
- Use a spatial index above ~300 turbines. Only turbines within
r_downcan block a candidate, so a KD-tree query bounds the comparison set instead of scanning every placement. - Generate candidates on a hexagonal grid, not a square one. Hexagonal candidate spacing packs about 15 percent more positions into the same mask before the spacing rule prunes them.
- Keep the candidate grid coarse. A 25-metre candidate spacing is finer than the uncertainty in the wind field, and a finer grid multiplies placement time for no measurable energy gain.
- Erode once, on the union. The negative buffer is the expensive geometry operation here; doing it per candidate is the most common reason a placement run takes minutes.
Downstream validation
import numpy as np
from scipy.spatial import cKDTree
def assert_layout_valid(
layout: gpd.GeoDataFrame,
buildable: gpd.GeoSeries,
*,
rotor_diameter_m: float,
pad_radius_m: float,
cross_spacing_d: float,
) -> None:
"""Three assertions that catch the layouts a review would reject."""
area = buildable.buffer(-pad_radius_m).union_all()
outside = layout[~layout.geometry.within(area)]
assert outside.empty, f"{len(outside)} turbines outside the pad-eroded buildable area"
xy = np.column_stack([layout.geometry.x.values, layout.geometry.y.values])
if len(xy) > 1:
pairs = cKDTree(xy).query_pairs(cross_spacing_d * rotor_diameter_m)
assert not pairs, f"{len(pairs)} turbine pairs closer than the crosswind minimum"
assert layout["turbine_id"].is_unique, "duplicate turbine identifiers in the layout"
Candidate grids and why they matter more than they look
Placement can only choose from the positions it is offered, so the candidate grid is a modelling decision rather than an implementation detail. Three properties matter.
Spacing. A 25-metre candidate spacing is finer than the uncertainty in the hub-height wind field, so anything finer buys resolution the resource data cannot support while multiplying placement time. Anything much coarser starts to cost real energy, because the placer cannot reach the local maximum it is aiming for.
Geometry. A hexagonal candidate grid packs about 15 percent more positions into the same mask than a square one at the same nominal spacing, and it aligns better with the hexagonal packing an unconstrained spacing rule tends toward. The difference shows up as one or two extra turbines on a mid-sized mask.
Stability. The same grid must be used across layout variants. Regenerating it — or generating it from a bounding box that moves when the mask changes — introduces differences between variants that look like layout improvements and are grid noise. Persist the grid with the project, and treat a change to it as a change to the study.
A useful diagnostic is the ratio of candidates offered to turbines placed. On an open mask at 4 diameters that ratio is in the hundreds; when it falls into the low tens, the mask is so constrained that the spacing rule is barely binding and the layout is being decided by the exclusions instead.
Frequently asked questions
Is greedy placement good enough, or should this be optimised?
Greedy with a resource sort lands within a few percent of an optimised layout and is explainable, which matters more than the last percent when a landowner asks why a turbine sits where it does. Optimisation earns its cost when the mask is highly fragmented, when a hard turbine count must be met, or when the wake model is inside the objective rather than applied afterwards.
What pad radius should be used?
Whatever the crane and the rotor require, and it is usually the crane. A large main crane needs a level pad tens of metres across plus assembly space, and the binding constraint is often the assembly area rather than the pad itself. Record which one bound, because a change of crane changes the buildable area rather than just the cost.
Should the ellipse use the prevailing direction or the energy-weighted mean direction?
The energy-weighted one. The most frequent direction and the direction that carries the most energy differ at many sites, sometimes by two sectors, and it is the energy-weighted axis that wake losses follow. Deriving it from the rose is a few lines and removes a systematic misalignment.
How do external turbines factor into placement?
As placed positions that cannot be moved. Add the neighbouring project’s turbines to the placed list before the loop starts, and the spacing rule will keep new positions clear of them automatically — which is both good practice and, in several jurisdictions, a permitting requirement.
What if the layout needs an exact turbine count?
Run placement at several spacings and pick the tightest that still meets the count, rather than forcing positions at a fixed spacing. A layout that meets a count by violating its own spacing rule will lose the difference to wake losses and more, which the array-efficiency calculation in the parent workflow will show immediately.
Does the candidate grid need to align with anything?
Only with itself. What matters is that the same grid is used across layout variants, so two options are comparable; an unaligned or regenerated grid introduces differences that look like layout improvements and are grid noise.
Related
- Wind Farm Layout & Wake Modeling — the parent workflow
- Estimating Wake Losses with a Jensen Model in Python — scoring the layouts this page produces
- Calculating Buildable Area After Setback and Habitat Exclusions — the eroded mask this placement consumes
- Building Wind Roses from Met Mast Data with Python — the prevailing axis the ellipse aligns to