Extrapolating Hub-Height Wind Speeds from ERA5 Reanalysis

The scenario: a prospecting screen ranks sites on ERA5 100-metre wind speed, a developer builds a campaign around the top of the list, and the first met mast comes in 12 percent below the reanalysis. ERA5 is not wrong — it is a 31-kilometre grid representing an area average over terrain it barely resolves, and using it as a site value skips two corrections and an uncertainty. This page does the extrapolation properly, and it extends wind speed and direction modeling.

Root-cause analysis

Four gaps separate a reanalysis grid value from a hub-height site estimate.

  1. Resolution. ERA5 cells are roughly 31 kilometres across and the model terrain is smoothed to match. A ridge that gains 15 percent over the surrounding plain does not exist in the model, and neither does the sheltering behind it.
  2. Height. ERA5 publishes 10-metre and 100-metre winds; hubs are commonly 120 to 160 metres. The pair gives a shear exponent per hour, which is far better than assuming one — but extrapolating above 100 metres is still extrapolation.
  3. Surface roughness. The model’s roughness is its own, derived from a land-cover climatology at model resolution. Where the real site is smoother or rougher, the whole profile shifts.
  4. Bias. Reanalysis has known regional biases, often several percent and signed consistently within a region. A measure-correlate-predict step against any nearby mast removes most of it.
From a reanalysis cell value to a site hub-height estimate A four-step waterfall. The raw ERA5 100-metre cell value is 7.9 metres per second. Extrapolation to a 140-metre hub using hourly shear derived from the 10 and 100 metre pair adds 0.4. A roughness correction for a site smoother than the model cell adds 0.2. A measure-correlate-predict correction against a nearby mast removes 0.6, reflecting a regional high bias of about 7 percent. The corrected estimate is 7.9 metres per second, annotated as coincidentally equal to the raw value — each step was still necessary, and skipping any one of them would have produced a different answer. ERA5 cell → hub-height site estimate 7.9 ERA5 100 m cell 0.4 + shear to 140 m 0.2 + roughness correction 0.6 regional bias (MCP) 7.9 corrected hub estimate The corrected value equals the raw one by coincidence. Skipping any single step would have produced a different answer — which is why "ERA5 says 7.9" is not the same claim as this one.

Pre-flight validation

The two ERA5 levels are the most useful input, so check they are both present and physically consistent before using either.

python
import numpy as np
import xarray as xr


def preflight_era5_pair(ds: xr.Dataset) -> dict:
    """Both levels present, both plausible, and a shear exponent that is physical."""
    required = {"u10", "v10", "u100", "v100"}
    missing = required - set(ds.data_vars)
    if missing:
        raise ValueError(f"ERA5 extract missing {sorted(missing)} — both levels are needed for shear")

    ws10 = np.hypot(ds["u10"], ds["v10"])
    ws100 = np.hypot(ds["u100"], ds["v100"])

    with np.errstate(divide="ignore", invalid="ignore"):
        alpha = np.log(ws100 / ws10) / np.log(100.0 / 10.0)

    finite = alpha.where(np.isfinite(alpha))
    return {
        "hours": int(ds.sizes.get("time", 0)),
        "mean_ws10": float(ws10.mean()),
        "mean_ws100": float(ws100.mean()),
        "median_alpha": float(finite.median()),
        "alpha_p5": float(finite.quantile(0.05)),
        "alpha_p95": float(finite.quantile(0.95)),
        "negative_alpha_share": float((finite < 0).mean()),
        "note": "negative alpha is physical at night but a large share suggests a level mix-up",
    }

A median shear exponent far outside 0.10 to 0.30 over land, or a negative share above about 15 percent, usually means the two levels were swapped or one is a different variable than assumed.

Fix implementation

python
import numpy as np
import xarray as xr


