How to Estimate Wind Energy with a Weibull Distribution
On this page
Problem statement
Wind atlases and site reports summarise a year of wind as two numbers: the Weibull scale c and shape k. A turbine manufacturer supplies a power curve. Between them sits the annual energy estimate, and the calculation is simple once it is set up properly: integrate the power curve against the distribution. The errors come from doing something simpler instead — reading the power at the mean speed, or fitting the distribution badly at the low end, or forgetting that the curve was published for sea-level air.
This guide fits a Weibull to a year of hub-height wind at two sites, computes the energy three ways, and checks what the distribution gets right and wrong against the hourly data it came from.
Quick answer
Fit k and c, then integrate the power curve against the density:
import requests, numpy as np, pandas as pd
from scipy import stats
from windpowerlib import WindTurbine
turbine = WindTurbine(turbine_type="V126/3450", hub_height=117)
curve_v = turbine.power_curve.wind_speed.to_numpy()
curve_p = turbine.power_curve.value.to_numpy() / 1e6 # MW
def hub_speed(lat, lon, start="2023-01-01", end="2023-12-31"):
j = requests.get("https://archive-api.open-meteo.com/v1/archive", params={
"latitude": lat, "longitude": lon, "start_date": start, "end_date": end,
"hourly": "wind_speed_10m,wind_speed_100m", "wind_speed_unit": "ms",
"timezone": "UTC"}, timeout=300).json()
d = pd.DataFrame(j["hourly"])
d.index = pd.to_datetime(d.pop("time"))
alpha = np.log(d.wind_speed_100m.mean() / d.wind_speed_10m.mean()) / np.log(10)
return d.wind_speed_100m * (117 / 100) ** alpha
def weibull_energy(k, c, hours=8760):
v = np.linspace(0, 30, 3001)
pdf = stats.weibull_min.pdf(v, k, scale=c)
power = np.interp(v, curve_v, curve_p, right=0) # 0 above cut-out
return np.trapezoid(pdf * power, v) * hours # MWh
v = hub_speed(56.09, 8.24) # western Jutland
k, _, c = stats.weibull_min.fit(v[v > 0], floc=0)
e_weibull = weibull_energy(k, c)
e_hourly = np.interp(v, curve_v, curve_p, right=0).sum()
print(f"Weibull fit: k = {k:.2f}, c = {c:.2f} m/s")
print(f"energy from the Weibull: {e_weibull:,.0f} MWh "
f"(capacity factor {e_weibull / (3.45 * 8760):.1%})")
print(f"energy from the hours: {e_hourly:,.0f} MWh "
f"({e_weibull / e_hourly - 1:+.1%} difference)")
Weibull fit: k = 2.19, c = 9.72 m/s
energy from the Weibull: 16,709 MWh (capacity factor 55.3%)
energy from the hours: 16,648 MWh (+0.4% difference)
The two agree to 0.4% here: a good Weibull fit carries the energy information of 8,760 hourly values in two numbers.
Hub-height speeds, a zero-location fit, a check at the curve
1. Get hub-height speeds, not 10 m ones
The fit should be done at the height where the energy is produced, after shear extrapolation — not at 10 m and then scaled, because the shape parameter changes with height.
2. Fit by maximum likelihood with the location fixed at zero
scipy.stats.weibull_min.fit(v, floc=0) returns (k, 0, c). Leaving the location free produces a three-parameter fit that describes the data slightly better and cannot be compared with any atlas.
3. Check the fit where the power curve is steep
Between 5 and 12 m/s every error in the distribution becomes an error in energy. Compare the fitted and observed frequencies there, not just the overall goodness of fit.
4. Integrate against the power curve on a fine grid
Use a speed step of 0.01 m/s and make sure the curve returns zero above cut-out. A coarse integration or an extrapolated curve adds a percent of error for no reason.
5. Correct for air density
Scale the speeds by (ρ / 1.225) ** (1/3) before the power curve — the standard IEC method for pitch-regulated turbines.
6. Report the year-to-year spread
A single year's Weibull is a single year's wind. Fit several years or use a long-term correction, and state the interannual variability.
Which speeds produce the energy
Example 1 — which speeds produce the energy
bins = np.arange(0, 27, 3)
power = np.interp(v, curve_v, curve_p, right=0)
by_bin = pd.DataFrame({"hours": v.groupby(pd.cut(v, bins)).size(),
"energy": pd.Series(power, index=v.index).groupby(pd.cut(v, bins)).sum()})
by_bin["share of hours"] = by_bin.hours / by_bin.hours.sum()
by_bin["share of energy"] = by_bin.energy / by_bin.energy.sum()
print(by_bin[["share of hours", "share of energy"]].map(lambda x: f"{x:.0%}").to_string())
share of hours share of energy
wind_speed_100m
(0, 3] 7% 0%
(3, 6] 21% 4%
(6, 9] 30% 24%
(9, 12] 23% 38%
(12, 15] 11% 20%
(15, 18] 6% 10%
(18, 21] 2% 4%
(21, 24] 0% 0%
The 6–12 m/s range is half of the hours and 62% of the energy. Hours below 3 m/s produce nothing, and the hours above 12 m/s produce the rated output and no more.
Example 2 — mean speed, Weibull and hourly, at two sites
print(f"{'site':<12}{'mean v':>7}{'k':>6}{'hours':>9}{'Weibull':>9}{'mean-v':>9}")
for name, (lat, lon) in {"Ringkøbing": (56.09, 8.24), "Albacete": (38.99, -1.86)}.items():
s = hub_speed(lat, lon)
kk, _, cc = stats.weibull_min.fit(s[s > 0], floc=0)
cf = lambda e: e / (3.45 * 8760)
hourly = np.interp(s, curve_v, curve_p, right=0).sum()
at_mean = np.interp(s.mean(), curve_v, curve_p) * 8760
print(f"{name:<12}{s.mean():>7.2f}{kk:>6.2f}{cf(hourly):>9.1%}"
f"{cf(weibull_energy(kk, cc)):>9.1%}{cf(at_mean):>9.1%}")
site mean v k hours Weibull mean-v
Ringkøbing 8.61 2.19 55.1% 55.3% 63.4%
Albacete 5.30 1.83 24.7% 24.3% 14.2%
Reading the power curve at the mean speed is not a conservative shortcut. It overestimates at the windy site, where the mean sits on the steep part of the curve and the spread above rated is thrown away, and badly underestimates at the calm one, where the mean sits near cut-in and the energy comes from the tail.
Example 3 — ten years of the same site
v10y = hub_speed(56.09, 8.24, "2014-01-01", "2023-12-31")
yearly = v10y.groupby(v10y.index.year).apply(
lambda s: np.interp(s, curve_v, curve_p, right=0).sum() / (3.45 * len(s)))
print(yearly.map(lambda x: f"{x:.1%}").to_string())
print(f"mean {yearly.mean():.1%}, std {yearly.std():.1%} "
f"(relative {yearly.std() / yearly.mean():.1%})")
time
2014 62.1%
2015 65.1%
2016 61.0%
2017 58.4%
2018 55.2%
2019 58.5%
2020 58.0%
2021 51.3%
2022 58.4%
2023 54.8%
mean 58.3%, std 3.9% (relative 6.7%)
One year is not the resource: the gross capacity factor ranges from 51.3% in 2021 to 65.1% in 2015 at the same grid cell. A project financed on the windiest year of the decade would miss its numbers in every other one.
Why the Weibull works, and the mean fails
Why the Weibull works as well as it does
Wind speed is the magnitude of a two-dimensional velocity whose components are roughly normal; that gives a Rayleigh distribution, which is a Weibull with k = 2. Real sites deviate — k from about 1.5 to 3 — and the two-parameter Weibull absorbs most of that deviation. It fails at sites with two regimes (a sea breeze plus synoptic winds, for example), where a mixture fits better.
Why the mean speed fails in both directions
The power curve is convex below rated and concave above. When most of the distribution sits on the convex part, Jensen's inequality says the power at the mean is less than the mean power; when a lot of it sits above rated, the reverse. The error has no fixed sign, so it cannot be corrected with a factor.
Why density goes in as a speed correction
For a pitch-regulated turbine, lower density means the rotor needs a higher speed to reach the same power. Scaling the speed by the cube root of the density ratio shifts the curve along the speed axis, which is closer to the truth than scaling the power.
Calm hours, sectors and reanalysis bias
- Calm hours (v = 0) must be dropped before a Weibull fit with
floc=0, or the likelihood is undefined. - Sector-wise Weibulls (12 × 30°) are standard for layout and wake modelling.
- Cut-out hysteresis: above 22–25 m/s turbines stop and restart only below a lower speed; a simple curve slightly overestimates energy in storms.
- Reanalysis bias: ERA5 tends to underestimate high speeds over complex terrain; long-term correction against a local mast (MCP) is standard for bankable estimates.
- Net energy: subtract wakes, availability and electrical losses — typically 10–20% — before calling anything a yield.
Internal links
- Wind resource explained — the cube, the Weibull shape and shear
- How to estimate wind speed at hub height — where
hub_speedcomes from - Wind energy from the mean speed is wrong — the fix guide
- Capacity factor explained — gross against net
- Siting mask removes everything — where turbines can go at all
- How to calculate zonal statistics in Python — summarising a gridded resource per region
FAQ
How do I calculate wind energy from Weibull parameters?
Integrate the turbine's power curve multiplied by the Weibull probability density over wind speed, and multiply by the hours in the period.
What do the Weibull k and c mean?
c is the scale, close to 1.13 times the mean speed for typical k; k is the shape — lower k means a wider spread of speeds.
Can I use the mean wind speed with the power curve?
No. The error can be large and in either direction.
How good is a Weibull fit?
Usually within a few percent of the energy computed from the hourly data at the same site.
Should I fit the Weibull at 10 m or at hub height?
At hub height. The shape changes with height.
How much does annual wind energy vary?
Typically a few percent to over 10% standard deviation between years; compute it from as many years as the data allow.