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.

Mean NO2 at Marylebone Road by wind-from direction sector in 2023, with the share of hours from each. NO2 by wind direction, Marylebone RoadN Β· 7% of hours23.4NE Β· 13%26.0E Β· 8%52.2SE Β· 5%47.5S Β· 9%48.1SW Β· 32%44.3 β€” the prevailing windW Β· 20%47.4NW Β· 7%28.7030 Β΅g/mΒ³60 Β΅g/mΒ³Any southerly component: 44–52; northerly: 23–29 spatialworkflow.io
Mean NO2 by the direction the wind blew from.

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.

Cross-section of a street canyon with the roof-level wind from the south and the reverse vortex carrying exhaust to the south-side kerb. roof-level wind from the south, looking east along the streetsouth sidenorth sidewind above the roofsvortextrafficMY1street-level flow runs north to south, carrying exhaust to the south kerb spatialworkflow.io
In a street canyon the ground-level flow reverses: one side of the street gets the exhaust.

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.

Mean NO2 at Marylebone Road by wind speed band for northern and southern sectors in 2023. speed dilutes only the northern sectorsNNEESWcalm < 1 m/s57596364571–3 m/s28365354493–5 m/s2023534949> 5 m/sβœ“21βœ“19!50!42!45Β΅g/mΒ³; 175 calm hours averaged 60 Β΅g/mΒ³ spatialworkflow.io
From the north, wind dilutes the road; from the south, the canyon keeps delivering it.

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.

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.