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.

  1. 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.
  2. A decay constant borrowed from the wrong environment. The Jensen wake decay constant k is 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.
  3. 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.
  4. 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.
Hours share against energy share, by sector A paired bar chart over eight direction sectors. For each sector, the share of hours and the share of annual energy are drawn side by side. The west-south-westerly sector carries 21 percent of hours and 34 percent of energy; west carries 14 and 19; south-west 12 and 14; and the four light sectors together carry 29 percent of hours and 12 percent of energy. A note explains that the divergence follows the cube of the sector mean speed and that weighting wake losses by frequency under-weights the strong sectors. The strong sectors carry more energy than hours 21 34 WSW 14 19 W 12 14 SW 9 8 S 8 9 NW 7 6 SSW 6 4 N 23 6 others share of hours share of annual energy (freq × mean³) Weighting by frequency under-weights WSW by 13 percentage points — the sector where turbines are most likely to be in line and where each waked hour costs the most.

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.

python
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

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

Linear addition against sum-of-squares superposition A diagram of one evaluated turbine with three upwind turbines at 5, 8 and 12 rotor diameters, producing velocity deficits of 12, 9 and 6 percent respectively. Two combination results are shown: linear addition giving a 27 percent deficit and a 61 percent power loss, and sum-of-squares giving 16.2 percent and a 41 percent power loss. A note explains that sum-of-squares is an engineering compromise rather than a first-principles result, and that the deficit must be clamped below one to avoid negative power in dense clusters. Three wakes on one turbine 5 D · 12% 8 D · 9% 12 D · 6% evaluated turbine linear addition 27.0% 61% power loss sum of squares 16.2% 41% power loss Sum-of-squares is a compromise, not a derivation — it matches measurements far better than linear addition and needs a clamp below 1.0, or a dense cluster produces a negative power.

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_d diameters 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

python
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"
    )
Per-sector efficiency, and the one sector that sets the answer A polar plot of array efficiency by direction sector for a 24-turbine layout, with the radius running from 0.6 at the centre to 1.0 at the rim. Most sectors sit between 0.95 and 0.99. Three adjacent sectors around west-south-west drop to between 0.71 and 0.82, forming a clear notch. An annotation marks the west-south-westerly sector as carrying 34 percent of the annual energy, and a summary gives the energy-weighted array efficiency as 0.902 against an unweighted sector mean of 0.94. Sixteen sectors, one that decides the number 0.6 0.7 0.8 0.9 1.0 N E S W WSW notch — 0.71 WSW carries 34% of annual energy and returns 0.71 efficiency — three rows line up along the axis unweighted sector mean 0.94 energy-weighted result 0.902 The gap between the two numbers is the whole argument for energy weighting

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.