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 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.
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.
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.
Internal links
- Air quality indices explained β how an index is built
- How to compute an air quality index in Python β the correct pipeline
- ppb and Β΅g/mΒ³ mixed up β the other common AQI input error
- How to analyse an air quality time series β statistics on concentrations
- How to interpolate air quality station data β surfaces of concentration, not index
- Wind energy from the mean speed is wrong β averaging before a non-linear step, elsewhere
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.