Optimizing Turbine Positions Under Setback Constraints
The scenario: an optimiser improves modelled energy by 3.4 percent and the result is discarded, because four turbines ended up 380 metres from a dwelling where the ordinance requires 400. The objective was right and the feasible set was not enforced. This page formulates layout optimisation so that infeasible positions cannot be chosen at all, and it is the refinement stage of wind farm layout and wake modeling.
Root-cause analysis
Three formulation choices decide whether an optimiser produces a usable layout.
- Continuous positions instead of a candidate set. Optimising over continuous coordinates means every proposal has to be re-tested against every constraint, and a solver that treats constraints as penalties will trade a setback violation against an energy gain. Restricting to a pre-filtered candidate grid makes infeasible positions unreachable rather than merely expensive.
- Constraints as penalties. A penalty term is a price, and any finite price can be paid. Setbacks are not preferences: a position 380 metres from a dwelling under a 400-metre ordinance is not slightly worse, it is unbuildable.
- Recomputing the constraint mask inside the loop. The mask does not change during optimisation, so recomputing it per evaluation is the most common reason a layout optimiser is slow enough that nobody runs it twice.
Pre-flight validation
Build the feasible candidate set once, and confirm it is large enough to make optimisation meaningful. A candidate set only slightly larger than the turbine count leaves nothing to optimise over.
import geopandas as gpd
def build_feasible_candidates(
buildable: gpd.GeoSeries,
grid: gpd.GeoDataFrame,
setbacks: dict[str, tuple[gpd.GeoSeries, float]],
*,
pad_radius_m: float,
) -> gpd.GeoDataFrame:
"""Every candidate that satisfies every hard constraint. Nothing else is ever offered."""
area = buildable.buffer(-pad_radius_m).union_all()
feasible = grid[grid.geometry.within(area)].copy()
for name, (features, distance_m) in setbacks.items():
zone = features.buffer(distance_m).union_all()
before = len(feasible)
feasible = feasible[~feasible.geometry.intersects(zone)]
feasible[f"cleared_{name}"] = True
print(f"{name}: {before - len(feasible)} candidates removed at {distance_m} m")
if feasible.empty:
raise ValueError("no feasible candidates — the constraint set is unsatisfiable here")
return feasible.reset_index(drop=True)
Fix implementation
With a feasible set in hand, optimisation becomes selection: choose n candidates that maximise
energy subject to the spacing rule. A local-swap search is enough to recover most of the available
gain and is easy to explain, which matters when the result has to be defended.
import numpy as np
def optimise_by_swap(
candidates: np.ndarray, # (m, 2) feasible positions, metres
resource: np.ndarray, # (m,) hub-height wind speed at each candidate
initial: list[int], # indices of a greedy starting layout
objective, # callable: positions -> net energy index
*,
min_spacing_m: float,
max_iterations: int = 2000,
rng: np.random.Generator | None = None,
) -> dict:
"""Local swap search over a feasible candidate set. Constraints cannot be violated."""
rng = rng or np.random.default_rng(7)
chosen = list(initial)
best = objective(candidates[chosen])
history = [best]
for _ in range(max_iterations):
out_pos = int(rng.integers(len(chosen)))
trial = chosen.copy()
removed = trial.pop(out_pos)
pool = [i for i in range(len(candidates)) if i not in trial]
cand = int(rng.choice(pool))
# Spacing is enforced structurally: an infeasible swap is simply not taken.
d = np.hypot(*(candidates[trial] - candidates[cand]).T)
if d.size and d.min() < min_spacing_m:
continue
trial.append(cand)
score = objective(candidates[trial])
if score > best:
chosen, best = trial, score
history.append(best)
return {"indices": chosen, "objective": best, "history": history,
"improvement": best / history[0] - 1.0}
Two properties make this defensible. Every layout the search ever holds is feasible, because infeasible swaps are skipped rather than penalised. And the objective is the wake-aware net energy from estimating wake losses with a Jensen model, not the resource sum — optimising resource alone reliably produces tightly clustered layouts that wake each other.
Fallback routing and performance tuning
- Precompute the pairwise geometry once. Distances and bearings between candidates do not change, so a swap updates one row of the wake matrix rather than rebuilding it.
- Cache objective evaluations by layout signature. A swap search revisits configurations; a hash of the sorted index tuple turns a repeat evaluation into a lookup.
- Use a smooth wake profile inside the loop. The Jensen top hat makes the objective discontinuous, so a search chases cliff edges; a Gaussian deficit gives a surface it can actually descend.
- Stop on plateau, not on iteration count. Most of the gain arrives in the first few hundred swaps; a plateau detector saves the rest of the budget for a second restart from a different seed.
- Run several seeds. Local search is seed-dependent, and three restarts usually bracket the achievable gain better than one long run.
Downstream validation
import numpy as np
from scipy.spatial import cKDTree
def assert_optimised_layout(
positions: np.ndarray,
feasible: np.ndarray,
*,
min_spacing_m: float,
tol_m: float = 0.5,
) -> None:
"""Prove feasibility independently of the search that produced it."""
tree = cKDTree(feasible)
d, _ = tree.query(positions, k=1)
assert np.all(d <= tol_m), (
f"{int((d > tol_m).sum())} optimised positions are not in the feasible candidate set"
)
pairs = cKDTree(positions).query_pairs(min_spacing_m - tol_m)
assert not pairs, f"{len(pairs)} pairs violate the minimum spacing after optimisation"
assert len(np.unique(positions, axis=0)) == len(positions), "duplicate turbine positions"
Reporting an optimised layout so it survives review
A stochastic search produces a number that nobody else can reproduce unless the run is described, and four items make it reproducible.
The feasible set — its size and the filters that produced it — is what proves the result respects every constraint, and it is checkable independently of the search. The baseline is what the gain is measured against; an optimised layout without its greedy baseline is an unfalsifiable claim. The seed and iteration budget make the run repeatable, and reporting three seeds rather than the best one is what distinguishes a range from a cherry-pick. And the objective definition — which wake model, which decay constant, which rose weighting — is the part reviewers most often disagree with, which is exactly why it belongs in the record rather than in a docstring.
The layout itself should ship as coordinates with turbine identifiers, in the projected frame it was optimised in, with the equal-area figures alongside. A layout delivered in geographic coordinates invites the next person to measure spacing in degrees, which is the failure the whole pipeline was built to prevent.
Frequently asked questions
How much energy does optimisation actually recover?
On a constrained mask, typically one to three percent of net energy over a resource-sorted greedy layout, and close to nothing over a greedy layout that already uses wind-aligned elliptical spacing. The gain is largest where the mask is fragmented and the spacing rule is barely binding, which is also where a greedy placer performs worst.
Should the turbine count be fixed or optimised too?
Fix it per run and sweep it across runs. Net energy against turbine count is a smooth curve with a broad maximum, and sweeping it produces the curve rather than a single point — which is far more useful to a development team weighing capital cost against energy.
Is a genetic algorithm better than local search here?
Rarely enough to justify the complexity. The candidate set is discrete and the objective is expensive, so the deciding factor is how many evaluations the budget allows, and local search with restarts converges faster on a few thousand evaluations. Population methods start to win when the objective is cheap or the feasible set is very large.
What if the ordinance changes mid-project?
Rebuild the feasible set and re-run; nothing else changes. That is the practical argument for the selection formulation — a setback change is a filter change, not a re-derivation, and the previous layout can be tested against the new set to see exactly which turbines become infeasible.
How should the result be presented?
With the feasible set, the objective, the seed and the improvement over the greedy baseline. An optimised layout without its baseline is an unfalsifiable claim, and the seed is what makes the run reproducible — a search that cannot be reproduced cannot be defended when a reviewer asks why a particular turbine sits where it does.
Can participation constraints be included?
Yes, and they belong in the feasible set rather than in the objective. A non-participating parcel is a setback whose distance comes from a landowner agreement instead of an ordinance, and encoding it the same way keeps the whole constraint set in one place — where a change to any of it is a filter rebuild rather than a model change.
How long should an optimisation run take?
Minutes, not hours, or it will be run once and never revisited. A swap search over a few thousand candidates with a cached wake matrix evaluates in milliseconds per step, so two thousand iterations across three seeds is a coffee break. When a run takes hours the cause is almost always the objective recomputing geometry that has not changed — the constraint mask, the pairwise distances, or the candidate filter — rather than the search itself being expensive.
Does the optimiser need the full wind rose?
It needs enough sectors to distinguish layouts, which in practice is the same sixteen the rose is usually binned into. Collapsing to four sectors makes the objective cheap and blind: layouts that differ only in how they align with the prevailing axis score identically, which removes exactly the distinction the optimiser exists to find.
Related
- Wind Farm Layout & Wake Modeling — the parent workflow
- Generating Turbine Layouts with Spacing Constraints in Shapely — the greedy baseline this search improves on
- Estimating Wake Losses with a Jensen Model in Python — the objective function
- Calculating Buildable Area After Setback and Habitat Exclusions — where the feasible mask comes from