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:

  1. Average each pollutant over its own period (1 h for NO2, 8 h for ozone, 24 h for particles).
  2. 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.
  3. 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.

How an air quality index is built from hourly concentrations. every mainstream index works this wayhourly dataper pollutantown periodPM 24 h · O3 8 hbreakpointtableone per pollutantmaximumthe worst sub-indexindex +pollutantreport bothSteps three and four are why an index cannot be averaged like a concentration spatialworkflow.io
An index is the worst sub-index of the day, not an average of the air.

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.

Days in each category of the US AQI and the UK DAQI for PM2.5 at Marylebone Road in 2023. the same 356 days, two indicesUS AQI: Good123 daysUS AQI: Moderate231 daysUS AQI: USG2 days — unhealthy for sensitive groupsUK DAQI: Low355 daysUK DAQI: Moderate1 day0180360US "Good" ends at 9.0 µg/m³ since 2024; the DAQI’s Low covers bands 1–3, up to 35 spatialworkflow.io
Identical air, two verdicts: the lowest band ends at 9 µg/m³ on one scale and 11 on the other, and "Low" spans three UK bands.

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.

UK DAQI results at the Marylebone Road kerbside and North Kensington background sites in 2023. the index rates the quiet street worse2023Marylebone RoadNorth Kensingtonannual NO241.6 µg/m³18.1 µg/m³days in DAQI band 3+41192ozone alone sets index107 days310 daysNO2 alone sets index60 days2 daysties156 days26 daysozone is higher away from traffic, and the DAQI’s ozone bands are narrowAn index answers "should I change my plans", not "where is the pollution" spatialworkflow.io
On the index the background site has the worse year; on NO2 it is 2.3 times cleaner.

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.

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.