Estimating Wake Losses with a Jensen Model in Python
The scenario: a layout is scored with a wake model, the array efficiency comes out at 0.96, and the operating fleet reports 0.89 for a comparable site. The model was not wrong so much as under-specified — it weighted directions by frequency instead of by energy, ignored the neighbouring project upwind, and used an offshore wake decay constant onshore. This page computes the number properly, and it is the scoring half of wind farm layout and wake modeling.
Root-cause analysis
Four modelling choices account for most of the gap between a modelled and a measured array efficiency.
- Frequency weighting instead of energy weighting. Wake losses cost energy, and energy scales with the cube of wind speed. Weighting sectors by frequency alone under-weights the strong sectors where the loss is largest — typically one to two percentage points of array efficiency.
- A decay constant borrowed from the wrong environment. The Jensen wake decay constant
kis about 0.075 onshore and 0.04 offshore, reflecting how quickly ambient turbulence refills the wake. Using the offshore value onshore over-states losses; the reverse under-states them. - Neighbouring turbines omitted. Wakes do not stop at a lease boundary, and an operating farm two kilometres upwind in the prevailing direction can cost one to three points on the near rows.
- Partial wakes treated as full ones. The Jensen profile is a top hat: a turbine is either in the wake or out of it. At tight crosswind spacing many turbines are partially waked, and the top hat makes the estimate jumpy and pessimistic.
Pre-flight validation
Two checks before any efficiency is computed: that the rose is normalised, and that the positions are in a projected metric frame. Both failures produce a number rather than an error.
import numpy as np
def preflight_wake_inputs(positions: np.ndarray, rose: list[dict], *, crs_is_projected: bool) -> None:
"""The two silent failures: an unnormalised rose and geographic coordinates."""
if not crs_is_projected:
raise ValueError("positions must be in a projected metric CRS — diameters are metres")
total = sum(s["freq"] for s in rose)
if not np.isclose(total, 1.0, atol=1e-3):
raise ValueError(f"rose frequencies sum to {total:.4f}, not 1.0 — normalise before weighting")
if positions.ndim != 2 or positions.shape[1] != 2:
raise ValueError(f"positions must be (n, 2) in metres, got {positions.shape}")
spread = positions.max(axis=0) - positions.min(axis=0)
if spread.max() < 500:
raise ValueError(
f"layout spans only {spread.max():.0f} m — coordinates are probably still in degrees"
)
Fix implementation
import numpy as np
def jensen_deficit(distance_m, *, rotor_diameter_m, thrust_coefficient=0.8, wake_decay_k=0.075):
"""Fractional velocity deficit behind one turbine, Jensen (Park) model."""
x = np.maximum(np.asarray(distance_m, dtype=float), 1e-6)
return (1.0 - np.sqrt(1.0 - thrust_coefficient)) / (
1.0 + 2.0 * wake_decay_k * x / rotor_diameter_m
) ** 2
def array_efficiency(
positions: np.ndarray,
rose: list[dict],
*,
rotor_diameter_m: float,
thrust_coefficient: float = 0.8,
wake_decay_k: float = 0.075,
external: np.ndarray | None = None,
max_wake_d: float = 20.0,
) -> dict:
"""Energy-weighted array efficiency, with per-sector detail."""
own = np.asarray(positions, dtype=float)
upwind_sources = own if external is None else np.vstack([own, np.asarray(external, float)])
max_wake_m = max_wake_d * rotor_diameter_m
gross = 0.0
net = 0.0
per_sector = []
for sector in rose:
theta = np.radians(sector["dir_deg"])
wx, wy = np.sin(theta + np.pi), np.cos(theta + np.pi) # unit vector downwind
weight = sector["freq"] * sector["mean_ms"] ** 3 # energy, not frequency
sector_gross = 0.0
sector_net = 0.0
for x, y in own:
dx = x - upwind_sources[:, 0]
dy = y - upwind_sources[:, 1]
downwind = dx * wx + dy * wy
cross = np.abs(dx * wy - dy * wx)
radius = rotor_diameter_m / 2 + wake_decay_k * downwind
in_wake = (downwind > 1e-6) & (downwind < max_wake_m) & (cross <= radius)
if in_wake.any():
deficits = jensen_deficit(
downwind[in_wake],
rotor_diameter_m=rotor_diameter_m,
thrust_coefficient=thrust_coefficient,
wake_decay_k=wake_decay_k,
)
deficit = float(np.sqrt(np.sum(deficits ** 2))) # sum-of-squares superposition
else:
deficit = 0.0
sector_gross += weight
sector_net += weight * (1.0 - min(deficit, 0.95)) ** 3
per_sector.append(
{"dir_deg": sector["dir_deg"], "efficiency": sector_net / sector_gross if sector_gross else 1.0}
)
gross += sector_gross
net += sector_net
return {
"array_efficiency": net / gross if gross else 1.0,
"per_sector": per_sector,
"wake_decay_k": wake_decay_k,
"external_turbines": 0 if external is None else len(external),
}
Two details protect the result. The deficit is clamped below 0.95 because a superposed deficit can
otherwise exceed one and produce negative power in a dense cluster. And the max_wake_d cut-off is
both a performance measure and a physical one: beyond about twenty diameters the Jensen deficit is
under one percent and the model has no useful resolution there.
Fallback routing and performance tuning
- Vectorise over turbines, not over sectors. The inner loop above is already vectorised across upwind sources; lifting it to operate on all evaluated turbines at once gives another order of magnitude at a few hundred turbines.
- Prune with a spatial index. Only sources within
max_wake_ddiameters can contribute, so a KD-tree query per sector bounds the comparison set on large arrays. - Cache the pairwise geometry. Downwind and crosswind distances depend only on positions and direction, so an optimisation loop that moves one turbine can update one row rather than recompute the matrix.
- Move to a Gaussian profile before optimising. The top-hat discontinuity makes an optimiser chase cliff edges; a Gaussian deficit gives it a smooth surface to descend.
Downstream validation
def assert_wake_result(result: dict, *, n_turbines: int) -> None:
"""Bounds and sanity for an array-efficiency figure."""
eff = result["array_efficiency"]
assert 0.6 <= eff <= 1.0, f"array efficiency {eff:.3f} outside any defensible range"
if n_turbines == 1:
assert eff == 1.0, "a single turbine cannot wake itself"
worst = min(s["efficiency"] for s in result["per_sector"])
best = max(s["efficiency"] for s in result["per_sector"])
assert best >= worst, "per-sector efficiencies inconsistent"
assert result["wake_decay_k"] in (0.04, 0.075) or 0.03 <= result["wake_decay_k"] <= 0.09, (
"wake decay constant outside the physically supported range"
)
Calibrating the model against an operating fleet
A wake model earns trust by being compared with measurement, and the comparison is more useful than it looks because the failure modes are distinguishable.
If modelled efficiency is uniformly higher than measured across every sector, the wake decay constant is too large — the model is recovering wakes faster than the site does. If the gap concentrates in the strong sectors, the weighting is wrong: either frequency was used instead of energy, or the thrust coefficient was held constant across a speed range where it falls. If the gap concentrates on the turbines nearest the boundary, a neighbouring project is missing from the source list. And if the gap appears only at night, atmospheric stability is doing what a Jensen model cannot represent, which is the honest point at which to move to a model that carries a stability parameter.
Calibration should adjust one parameter at a time and record the result, because two parameters can compensate for each other and produce a model that matches this fleet and generalises to nothing. In practice the decay constant is the only parameter worth fitting; the rest should come from the turbine specification and the measured rose.
Where no operating data exists, the substitute is a sensitivity band rather than a single figure.
Running the model at k values of 0.05 and 0.09 brackets most onshore conditions, and reporting the
resulting range of array efficiencies is more defensible than a single number carrying three decimal
places.
Frequently asked questions
Why sum-of-squares rather than adding the deficits?
Because adding them over-counts: two upwind turbines each producing a 10 percent deficit do not combine to 20 percent, since the second wake is acting on air the first already slowed. Sum-of-squares is the standard engineering compromise — it is not derived from first principles, but it matches measurements far better than linear addition and is cheap.
When should I move to FLORIS or a Gaussian model?
When partial wakes dominate, when the site has strong atmospheric stability structure, or when the layout is being optimised rather than screened. For ranking candidate layouts, Jensen with sum-of-squares is typically within one to two percentage points of the heavier models and runs in milliseconds, which is what makes a sensitivity sweep practical.
Does the thrust coefficient need to vary with wind speed?
For a screening estimate, no — a representative Ct near 0.8 covers the region below rated speed
where wakes matter most. For a bankable figure, yes: Ct falls sharply above rated speed, so a
constant value over-states losses in the strong sectors, which is precisely where the energy is.
How much does the array efficiency change with turbine count?
Substantially and non-linearly. Adding turbines to a fixed mask tightens spacing, so efficiency falls while total net energy usually keeps rising until the spacing gets very tight. That is why efficiency is a diagnostic rather than an objective — the objective is net energy, and a layout with a lower efficiency and more turbines is frequently the better project.
Should the model include turbulence-driven fatigue?
Not in this calculation, but the wake map it produces is the right input for one. Waked turbines see elevated turbulence intensity as well as reduced speed, which is a loads and maintenance question rather than an energy one. Reporting which turbines are waked in which sectors gives the loads engineer what they need without conflating two different analyses.
What array efficiency should trigger a redesign?
There is no universal threshold, but a per-sector efficiency below about 0.75 in a sector carrying more than a tenth of the energy is worth investigating — it usually means several turbines are directly in line along the energy-carrying axis, which the elliptical spacing rule in the placement stage is designed to prevent.
Related
- Wind Farm Layout & Wake Modeling — the parent workflow
- Generating Turbine Layouts with Spacing Constraints in Shapely — producing the positions this model scores
- Building Wind Roses from Met Mast Data with Python — the sector weights this calculation needs
- Calculating Wind Shear Coefficients with Python — the hub-height speeds that set the energy weighting