How to Make a Wind Rose in Python
On this page
Problem statement
A wind rose summarises a year of wind at one place on a single figure: how often the wind comes from each direction, and how strong it is when it does. Airports use roses to align runways, engineers to site turbines and stacks, planners to judge where smoke and noise will go. The plot itself is a stacked bar chart on polar axes. The work is in the data: choosing which reports to count, keeping calm and variable winds out of the directional bars but in the total, and binning directions so that north is a whole sector, not two halves.
This page builds a wind rose for Chicago O'Hare International Airport for 2024 from NOAA's Integrated Surface Database, with matplotlib alone, and compares winter with summer.
Quick answer
Keep one report per hour, decode the wind field, bin direction into 16 sectors centred on north, and stack speed classes on polar bars:
SECTORS = 16
width = 360 / SECTORS
H["sector"] = ((H.dir + width / 2) // width % SECTORS).astype("Int64") # sector 0 is centred on north
bins, labels = [0, 3, 6, 9, 12, 99], ["0โ3", "3โ6", "6โ9", "9โ12", "12+"]
H["speed_class"] = pd.cut(H.speed, bins, labels=labels, right=False)
table = pd.crosstab(H.sector, H.speed_class).reindex(range(SECTORS), fill_value=0) / len(ALL) * 100 # % of all hours
ax = plt.figure(figsize=(6, 6)).add_subplot(projection="polar")
ax.set_theta_zero_location("N"); ax.set_theta_direction(-1) # compass, not maths, angles
bottom = np.zeros(SECTORS)
for lab in labels:
ax.bar(np.radians(np.arange(SECTORS) * width), table[lab], width=np.radians(width) * 0.95, bottom=bottom, label=f"{lab} m/s")
bottom += table[lab].values
plt.legend(loc="lower left", bbox_to_anchor=(1.0, 0)); plt.savefig("ohare_rose.png", dpi=150, bbox_inches="tight")
top = table.sum(axis=1).idxmax()
print(f"{len(ALL):,} hourly reports: {len(H):,} with a direction, {CALM} calm, {VAR} variable")
print(f"most frequent sector: {NAMES[top]} ({table.sum(axis=1)[top]:.1f}% of hours)")
8,756 hourly reports: 8,078 with a direction, 517 calm, 160 variable
most frequent sector: W (9.8% of hours)
Step-by-step solution
1. Count one report per hour
ISD holds several kinds of report for the same station: routine hourly METARs (FM-15), special reports issued when the weather changes (FM-16), synoptic reports (FM-12) and daily summaries. Counting them all over-weights the hours when the weather was changing:
print(RAW.REPORT_TYPE.str.strip().value_counts().to_string())
print(f"hours in 2024: {366 * 24:,}; routine FM-15 reports: {len(ALL):,}")
REPORT_TYPE
FM-15 8756
FM-12 2766
FM-16 2140
SOD 353
SY-MT 14
SOM 12
hours in 2024: 8,784; routine FM-15 reports: 8,756
2. Decode the wind field
WND holds direction, a quality code, a type code, speed in tenths of a metre per second, and a quality code. Calm (C) and variable (V) winds have no usable direction:
print(ALL.wtype.value_counts().rename({"N": "normal", "C": "calm", "V": "variable", "9": "missing"}).to_string())
print(f"direction 999 (missing or variable): {(ALL.dir_raw == '999').sum()}; speed 9999 (missing): {(ALL.spd_raw == '9999').sum()}")
wtype
normal 8079
calm 517
variable 160
direction 999 (missing or variable): 627; speed 9999 (missing): 1
Calm hours belong in the denominator but not in any direction, so the bars are percentages of all hours and the calm percentage is printed in the middle of the rose.
3. Centre the sectors on the compass points
A naive direction // 22.5 makes the first sector 0โ22.5ยฐ, so winds from 350ยฐ and from 10ยฐ, both northerly, fall in different sectors. Shifting by half a sector centres sector 0 on north:
naive = (H.dir // width).astype(int)
print(f"reports from 349โ360ยฐ and 0โ11ยฐ: {((H.dir >= 349) | (H.dir < 11.25)).sum()}; "
f"naive sector 0 holds {(naive == 0).sum()}, centred sector 0 holds {(H.sector == 0).sum()}")
reports from 349โ360ยฐ and 0โ11ยฐ: 536; naive sector 0 holds 403, centred sector 0 holds 536
Code examples
The data used on this page
NOAA's Integrated Surface Database, Global Hourly access file for Chicago O'Hare International Airport (station 725300-94846) for 2024.
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import requests
Path("data").mkdir(exist_ok=True)
F = Path("data/72530094846.csv")
if not F.exists():
F.write_bytes(requests.get("https://www.ncei.noaa.gov/data/global-hourly/access/2024/72530094846.csv", timeout=300).content)
RAW = pd.read_csv(F, usecols=["DATE", "REPORT_TYPE", "NAME", "WND"], dtype=str)
ALL = RAW[RAW.REPORT_TYPE.str.strip() == "FM-15"].copy()
ALL[["dir_raw", "dq", "wtype", "spd_raw", "sq"]] = ALL.WND.str.split(",", expand=True)
ALL["time"] = pd.to_datetime(ALL.DATE)
ALL["speed"] = pd.to_numeric(ALL.spd_raw.replace("9999", np.nan)) / 10 # tenths of m/s
ALL["dir"] = pd.to_numeric(ALL.dir_raw.replace("999", np.nan))
CALM, VAR = (ALL.wtype == "C").sum(), (ALL.wtype == "V").sum()
H = ALL[(ALL.wtype == "N") & ALL.dir.notna() & ALL.speed.notna()].copy()
NAMES = ["N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"]
print(f"{RAW.NAME.iloc[0]}: {len(RAW):,} reports in 2024, {len(ALL):,} routine hourly")
CHICAGO OHARE INTERNATIONAL AIRPORT, IL US: 14,041 reports in 2024, 8,756 routine hourly
Example 1: winter against summer
for label, months in (("DecโFeb", [12, 1, 2]), ("JunโAug", [6, 7, 8])):
s = H[H.time.dt.month.isin(months)]
share = s.sector.value_counts(normalize=True).sort_index() * 100
print(f"{label}: most frequent {NAMES[share.idxmax()]} ({share.max():.1f}%), mean speed {s.speed.mean():.1f} m/s; "
f"from the western half (SSW to NNW): {share.loc[9:15].sum():.0f}%")
DecโFeb: most frequent W (11.5%), mean speed 4.6 m/s; from the western half (SSW to NNW): 62%
JunโAug: most frequent S (11.6%), mean speed 4.0 m/s; from the western half (SSW to NNW): 49%
In winter 62% of the hours with a direction came from the western half of the compass, against 49% in summer, when south was the most frequent sector and the winds were lighter.
Example 2: the numbers behind the rose
The rose is a picture of this table; publish the table with it:
out = table.set_axis([NAMES[i] for i in table.index])
out["all"] = out.sum(axis=1)
out = out.round(1)
print(out.sort_values("all", ascending=False).head(5).to_string())
print(f"directional bars add up to {table.values.sum():.1f}% of hours; calm {CALM / len(ALL):.1%}, variable {VAR / len(ALL):.1%}")
speed_class 0โ3 3โ6 6โ9 9โ12 12+ all
W 2.2 4.8 2.3 0.4 0.0 9.8
S 2.2 5.0 1.8 0.4 0.0 9.4
SSW 1.6 3.8 1.9 0.4 0.0 7.6
SW 1.8 3.8 1.6 0.3 0.0 7.4
WSW 1.5 3.3 1.6 0.2 0.0 6.7
directional bars add up to 92.3% of hours; calm 5.9%, variable 1.8%
Explanation
A wind rose is a frequency distribution of a vector, collapsed onto direction. It answers "from where, how often, how strong", and loses everything about when: a rose cannot show whether the summer southerlies blow by day or by night, or which weather brought the strongest winds. Splitting by season or time of day recovers some of that, at the cost of more roses.
The design choices change what a rose shows. Sixteen sectors suit most purposes; thirty-six follow the reported 10ยฐ resolution but scatter the data thinly. Bar length can be linear in frequency, as here, or proportional to the square root so that area is proportional to frequency, which reads more fairly but is less common. Whatever the choices, the percentages must say what they are of: all hours, or only hours with a direction.
Edge cases or notes
- Report types: use
FM-15for hourly roses; addingFM-16specials over-weights changeable weather. - Reported resolution: METAR directions are rounded to 10ยฐ, so 16 sectors of 22.5ยฐ get unequal numbers of possible values; 36 sectors avoid that.
- Height: airport winds are measured at about 10 m; other sites differ, and speed changes with height.
- Calm threshold: calm is below about 1.5 m/s in METAR practice; low-speed bins are partly calm by another name.
- Missing hours: check how many hours are missing before treating percentages as climate.
Internal links
- How to calculate wind speed and direction from u and v โ the convention behind the directions
- Wind resource explained โ wind speed distributions for energy
FAQ
How do I make a wind rose in Python without the windrose package?
Use matplotlib polar axes with set_theta_zero_location("N") and set_theta_direction(-1), and stack bar calls, one per speed class.
Where can I get hourly wind data for a station?
From NOAA's Integrated Surface Database (Global Hourly), one CSV per station and year, or from the station's national weather service.
Should calm winds be included in a wind rose?
Yes, in the total: show their percentage in the centre and compute the sector percentages over all hours.
Why is north split across two sectors in my wind rose?
The sectors start at 0ยฐ instead of being centred on it; shift directions by half a sector before binning.