Satellite NO2 Explained
On this page
Problem statement
Satellite NO2 maps are among the most striking images in environmental science: every city, power plant and shipping lane glowing against a dark background. They invite a reading as maps of the air people breathe. They are not. A satellite measures a tropospheric column — the number of NO2 molecules above each square metre of ground, from the surface to about 10 km — averaged over a pixel several kilometres across, at one moment in the early afternoon, on days without cloud.
Each of those qualifications changes what the map can say. The column is not a concentration; the pixel is not a street; the overpass is not the rush hour; the clear days are not all days.
Quick answer
What TROPOMI on Sentinel-5P actually delivers:
| Property | Value |
|---|---|
| Quantity | tropospheric vertical column, mol/m² |
| Pixel | about 5.5 × 3.5 km at nadir |
| Time | one overpass a day, around 13:30 local solar time |
| Coverage | only where qa_value ≥ 0.75 — cloud-free, snow-free |
import gc
import numpy as np, pandas as pd, xarray as xr, fsspec, pystac_client, planetary_computer
BBOX = (-0.6, 51.25, 0.35, 51.75)
cat = pystac_client.Client.open("https://planetarycomputer.microsoft.com/api/stac/v1")
items = [i for i in cat.search(collections=["sentinel-5p-l2-netcdf"], bbox=list(BBOX),
datetime="2023-01-01/2023-12-31").items()
if i.properties["s5p:product_name"] == "no2"
and i.properties["s5p:processing_mode"] == "NRTI" and 9 <= i.datetime.hour <= 15]
fs = fsspec.filesystem("http")
def pixels(item):
gc.collect() # each file's HDF5 objects sit in reference cycles: ~20 MB a file otherwise
with fs.open(planetary_computer.sign(item.assets["no2"].href), block_size=4 * 2**20, cache_type="blockcache") as f:
with xr.open_dataset(f, group="PRODUCT", engine="h5netcdf") as ds: # close it: 644 open files fill memory
lat, lon = ds.latitude[0].values, ds.longitude[0].values
inside = (lat > BBOX[1]) & (lat < BBOX[3]) & (lon > BBOX[0]) & (lon < BBOX[2])
rows = np.where(inside.any(axis=1))[0]
if len(rows) == 0:
return None
sl = slice(rows.min(), rows.max() + 1)
m = inside[sl]
return pd.DataFrame({"lat": lat[sl][m], "lon": lon[sl][m],
"no2": ds.nitrogendioxide_tropospheric_column[0, sl].values[m],
"qa": ds.qa_value[0, sl].values[m], "time": item.datetime})
px = pd.concat([p for p in (pixels(i) for i in items) if p is not None], ignore_index=True)
good = px[px.qa >= 0.75]
print(f"2023, London: {len(items)} daytime passes, {len(px):,} pixels, "
f"{len(good):,} usable ({len(good) / len(px):.0%})")
print(f"mean column {good.no2.mean():.2e} mol/m² = {good.no2.mean() * 1e6:.0f} µmol/m² "
f"= {good.no2.mean() * 6.022e19:.2e} molecules/cm²")
print(f"overpass times (UTC): {good.time.dt.strftime('%H').value_counts().sort_index().to_dict()}")
2023, London: 644 daytime passes, 64,828 pixels, 19,809 usable (31%)
mean column 8.15e-05 mol/m² = 82 µmol/m² = 4.91e+15 molecules/cm²
overpass times (UTC): {'11': 2289, '12': 10353, '13': 6578, '14': 589}
Three units for the same number — mol/m², µmol/m², molecules/cm² — all in common use, none of them a concentration.
A column, at the question's scale, at overpass time
1. Treat the column as a column
It integrates NO2 over the whole troposphere. Most urban NO2 is in the lowest kilometre, so the column tracks surface pollution — loosely, and with a relationship that changes with the height of the mixing layer, the season and the weather.
2. Match the scale of the question
A 5 × 3.5 km pixel averages parks, rivers, residential streets and motorways. Satellite NO2 answers city-scale questions — which cities, which industrial clusters, what changed between years — not street-scale ones.
3. Remember the overpass time
Early afternoon is when NO2 is at a daily minimum at many sites: sunlight is breaking it down and the mixing layer is deep. The satellite sees a different part of the day from the rush-hour peaks.
4. Average over many clear days, and report how many
A single day is noisy and often cloudy. Monthly or seasonal means are standard; each cell's mean should carry its count.
5. Validate against the right ground stations
Compare with urban background stations, which represent a pixel-sized area. Kerbside stations measure a street the pixel cannot resolve.
TROPOMI columns against ground stations
The data used on this page
Sentinel-5P TROPOMI Level-2 NO2 (near-real-time processing) for 2023 over a box around London, 0.6° W to 0.35° E and 51.25–51.75° N, read from the Microsoft Planetary Computer by the quick answer's own code; and hourly NO2 from DEFRA's AURN stations London North Kensington (KC1, urban background) and London Marylebone Road (MY1, kerbside). Reading the 644 swaths takes about 25 minutes.
import pystac_client
catalog = pystac_client.Client.open("https://planetarycomputer.microsoft.com/api/stac/v1")
print(catalog.get_collection("sentinel-5p-l2-netcdf").title)
Sentinel-5P Level-2
Example 1 — the column against two kinds of ground station
import io, requests
def aurn(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)
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 - pd.Timedelta("1h")).dt.tz_localize("UTC"))
stations = {"KC1 urban background": ("KC1", 51.521050, -0.213419),
"MY1 kerbside": ("MY1", 51.522530, -0.154611)}
for label, (code, lat, lon) in stations.items():
ground = aurn(code)
pairs = []
for t, g in good.groupby("time"):
d_km = np.hypot((g.lat - lat) * 111, (g.lon - lon) * 111 * np.cos(np.radians(lat)))
if d_km.min() > 4:
continue
hour = pd.Timestamp(t).floor("h")
if hour in ground.index and not np.isnan(ground[hour]):
pairs.append((g.no2.iloc[int(np.argmin(d_km.to_numpy()))] * 1e6, ground[hour]))
p = np.array(pairs)
print(f"{label:<22} {len(p)} clear overpasses, r = {np.corrcoef(p.T)[0, 1]:.2f}, "
f"mean column {p[:, 0].mean():.0f} µmol/m², mean surface {p[:, 1].mean():.0f} µg/m³")
KC1 urban background 155 clear overpasses, r = 0.68, mean column 112 µmol/m², mean surface 16 µg/m³
MY1 kerbside 159 clear overpasses, r = 0.30, mean column 106 µmol/m², mean surface 51 µg/m³
The same pixel, the same hours: the column follows the background station reasonably and the kerbside station hardly at all. The satellite sees the neighbourhood; the kerbside monitor sees its road.
Example 2 — why the column does not convert to µg/m³
column = good.no2.mean() # mol/m²
for mixing_height in (500, 1000, 1500):
naive = column / mixing_height * 46.0055 * 1e6 # µg/m³ if spread evenly
print(f"spread evenly through {mixing_height:>4} m: {naive:4.1f} µg/m³")
print(f"measured at the background station at overpass time: about 16 µg/m³")
spread evenly through 500 m: 7.5 µg/m³
spread evenly through 1000 m: 3.8 µg/m³
spread evenly through 1500 m: 2.5 µg/m³
measured at the background station at overpass time: about 16 µg/m³
Spreading the column evenly through any plausible mixing layer gives a fraction of what a background station measures at the same hour. NO2 is not evenly mixed: it is concentrated near the ground, next to its sources, and the column is diluted by the clean air above. Turning a column into a surface concentration needs a model of the vertical profile — which is what data-fusion products do.
Example 3 — which days are in the average
per_pass = px.groupby("time").apply(lambda g: (g.qa >= 0.75).mean())
months = per_pass.groupby(per_pass.index.month).mean()
print("share of usable pixels by month:", " ".join(f"{m}:{v:.0%}" for m, v in months.items()))
print(f"passes with no usable pixel over London: {(per_pass == 0).mean():.0%}")
share of usable pixels by month: 1:25% 2:29% 3:11% 4:26% 5:28% 6:52% 7:10% 8:28% 9:34% 10:39% 11:38% 12:20%
passes with no usable pixel over London: 41%
An annual satellite mean is a mean over the clear afternoons, weighted towards whichever months were sunniest. Clear days can have systematically different pollution — more photochemistry, less dispersion in anticyclones — so the average is of a particular kind of day.
Profiles, pixel averaging and the afternoon
Why the retrieval needs a profile
The instrument measures sunlight reflected by the Earth and absorbed along the way. Converting that absorption into a vertical column needs an air-mass factor — how long the light path is in the layers where the NO2 sits — which depends on the assumed vertical profile, the surface brightness and clouds. The averaging_kernel in the product lets advanced users swap the assumed profile for their own.
Why the pixel averages away streets
A pixel of 19 km² in London contains hundreds of streets. Roadside NO2 can be twice the background, but only within tens of metres of the road; averaged over the pixel, the roads add a little to the background. That is why satellite NO2 matches background stations and not kerbside ones.
Why the afternoon matters
NO2 is photolysed in sunlight, and the boundary layer is deepest in the early afternoon. Both lower surface concentrations and spread the column. Morning rush-hour peaks happen before the overpass and are invisible to it — a limitation geostationary instruments are designed to remove.
Stratospheric NO2, negative columns and weekly cycles
- Stratospheric NO2 is removed in the tropospheric product; the total column includes it.
- Negative columns occur in clean regions as retrieval noise; keep them in averages.
- Resolution change: the along-track pixel shrank from 7 to 5.5 km in August 2019; long series cross that change.
- Weekly cycles in satellite NO2 are real over cities but need years of data to resolve against cloud-driven noise.
- Data fusion: combining satellite columns, models and ground stations is how operational surface maps are made.
Internal links
- How to map Sentinel-5P NO2 in Python — reading and gridding the swaths
- Satellite NO2 map mostly empty — the fix guide for sparse coverage
- Air quality monitoring networks explained — background against kerbside
- Air pollutants and their units explained — surface concentrations and their units
- ppb and µg/m³ mixed up — a column converts to neither
- How to interpolate air quality station data — the ground-based alternative
FAQ
What does Sentinel-5P measure for NO2?
The tropospheric vertical column: moles of NO2 per square metre of ground, integrated from the surface to the tropopause.
Can I convert satellite NO2 to µg/m³?
Not directly. It needs a vertical profile, usually from a chemistry model. Dividing by a mixing height underestimates badly.
What resolution is satellite NO2?
TROPOMI pixels are about 5.5 × 3.5 km at nadir — neighbourhood scale, not street scale.
When does Sentinel-5P pass over?
Once a day in the early afternoon, around 13:30 local solar time.
Why does satellite NO2 not match my roadside monitor?
The pixel averages kilometres of mostly background air; the kerbside monitor measures its street.
What are satellite NO2 maps good for?
City and regional contrasts, industrial point sources, shipping lanes, and changes over time — averaged over many clear days.