Air Quality Indices Explained
On this page
Problem statement
A map of air quality index values looks like one quantity on one scale. It is a stack of decisions: which pollutants count, over what averaging period each is measured, where the band boundaries sit, and what happens when several pollutants are elevated at once. The United States, the United Kingdom and the European Union answer those questions differently, so the same air in London can be "Moderate" on one index and "Low" on another on most days of the year.
An index is a communication tool built on top of concentrations. Using it as an analysis variable — averaging it, interpolating it, correlating it — works against how it is constructed.
Quick answer
Every common AQI works the same way:
- Average each pollutant over its own period (1 h for NO2, 8 h for ozone, 24 h for particles).
- Look up each average in that pollutant's breakpoint table — a piecewise-linear scale for the US AQI, a step function for the UK DAQI.
- Report the highest sub-index, and name the pollutant responsible.
import io, requests, numpy as np, pandas as pd
# US AQI, PM2.5 24-hour mean in µg/m³ (breakpoints revised by the EPA in 2024)
US_PM25 = [(0.0, 9.0, 0, 50), (9.1, 35.4, 51, 100), (35.5, 55.4, 101, 150),
(55.5, 125.4, 151, 200), (125.5, 225.4, 201, 300), (225.5, 325.4, 301, 500)]
def us_subindex(c, table):
c = np.floor(c * 10) / 10 # truncate to the table's precision
for lo, hi, ilo, ihi in table:
if lo <= c <= hi:
return round((ihi - ilo) / (hi - lo) * (c - lo) + ilo)
return 500 if c > table[-1][1] else np.nan
# UK DAQI, PM2.5 24-hour mean in µg/m³: a step function, bands 1–10
UK_PM25 = [11, 23, 35, 41, 47, 53, 58, 64, 70] # upper edges of bands 1–9
def uk_band(c, edges):
if np.isnan(c):
return np.nan # searchsorted would put NaN in the top band
return int(np.searchsorted(edges, np.floor(c), side="left")) + 1
text = requests.get("https://uk-air.defra.gov.uk/datastore/data_files/site_data/MY1_2023.csv",
timeout=120).text
d = pd.read_csv(io.StringIO(text), skiprows=4, low_memory=False)
pm = pd.to_numeric(d["PM<sub>2.5</sub> particulate matter (Hourly measured)"], errors="coerce")
daily = pm.groupby(d["Date"]).mean().dropna()
us = daily.map(lambda c: us_subindex(c, US_PM25))
uk = daily.map(lambda c: uk_band(c, UK_PM25))
us_cat = pd.cut(us, [-1, 50, 100, 150, 200, 300, 500],
labels=["Good", "Moderate", "USG", "Unhealthy", "Very unhealthy", "Hazardous"])
uk_cat = pd.cut(uk, [0, 3, 6, 9, 10], labels=["Low", "Moderate", "High", "Very high"])
print(f"Marylebone Road, PM2.5, {len(daily)} days of 2023")
print("US AQI: ", us_cat.value_counts().reindex(us_cat.cat.categories).to_dict())
print("UK DAQI:", uk_cat.value_counts().reindex(uk_cat.cat.categories).to_dict())
Marylebone Road, PM2.5, 356 days of 2023
US AQI: {'Good': 123, 'Moderate': 231, 'USG': 2, 'Unhealthy': 0, 'Very unhealthy': 0, 'Hazardous': 0}
UK DAQI: {'Low': 355, 'Moderate': 1, 'High': 0, 'Very high': 0}
Same site, same days, same pollutant. The difference is entirely in where each index draws its first line: the EPA's 2024 revision put "Good" at 9 µg/m³, while the DAQI's lowest band runs to 11 and "Low" covers three bands.
Name the index, compute each pollutant on its own period
1. Name the index and its version
"AQI" alone is ambiguous. The US AQI changed its PM2.5 breakpoints in 2024; the European index has been revised too. Record the index, its version and the country it belongs to next to every value.
2. Compute each pollutant on its own averaging period
Particles on 24-hour means, ozone on 8-hour running means, NO2 on hourly values (for the DAQI and the US index alike). An index computed from the wrong period is a different index.
3. Keep the concentrations as the analysis variable
Average, interpolate and model the concentrations. Convert to an index at the end, for presentation.
4. Report the dominant pollutant
The overall index is the maximum sub-index. Two cities with the same AQI can have completely different problems — traffic NO2 in one, wildfire smoke in the other — and the index alone hides which.
5. Watch the upper tail, not just the category counts
Indices are designed for alerting. A handful of days in a high band can matter more than a shift of many days between the two lowest bands.
Which pollutant sets the index, and its non-linear scale
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.
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 — which pollutant sets the index
# UK DAQI bands for NO2 (hourly) and ozone (8-hour running mean), µg/m³
UK_NO2 = [67, 134, 200, 267, 334, 400, 467, 534, 600]
UK_O3 = [33, 66, 100, 120, 140, 160, 187, 213, 240]
def site_daqi(site):
text = requests.get(f"https://uk-air.defra.gov.uk/datastore/data_files/site_data/"
f"{site}_2023.csv", timeout=120).text
d = pd.read_csv(io.StringIO(text), skiprows=4, low_memory=False)
num = lambda c: pd.to_numeric(d[c], errors="coerce")
by_day = pd.DataFrame({
"PM2.5": num("PM<sub>2.5</sub> particulate matter (Hourly measured)").groupby(d.Date).mean()
.map(lambda c: uk_band(c, UK_PM25)),
"NO2": num("Nitrogen dioxide").groupby(d.Date).max()
.map(lambda c: uk_band(c, UK_NO2)),
"O3": num("Ozone").rolling(8, min_periods=6).mean().groupby(d.Date).max()
.map(lambda c: uk_band(c, UK_O3)),
}).dropna()
return by_day
def dominant(row):
top = row[row == row.max()].index.tolist()
return top[0] if len(top) == 1 else "tie" # idxmax would pick the first column
for site in ("MY1", "KC1"):
b = site_daqi(site)
overall = b.max(axis=1).astype(int)
print(f"{site}: days per DAQI band {overall.value_counts().sort_index().to_dict()}")
print(f" pollutant alone setting the index: "
f"{b.apply(dominant, axis=1).value_counts().to_dict()}")
MY1: days per DAQI band {1: 37, 2: 270, 3: 39, 4: 2}
pollutant alone setting the index: {'tie': 156, 'O3': 107, 'NO2': 60, 'PM2.5': 25}
KC1: days per DAQI band {1: 10, 2: 145, 3: 164, 4: 14, 5: 7, 6: 5, 7: 2}
pollutant alone setting the index: {'O3': 310, 'tie': 26, 'PM2.5': 9, 'NO2': 2}
Even at the kerbside site, with the worst NO2 in the network, ozone alone sets the DAQI on more days than NO2 does, and on 156 days two or more pollutants tie. At the background site ozone sets it on 310 of 347 days — and because ozone is higher away from traffic, North Kensington spends 192 days in band 3 or above against Marylebone Road's 41. On the index, the quiet residential street has the worse air. On NO2, the pollutant the kerbside site exists to measure, it is 2.3 times cleaner.
Example 2 — the piecewise scale is not linear in concentration
for c in (5, 9, 12, 20, 35, 40, 55, 60):
print(f"PM2.5 {c:>3} µg/m³ → US AQI {us_subindex(c, US_PM25):>3}, UK DAQI band {uk_band(c, UK_PM25)}")
PM2.5 5 µg/m³ → US AQI 28, UK DAQI band 1
PM2.5 9 µg/m³ → US AQI 50, UK DAQI band 1
PM2.5 12 µg/m³ → US AQI 56, UK DAQI band 2
PM2.5 20 µg/m³ → US AQI 71, UK DAQI band 2
PM2.5 35 µg/m³ → US AQI 99, UK DAQI band 3
PM2.5 40 µg/m³ → US AQI 112, UK DAQI band 4
PM2.5 55 µg/m³ → US AQI 149, UK DAQI band 7
PM2.5 60 µg/m³ → US AQI 154, UK DAQI band 8
The US scale compresses concentrations into different slopes on each segment: 9 µg/m³ spans 50 index points in the first band, while the second band needs 26 µg/m³ for the same 50 points. Differences and averages of index values therefore do not correspond to differences and averages of concentration.
Example 3 — the same days, two indices, side by side
both = pd.DataFrame({"US": us_cat, "UK": uk_cat})
print(pd.crosstab(both.US, both.UK).to_string())
UK Low Moderate
US
Good 123 0
Moderate 231 0
USG 1 1
The table is the whole argument for naming the index: all 231 days that the US scale calls Moderate are Low on the UK one.
The maximum, breakpoints and why indices are poor variables
Why the maximum, not the average
An index exists to tell people whether to change what they do. If ozone is high and everything else is low, the advice depends on the ozone. Averaging sub-indices would dilute a real hazard with irrelevant good news, so every mainstream index reports the worst one.
Why breakpoints encode health evidence and policy
Band edges come from dose–response evidence and from legal limit values at the time the index was designed. When the evidence moves — as it did for PM2.5 — the breakpoints move with it, and an index value from before a revision is not comparable with one after.
Why an index is a poor analysis variable
It is bounded, discretised (for the DAQI) or piecewise-linear (for the US AQI), and defined as a maximum across pollutants that change independently. Regression, interpolation or trend analysis on it mixes all three effects. The concentrations are continuous, pollutant-specific and physically meaningful.
NowCast, running means and colour scales
- NowCast: the US reports hourly "AQI" for PM2.5 using a weighted 12-hour NowCast rather than a true 24-hour mean.
- Running means: the ozone 8-hour mean is a running mean; a day's value is its maximum.
- Data capture: a 24-hour mean from a few hours of data is not a 24-hour mean; require at least 18 of 24 hours.
- Different pollutants in different indices: the DAQI includes SO2 and PM10; some national indices include CO.
- Colour scales differ as well as bands; a green on one map is not a green on another.
Internal links
- Air pollutants and their units explained — concentrations, units and periods
- How to compute an air quality index in Python — full implementation with rolling windows
- AQI averaged the wrong way — the fix guide for averaging an index
- ppb and µg/m³ mixed up — US breakpoints for gases are in ppb
- How to analyse an air quality time series — exceedances on the concentrations
- Choropleth classification explained — bands as a classification choice
FAQ
What is an air quality index?
A scale that turns pollutant concentrations, each averaged over its own period, into a single number or band, set by the most severe pollutant.
Is the US AQI the same as the European or UK index?
No. They use different pollutants, periods and breakpoints. The same air can fall in different categories.
Why did the US AQI change in 2024?
The EPA lowered the PM2.5 breakpoints after tightening the annual standard; "Good" now ends at 9.0 µg/m³.
Can I average AQI values?
Not meaningfully. Average the concentrations, then convert.
Which pollutant determines the AQI?
The one with the highest sub-index that day. Report it alongside the value.
What averaging period does each pollutant use?
Typically 24 hours for PM2.5 and PM10, a running 8 hours for ozone, and one hour for NO2.