AQI Averaged the Wrong Way

On this page

Problem statement

The report says the station's "average AQI for the year was 53.7". Or a map shows each district's mean daily AQI. Or a dashboard computes today's AQI as the average of the last 24 hourly AQI values. Each of these averages an index, and an index is not a quantity that averages: it is a piecewise function of a concentration, taken as a maximum across pollutants that each have their own averaging period.

The results look reasonable and are wrong in ways that depend on where the concentrations sit on the breakpoint table β€” which is why the error cannot be corrected after the fact, only avoided.

Quick answer

Average the concentrations, then convert once:

import io, requests, numpy as np, pandas as pd

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 aqi(c):
    c = np.floor(c * 10) / 10
    for lo, hi, ilo, ihi in US_PM25:
        if lo <= c <= hi:
            return (ihi - ilo) / (hi - lo) * (c - lo) + ilo
    return np.nan

def pm25(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)
    v = pd.to_numeric(d["PM<sub>2.5</sub> particulate matter (Hourly measured)"], errors="coerce")
    g = v.groupby(d.Date)
    return g.mean().where(g.count() >= 18).dropna()

daily = pm25("MY1")
print(f"Marylebone Road 2023, {len(daily)} valid days of PM2.5")
print(f"mean of daily AQI values:        {daily.map(aqi).mean():5.1f}")
print(f"AQI of the mean concentration:   {aqi(daily.mean()):5.1f}  "
      f"(annual mean {daily.mean():.1f} Β΅g/mΒ³)")
Marylebone Road 2023, 340 valid days of PM2.5
mean of daily AQI values:         53.7
AQI of the mean concentration:    55.7  (annual mean 11.7 Β΅g/mΒ³)

The two differ because the breakpoint scale is steeper below 9 Β΅g/mΒ³ than above it β€” concave, overall β€” so the mean of the index values sits below the index of the mean concentration: 53.7 against 55.7 here. At another site, with its days spread differently across the bands, the gap has a different size.

Find the average, move it to concentrations

1. Find where the average happens

Look for any mean, resample or group-by applied to a column of index values β€” daily AQIs averaged into a year, hourly AQIs into a day, site AQIs into a city.

2. Move the average to the concentrations

Average each pollutant's concentration over the period and area first, on the averaging period the index defines. Then look up the index once.

3. Keep the daily index as a count, not a mean

The honest annual summary of daily AQI values is a distribution: days in each category, the worst day, the dominant pollutant. A mean hides the only part β€” the bad days β€” an index was designed to communicate.

4. Never average the overall index across pollutants

The overall AQI is a maximum across pollutants. Its mean mixes ozone days with particle days into a number that describes neither pollutant.

5. For hourly reporting use the defined method

The US hourly PM "AQI" is computed from a NowCast concentration, not from an average of hourly AQI values.

Averaging then converting against converting then averaging a year of daily PM2.5 at Marylebone Road. the order of two stepsconvert, then averageβœ•340 daily AQIsβœ•mean 53.7βœ•weights clean days by the steep scaleaverage, then convertβœ“annual mean 11.7 Β΅g/mΒ³βœ“AQI 55.7βœ“one lookup, well definedThe breakpoint scale is concave, so the order changes the answer spatialworkflow.io
The order of the two steps is the whole bug.

Averaging across sites and pollutants

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 Bloomsbury (CLL2), 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("CLL2_2023.csv:", requests.head("https://uk-air.defra.gov.uk/datastore/data_files/site_data/CLL2_2023.csv", timeout=60, allow_redirects=True).status_code)
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)
CLL2_2023.csv: 200
KC1_2023.csv: 200
MY1_2023.csv: 200

Example 1 β€” averaging across sites

sites = {s: pm25(s) for s in ("MY1", "KC1", "CLL2")}
days = sorted(set.intersection(*[set(v.index) for v in sites.values()]))
conc = pd.DataFrame({s: v.loc[days] for s, v in sites.items()})
index_mean = conc.map(aqi).mean(axis=1)
conc_mean = conc.mean(axis=1).map(aqi)
diff = index_mean - conc_mean
print(f"{len(days)} common days; city AQI from mean of site AQIs minus AQI of mean concentration:")
print(f"  mean {diff.mean():+.2f}, range {diff.min():+.1f} to {diff.max():+.1f} index points")
print(f"  days on which the two land in different categories: "
      f"{(pd.cut(index_mean, [-1, 50, 100, 150, 500]) != pd.cut(conc_mean, [-1, 50, 100, 150, 500])).sum()}")
328 common days; city AQI from mean of site AQIs minus AQI of mean concentration:
  mean -0.71, range -6.0 to +2.4 index points
  days on which the two land in different categories: 17

The disagreement is small on most days and concentrated around the band boundaries: on 17 of 328 days the two orders put the city in different categories β€” exactly the days on which somebody decides whether to issue advice.

Example 2 β€” the overall index mixes pollutants

text = requests.get("https://uk-air.defra.gov.uk/datastore/data_files/site_data/KC1_2023.csv",
                    timeout=120).text