def hub_height_from_era5(
    ds: xr.Dataset,
    *,
    hub_height_m: float,
    site_roughness_m: float | None = None,
    era5_roughness_m: float | None = None,
    alpha_clip: tuple[float, float] = (0.05, 0.40),
) -> xr.DataArray:
    """Hourly hub-height wind speed from the ERA5 10 m / 100 m pair."""
    ws10 = np.hypot(ds["u10"], ds["v10"])
    ws100 = np.hypot(ds["u100"], ds["v100"])

    # Hourly shear from the pair, clipped to a physical band so a calm hour
    # cannot produce an exponent that explodes on extrapolation.
    alpha = np.log(ws100 / ws10.where(ws10 > 0.5)) / np.log(10.0)
    alpha = alpha.clip(*alpha_clip).fillna(0.14)

    ws_hub = ws100 * (hub_height_m / 100.0) ** alpha

    # Optional roughness correction: shift the profile when the site surface
    # differs from the model's own roughness for that cell.
    if site_roughness_m and era5_roughness_m:
        log_ratio = (
            np.log(hub_height_m / site_roughness_m) / np.log(hub_height_m / era5_roughness_m)
        )
        ws_hub = ws_hub * log_ratio

    ws_hub.name = "wind_speed_hub"
    ws_hub.attrs.update({
        "hub_height_m": hub_height_m,
        "method": "ERA5 10/100 m shear, clipped, power-law extrapolation",
        "alpha_clip": alpha_clip,
        "roughness_corrected": bool(site_roughness_m and era5_roughness_m),
    })
    return ws_hub


def bias_correct_against_mast(
    modelled: xr.DataArray,
    mast_ws: xr.DataArray,
    *,
    method: str = "ratio",
) -> tuple[xr.DataArray, dict]:
    """Measure-correlate-predict, in its simplest defensible form."""
    common = xr.align(modelled, mast_ws, join="inner")
    m, o = common[0], common[1]
    if len(m) < 24 * 30 * 6:
        raise ValueError("under six months of concurrent data — MCP is not defensible")

    if method == "ratio":
        factor = float(o.mean() / m.mean())
        corrected = modelled * factor
        params = {"method": "ratio", "factor": factor}
    else:
        slope = float(((m - m.mean()) * (o - o.mean())).sum() / ((m - m.mean()) ** 2).sum())
        intercept = float(o.mean() - slope * m.mean())
        corrected = modelled * slope + intercept
        params = {"method": "linear", "slope": slope, "intercept": intercept}

    resid = o - (m * params.get("factor", params.get("slope", 1.0)))
    params["r"] = float(np.corrcoef(m.values.ravel(), o.values.ravel())[0, 1])
    params["residual_std_ms"] = float(resid.std())
    params["concurrent_hours"] = int(len(m))
    return corrected, params

Clipping the hourly shear exponent is the detail that prevents the worst failure. In a calm hour the 10-metre speed approaches zero, the ratio explodes, and an unclipped exponent extrapolated to 140 metres produces a wind speed of hundreds of metres per second in a handful of hours — which then dominates any energy calculation because power goes with the cube.

Uncertainty terms on a reanalysis hub-height estimate A stacked comparison for two site types. On flat terrain: representativeness 3 percent, extrapolation 3 percent, bias-correction residual 4 percent, combining in quadrature to about 5.8 percent on wind speed and roughly 17 percent on energy. In complex terrain: representativeness 15 percent, extrapolation 4 percent, bias residual 5 percent, combining to about 16.3 percent on speed and about 50 percent on energy. A note observes that no post-processing reduces the representativeness term — only measurement or downscaling does. What is left after every correction flat terrain ≈ 5.8% on speed · ≈ 17% on energy complex terrain 15% ≈ 16.3% on speed · ≈ 50% on energy representativeness extrapolation 100→140 m MCP residual Only measurement or mesoscale downscaling reduces the representativeness term. Every other correction on this page leaves it exactly where it was, which is why it dominates in complex terrain.

Fallback routing and performance tuning

  • Download the pair, not just 100 metres. The 10-metre level is what makes the shear hourly rather than assumed, and it doubles the transfer for a large improvement.
  • Extract by point, not by area, for a site. ERA5 is served as gridded NetCDF, and the nearest-cell time series for one site is a few megabytes against tens of gigabytes for a regional extract.
  • Interpolate between cells with care. Bilinear interpolation of wind components is defensible; interpolating speed and direction separately is not, for the same reason bearings cannot be averaged directly.
  • Cache by cell, not by site. Several prospects usually fall in one ERA5 cell, so a cache keyed on the cell index serves them all from one download.
  • Do the MCP once per region. The bias is regional and slowly varying, so a correction derived from one good mast usually improves every site within tens of kilometres.

Downstream validation

