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.

A tropospheric NO2 column above a city pixel, with most of the NO2 near the ground and the satellite overhead. the satellite counts every molecule above the pixelmost NO2: the mixed layer, near sourcesless abovefree troposphere, up to ~10 kmTROPOMI ~13:30one pixel: about 5.5 × 3.5 kmLondon 2023 mean column 82 µmol/m² — not a concentration spatialworkflow.io
The satellite counts every molecule above the pixel; people breathe the bottom few metres.

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.

Correlation between the Sentinel-5P column and hourly ground NO2 at overpass time at an urban background and a kerbside station in London, 2023. r between column and ground, clear overpassesNorth Kensington, background (155)r = 0.68Marylebone Road, kerbside (159)r = 0.3000.51Same pixel, same hours: the column tracks the neighbourhood, not the kerb spatialworkflow.io
The column tracks the neighbourhood, not the kerb.

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.

What Sentinel-5P tropospheric NO2 delivers over London, with the naive conversion to a surface concentration. what the column is, and is notpropertyvaluequantitytropospheric column, mol/m²London mean, 202382 µmol/m² = 4.9 × 10¹⁵ molecules/cm²usable pixels (qa ≥ 0.75)31% of 64,828overpass11–14 UTC, mostly 12–13column ÷ 1 km mixed layer3.8 µg/m³ vs 16 measuredNO2 is not evenly mixed; converting needs a vertical profile from a model spatialworkflow.io
Three units for the column, none of them a concentration.

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.

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.