d = pd.read_csv(io.StringIO(text), skiprows=4, low_memory=False)
o3 = pd.to_numeric(d["Ozone"], errors="coerce") / 1.96 / 1000                # ppm at 25 Β°C
US_O3 = [(0.000, 0.054, 0, 50), (0.055, 0.070, 51, 100), (0.071, 0.085, 101, 150),
         (0.086, 0.105, 151, 200)]
def aqi_o3(c):
    c = np.floor(c * 1000) / 1000
    for lo, hi, ilo, ihi in US_O3:
        if lo <= c <= hi:
            return (ihi - ilo) / (hi - lo) * (c - lo) + ilo
    return np.nan
o3_daily = o3.rolling(8, min_periods=6).mean().groupby(d.Date).max().map(aqi_o3)
pm_daily = pm25("KC1").map(aqi)
both = pd.DataFrame({"PM2.5": pm_daily, "O3": o3_daily}).dropna()
overall = both.max(axis=1)
print(f"North Kensington: mean overall AQI {overall.mean():.1f}")
print(f"  set by ozone on {(both.O3 > both['PM2.5']).mean():.0%} of days, "
      f"by PM2.5 on {(both['PM2.5'] >= both.O3).mean():.0%}")
print(f"  mean PM2.5 sub-index {both['PM2.5'].mean():.1f}, mean ozone sub-index {both.O3.mean():.1f}")
North Kensington: mean overall AQI 43.6
  set by ozone on 51% of days, by PM2.5 on 49%
  mean PM2.5 sub-index 37.8, mean ozone sub-index 33.9

The mean overall index is higher than the mean of either sub-index, because on each day it picks whichever is worse. It is a statistic of the maximum, not of any pollutant, and cannot be compared with a site where only one pollutant is measured.

Example 3 β€” what to report instead

cats = pd.cut(daily.map(aqi), [-1, 50, 100, 150, 200, 500],
              labels=["Good", "Moderate", "USG", "Unhealthy", "Very unhealthy+"])
summary = cats.value_counts().reindex(cats.cat.categories)
print(summary.to_string())
print(f"worst day: {daily.idxmax()} at {daily.max():.1f} Β΅g/mΒ³ (AQI {aqi(daily.max()):.0f})")
Good               116
Moderate           222
USG                  2
Unhealthy            0
Very unhealthy+      0
worst day: 14-02-2023 at 39.7 Β΅g/mΒ³ (AQI 111)

Counts by category, the worst day, and the annual mean concentration against the annual standard: three facts, none of which is an averaged index.

What to report for a year of AQI instead of an average index. summaries that keep the meaningdays in each categoryGood 116 Β· Moderate 222 Β· USG 2the worst day14 Feb 2023, 39.7 Β΅g/mΒ³, AQI 111the dominant pollutantper day, with tiesannual mean concentrationagainst the annual standardmean of daily AQI valuesnot meaningfulPercentiles of daily AQI are fine: the conversion is monotonic spatialworkflow.io
Summaries that keep the index's meaning intact.

Piecewise scales break averages

Why piecewise-linear breaks averages

Within one band the index is linear in concentration and averaging commutes with the conversion. Across bands the slope changes β€” 5.6 index points per Β΅g/mΒ³ below 9, under 2 between 9 and 35 β€” so a set of days straddling a boundary averages to different values depending on the order of operations. The more the data straddle boundaries, the larger the gap.

Why the maximum makes it worse

The overall index is max(sub-indices), and the mean of a maximum is not the maximum of the means. Averaging it produces a number that no pollutant ever had and that rises simply because more pollutants are measured.

Why the UK DAQI is even less averageable

The DAQI is an integer band from 1 to 10, a step function. Its average is a mean of ranks β€” a number like 2.7 that corresponds to no concentration and no advice.

Mean overall AQI against mean PM2.5 and ozone sub-indices at North Kensington in 2023. the mean of a maximummean ozone sub-index33.9mean PM2.5 sub-index37.8mean overall AQI43.6 β€” above both02550Ozone set the index on 51% of days, PM2.5 on 49% spatialworkflow.io
Averaging a daily maximum produces a number no pollutant ever had.

Single bands, truncation and percentiles

  • Within a single band, averaging index values and converting an averaged concentration agree; the error lives at boundaries.
  • Truncation before the lookup adds its own small discontinuities; keep it on the concentration side.
  • Spatial maps: interpolate concentrations and convert the surface, never interpolate AQI values between stations.
  • Trend of an index: a changing breakpoint table (the 2024 PM2.5 revision) creates a step in any AQI time series.
  • Percentiles of daily AQI values are fine β€” they are order statistics and survive the monotonic conversion.

FAQ

Can I average daily AQI values?

Not meaningfully. Average the concentrations and convert, or report the distribution of daily values.

Why is the mean of AQIs different from the AQI of the mean?

The breakpoint scale changes slope at each band boundary, so the order of averaging and converting matters.

How should I summarise a year of AQI?

Days per category, the worst day, the dominant pollutant, and the annual mean concentration against its standard.

Can I average AQI across monitoring stations?

No. Average or interpolate concentrations, then convert.

Are percentiles of daily AQI valid?

Yes. The conversion is monotonic, so the 98th percentile of AQI equals the AQI of the 98th percentile concentration.

How is an hourly AQI computed properly?

From a NowCast concentration for particles, or the pollutant's own short-period value β€” not by averaging hourly AQIs.