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.

  1. 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.
  2. 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.
  3. 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.
From candidate grid to feasible set, once A funnel from 4,120 grid candidates to 1,150 feasible positions. Successive filters remove 610 candidates for pad clearance, 1,284 for the dwelling setback, 302 for the road setback, 418 for the property-line setback and 356 for the habitat buffer. The surviving 1,150 are marked as the only positions the optimiser may ever select. A note contrasts this with a penalty formulation, where an infeasible position remains selectable at a price. 4 120 candidates in, 1 150 out — and only those are ever offered grid candidates 4 120 − pad clearance 610 − dwelling setback 1 284 − road setback 302 − property line 418 − habitat buffer 356 feasible set 1 150 A penalty term is a price, and any finite price can be paid. A candidate that is never offered cannot be chosen — which is the whole reason to formulate layout optimisation as selection.

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.

python
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.

python
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.

Swap-search convergence over three seeds A convergence chart with iterations from 0 to 2,000 on the horizontal axis and the net-energy index on the vertical. Three curves, one per random seed, all start at the greedy baseline of 21.6. Each rises steeply over the first 200 iterations to about 22.1, flattens by 1,000 iterations, and ends between 22.2 and 22.4. A shaded band marks the spread between seeds, annotated as the honest reporting range, and a note records the overall improvement over the greedy baseline as 3.2 percent. Most of the gain arrives in the first 200 swaps 21.5 22.0 22.5 0 500 1000 1500 2000 swap iterations greedy baseline 21.6 Three seeds bracket the result between 22.19 and 22.42 — a 3.2% gain over greedy. Reporting the range rather than the best seed is what makes a stochastic search reproducible.

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

python
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"
Optimisation gain by starting layout and mask type A bar chart of the improvement a swap search recovers over four starting points: 3.2 percent over a resource-sorted greedy layout on a fragmented mask, 1.9 percent over the same layout on a moderately constrained mask, 0.8 percent on an open mask, and 0.4 percent over a greedy layout that already applies wind-aligned elliptical spacing. A note draws the conclusion that most of the achievable gain is available from a better spacing rule rather than from search. The optimiser earns its cost where greedy struggles fragmented mask, greedy start 3.2% moderate mask, greedy start 1.9% open mask, greedy start 0.8% elliptical greedy start 0.4% net energy recovered over the starting layout Most of the available gain comes from the spacing rule, not from the search: an elliptical greedy layout leaves only 0.4% on the table, and it takes seconds rather than minutes to produce.

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.