How to Make a Pollution Rose in Python
On this page
Problem statement
A station's NO2 is high, and the question is where it comes from: the road outside, a depot down the street, the city centre upwind, or somewhere much further away. Concentrations alone cannot say. Concentrations joined to wind direction can β a source shows up as a sector of the compass where the mean concentration is high, and its distance shows up in how that depends on wind speed.
The pollution rose is the standard tool. It is simple to build and easy to misread, because a sector's mean depends on how often the wind blows from it, because calm hours have no direction, and because in a street canyon the wind at the station runs opposite to the wind above the roofs.
Quick answer
Join hourly concentrations to hourly wind, bin by direction sector, and average:
import io, requests, numpy as np, pandas as pd
SITES = {"MY1": (51.522530, -0.154611), "KC1": (51.521050, -0.213419)} # UK-AIR site info
def aurn_no2(site, year=2023):
text = requests.get(f"https://uk-air.defra.gov.uk/datastore/data_files/site_data/"
f"{site}_{year}.csv", timeout=120).text
d = pd.read_csv(io.StringIO(text), skiprows=4, low_memory=False)
end = pd.to_datetime(d.Date, dayfirst=True) + pd.to_timedelta(d.time.str[:2].astype(int), "h")
return pd.Series(pd.to_numeric(d["Nitrogen dioxide"], errors="coerce").to_numpy(),
index=end.dt.tz_localize("UTC"), name="no2") # hour-ending labels
def wind(lat, lon):
j = requests.get("https://archive-api.open-meteo.com/v1/archive", params={
"latitude": lat, "longitude": lon, "start_date": "2023-01-01", "end_date": "2023-12-31",
"hourly": "wind_speed_10m,wind_direction_10m", "wind_speed_unit": "ms",
"timezone": "UTC"}, timeout=120).json()
w = pd.DataFrame(j["hourly"])
w.index = pd.to_datetime(w.pop("time")).dt.tz_localize("UTC")
return w.rename(columns={"wind_speed_10m": "ws", "wind_direction_10m": "wd"})
SECTORS = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]
def rose(site):
df = wind(*SITES[site]).join(aurn_no2(site), how="inner").dropna()
df = df[df.ws >= 1.0] # calms have no direction
df["sector"] = pd.Categorical(
[SECTORS[int(((d + 22.5) % 360) // 45)] for d in df.wd], SECTORS)
return df.groupby("sector", observed=False).agg(
hours=("no2", "size"), mean_no2=("no2", "mean"))
r = rose("MY1")
r["share"] = r.hours / r.hours.sum()
print("Marylebone Road, NO2 by wind-from sector, 2023")
print(r.round({"mean_no2": 1, "share": 3}).to_string())
Marylebone Road, NO2 by wind-from sector, 2023
hours mean_no2 share
sector
N 544 23.4 0.067
NE 1049 26.0 0.128
E 661 52.2 0.081
SE 369 47.5 0.045
S 757 48.1 0.093
SW 2591 44.3 0.317
W 1637 47.4 0.200
NW 569 28.7 0.070
Two numbers per sector: how often the wind came from there, and how dirty the air was when it did. A high mean from a rare direction matters less to the annual total than a moderate one from the prevailing south-west.
Local wind, aligned times, no calms, direction and speed bins
1. Get wind for the station's location
A nearby meteorological station is best; reanalysis at the station's coordinates is a good substitute for direction. Request speeds in m/s.
2. Align the timestamps
AURN hourly means are labelled at the end of the hour; Open-Meteo wind is instantaneous at the stamp. Joining on the end-of-hour label pairs each mean with the wind at the end of its hour β close enough for direction, which changes slowly.
3. Drop the calms
Below about 1 m/s the direction is meaningless. Calm hours are often the most polluted, so report them as their own category rather than scattering them across sectors.
4. Bin by direction β and by speed if you can
Eight or sixteen sectors for direction. Adding speed bins separates local sources (high at low speeds, diluted by wind) from distant ones (high at higher speeds, carried in by it).
5. Weight by frequency for contributions
The mean concentration per sector says how polluted each direction is. Mean Γ hours says how much each direction contributes to the annual mean. Both are useful; they answer different questions.
6. Remember the street canyon
In a street lined with buildings, the roof-level wind drives a vortex: at street level the air moves the other way. A kerbside station's rose reflects which side of the street it is on as much as where the sources are.
Kerbside against background roses
The data used on this page
Hourly measurements from the UK's Automatic Urban and Rural Network, downloaded by the page's own code from DEFRA's site-data archive: London North Kensington (KC1), London Marylebone Road (MY1). The files are ratified data; the hour in each row is the end of the measurement hour, in GMT. Hourly wind at the site comes from the Open-Meteo historical weather API, also fetched by the page's code.
import requests
print("KC1_2023.csv:", requests.head("https://uk-air.defra.gov.uk/datastore/data_files/site_data/KC1_2023.csv", timeout=60, allow_redirects=True).status_code)
print("MY1_2023.csv:", requests.head("https://uk-air.defra.gov.uk/datastore/data_files/site_data/MY1_2023.csv", timeout=60, allow_redirects=True).status_code)
KC1_2023.csv: 200
MY1_2023.csv: 200
Example 1 β kerbside against background
both = pd.DataFrame({"MY1 kerbside": rose("MY1").mean_no2,
"KC1 background": rose("KC1").mean_no2})
both["ratio"] = both["MY1 kerbside"] / both["KC1 background"]
print(both.round(1).to_string())
MY1 kerbside KC1 background ratio
sector
N 23.4 17.3 1.4
NE 26.0 16.1 1.6
E 52.2 25.6 2.0
SE 47.5 24.7 1.9
S 48.1 17.7 2.7
SW 44.3 13.8 3.2
W 47.4 19.4 2.4
NW 28.7 18.7 1.5
The background site's rose is the city: highest from the east and south-east, when the wind brings air across central London, and lowest from the south-west, when it arrives from the countryside. The kerbside site adds its road on top, and the ratio between the two shows in which directions the local source dominates.
Example 2 β direction and speed together
df = wind(*SITES["MY1"]).join(aurn_no2("MY1"), how="inner").dropna()
df["speed"] = pd.cut(df.ws, [0, 1, 3, 5, 20], labels=["calm <1", "1β3", "3β5", ">5 m/s"])
df["sector"] = pd.Categorical(
[SECTORS[int(((d + 22.5) % 360) // 45)] for d in df.wd], SECTORS)
table = df.pivot_table(index="speed", columns="sector", values="no2",
aggfunc="mean", observed=False).round(0)
print(table.to_string())
print(f"\ncalm hours: {(df.ws < 1).sum()}, mean NO2 {df[df.ws < 1].no2.mean():.1f} Β΅g/mΒ³")
sector N NE E SE S SW W NW
speed
calm <1 57.0 59.0 63.0 52.0 64.0 62.0 57.0 59.0
1β3 28.0 36.0 53.0 50.0 54.0 50.0 49.0 33.0
3β5 20.0 23.0 53.0 50.0 49.0 46.0 49.0 27.0
>5 m/s 21.0 19.0 50.0 37.0 42.0 40.0 45.0 19.0
calm hours: 175, mean NO2 60.0 Β΅g/mΒ³
In the northern sectors concentrations fall steeply with speed, from the high 50s in calm air to about 20 above 5 m/s β a local, ground-level source being diluted. In the southern sectors they barely move: 53 to 50 from the east, 49 to 45 from the west. At this kerb, with a southerly component in the wind, the canyon vortex keeps delivering the road's exhaust whatever the speed. A distant source would show a third pattern, rising with speed in its sector as the wind brings it in.
Example 3 β contribution to the annual mean
r = rose("MY1")
r["contribution"] = r.mean_no2 * r.hours / r.hours.sum()
r["share of annual mean"] = r.contribution / r.contribution.sum()
print(r[["hours", "mean_no2", "share of annual mean"]].round(
{"mean_no2": 1, "share of annual mean": 3}).to_string())
hours mean_no2 share of annual mean
sector
N 544 23.4 0.038
NE 1049 26.0 0.081
E 661 52.2 0.102
SE 369 47.5 0.052
S 757 48.1 0.108
SW 2591 44.3 0.340
W 1637 47.4 0.230
NW 569 28.7 0.048
The prevailing south-westerlies contribute the largest share of the annual mean even if other sectors are dirtier per hour. A policy aimed at the dirtiest direction and a policy aimed at the largest contribution can target different sources.
Speed separates near from far
Why wind speed separates near from far
A ground-level source next to the station is diluted by the wind: double the speed, roughly halve the concentration. A tall stack or a distant city is brought to the station by the wind; low speeds leave its plume elsewhere or aloft. So a sector whose concentration rises with speed points to something far away or elevated, and one whose concentration falls points to something close and low.
Why the canyon vortex matters for Marylebone Road
Marylebone Road runs roughly eastβwest between tall buildings, and the station sits on its south side. When the roof-level wind blows across the street, a vortex forms inside it and the street-level flow carries exhaust towards the leeward wall β the wall facing downwind. With a southerly component in the roof-level wind, the leeward wall is the south one, and that is where the station is: 44β52 Β΅g/mΒ³ from every sector between east and west through south, against 23β29 from the north. The kerbside rose is, to a large degree, a measurement of the street's geometry.
Why reanalysis wind is acceptable here
ERA5 at 30 km cannot see individual streets, but wind direction at roof level and above is coherent across a city. For a rose, direction matters more than exact speed. For the canyon itself, only a local measurement or a flow model would do.
Direction conventions, circular means and CPF
- Direction convention: meteorological direction is where the wind comes from; 270Β° is a westerly.
- Circular averaging: never average directions as numbers β 350Β° and 10Β° average to 180Β°. Average the u and v components.
- Sector width: sixteen sectors need several years of data for stable means in rare directions.
- Conditional probability function (CPF): the share of hours in each sector above a high percentile is a sharper source-finder than the mean.
- openair in R popularised bivariate polar plots; in Python, bin as above and plot with matplotlib's polar axes.
Internal links
- How to analyse an air quality time series β the time structure the rose leaves out
- Air quality monitoring networks explained β kerbside against background sites
- How to build a land-use regression model for NO2 β explaining concentrations by their surroundings
- Wind resource explained β the same wind data, for energy
- How to download air quality station data in Python β the AURN loader
- Air pollutants and their units explained β what NO2 is and where it comes from
FAQ
What is a pollution rose?
A summary of a pollutant's concentration by wind direction β mean, percentile or frequency above a threshold β for each compass sector.
Where can I get wind data for a monitoring station?
From a nearby weather station, or from reanalysis (ERA5 via Open-Meteo) at the station's coordinates.
Should I include calm hours?
Report them separately. Below about 1 m/s the direction is meaningless, and calms are often the most polluted hours.
How do I average wind directions?
Convert to u and v components, average those, and convert back.
How do I tell a local source from a distant one?
Local ground-level sources get diluted as wind speed rises; distant or elevated ones appear at higher speeds.
Why does a kerbside rose look odd?
Street canyons reverse the flow at street level, so the station's side of the street shapes its rose.