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.
- 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.
- 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.
- 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.
- 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.
Pre-flight validation
The two ERA5 levels are the most useful input, so check they are both present and physically consistent before using either.
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
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.
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
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.
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.
Related
- Wind Speed & Direction Modeling — the parent workflow
- Calculating Wind Shear Coefficients with Python — the exponent this page derives hourly rather than assuming
- Interpolating Sparse Met Mast Data with Kriging — combining reanalysis with the masts that correct it
- Wind Farm Layout & Wake Modeling — the consumer of the hub-height field