python
def assert_hub_estimate_defensible(ws_hub, params: dict, *, hub_height_m: float) -> None:
    """Bounds and provenance for a reanalysis-derived hub-height series."""
    mean_ws = float(ws_hub.mean())
    assert 2.0 <= mean_ws <= 12.0, f"mean hub-height wind {mean_ws:.2f} m/s outside a plausible band"
    assert float(ws_hub.max()) < 45.0, "extrapolated speeds above 45 m/s — check the shear clip"
    assert ws_hub.attrs.get("hub_height_m") == hub_height_m, "hub height not recorded on the output"
    assert "method" in ws_hub.attrs, "extrapolation method not recorded"
    if params:
        assert params.get("concurrent_hours", 0) >= 24 * 30 * 6, "MCP on under six months of data"
        assert params.get("r", 0) >= 0.7, (
            f"correlation {params.get('r'):.2f} too low for a defensible bias correction"
        )

What the residual uncertainty actually is

After extrapolation, roughness correction and bias correction, a reanalysis-derived hub-height mean still carries uncertainty, and stating it is what separates a prospecting number from a made-up one.

Representativeness is the largest term at complex sites: the ERA5 cell is an area average, and the site may sit on a ridge or in a valley the model does not resolve. On flat terrain this is a few percent; in complex terrain it can exceed 15 and no post-processing removes it.

Extrapolation above 100 metres adds a term that grows with the height ratio and with the spread of the hourly shear exponent. From 100 to 140 metres with a well-behaved shear distribution it is typically 2 to 4 percent on the mean speed — and because energy goes with the cube, 6 to 12 percent on energy.

Bias correction residual is what the MCP leaves behind, and it is measurable: the residual standard deviation and the correlation coefficient from the fit quantify it directly. A correlation of 0.85 and a residual of 1.1 metres per second is a useful correction; 0.6 and 2.4 is a warning that the mast and the cell are not describing the same wind.

Reporting a single hub-height number without these three is what produced the opening scenario. A prospecting estimate should read “7.6 metres per second, plus or minus 0.6, from ERA5 with a ridge-representativeness caveat” — which ranks sites just as well and does not promise what it cannot deliver.

The three numbers a bias correction should report A scatter of concurrent hourly wind speeds with the ERA5 cell value on the horizontal axis and the site mast on the vertical, with a fitted line of slope 0.93 passing near the origin and a one-to-one reference line above it. Three summary values are given beside it: a correlation of 0.87, a ratio of 0.93 meaning the reanalysis reads 7 percent high, and a residual standard deviation of 1.1 metres per second over 8,760 concurrent hours. A note records that a correction reported as a single factor hides the correlation and the residual, which together say how much the factor can be trusted. 8 760 concurrent hours · ERA5 cell against the site mast 0 0 6 6 12 12 18 18 ERA5 m/s mast m/s 1:1 correlation r 0.87 ratio (mast ÷ ERA5) 0.93 residual σ 1.1 m/s concurrent hours 8 760 A factor without r and σ is a correction with no error bar

Frequently asked questions

Is ERA5 good enough to site a project?

For prospecting and for long-term correlation, yes. For a financeable energy estimate, no — that needs on-site measurement, with the reanalysis used to extend the short measurement record to a long-term climatology. That is exactly the division of labour the MCP step above implements.

Should I use ERA5 or a mesoscale downscaled product?

A downscaled product where one exists for the region, because the representativeness term is the dominant uncertainty and downscaling is what reduces it. ERA5’s advantages are global coverage, a long consistent record and no licensing friction, which make it the right default when a downscaled product is unavailable or its vintage is unknown.

How many years should the extract cover?

Twenty or more for a long-term mean, because interannual variability in wind speed is several percent and energy scales with the cube. For a bias correction against a mast, only the concurrent period matters, and six months is the practical minimum — a full year is better because it covers the seasonal cycle.

Can the same approach give a wind rose?

Yes, and it is one of the more reliable things reanalysis provides. Direction is far less sensitive to resolution than speed, so an ERA5-derived rose is usually a good approximation of the site regime even where the speed needs substantial correction. Decompose to components before any interpolation, for the reasons given in the parent workflow.

What does a negative shear exponent mean?

That wind speed decreased with height in that hour, which is physical during a low-level jet or a strongly stable night, and also what a sensor fault or an icing event looks like. A small share of negative hours is expected; a large share means the levels were swapped. Clipping handles the production path and the share belongs in the pre-flight report.

How should the estimate be recorded?

With the hub height, the extrapolation method, the shear clip, whether a roughness correction was applied, the MCP parameters and the concurrent period they came from. Those six fields are what let someone else reproduce the number — and the assertion above refuses to publish a series that is missing them.