q03·intermediate

How has air quality changed in my city over the last decade?

atmosphereair-qualitypublic-healthurban Datasets: 5 10–30 min

NO₂ (nitrogen dioxide) is the chemical fingerprint of burning fuel: cars, trucks, power plants, factories. More NO₂ in the air above a city means more combustion below it. Satellites have measured it from orbit for 20 years, so you can watch whether your city's air has been getting cleaner or dirtier, with no ground sensors required.

How has air quality changed in my city over the last decade?

NO₂ (nitrogen dioxide) is the chemical fingerprint of burning fuel: cars, trucks, power plants, factories. More NO₂ in the air above a city means more combustion below it. Satellites have measured it from orbit for 20 years, so you can watch whether your city’s air has been getting cleaner or dirtier, with no ground sensors required.

Which data to use, and how

Use this dataset: TROPOMI NO₂ (short name S5P_L2__NO2___), which covers 2018 → today at a sharp ~5 km. It’s the easiest to work with. Want a longer history? OMI NO₂ (OMNO2) reaches back to 2004, but it’s blockier (~25 km) and has a quirk or two. Start with TROPOMI and add OMI only if you need the extra years.

Then do four steps. The runnable code further down does all of this; this is just the idea.

  1. Pick your city. Draw a small latitude/longitude box around it and choose the years you care about.
  2. One number per month. Average the NO₂ inside your box for each month, giving a monthly time series.
  3. Take out the seasons. NO₂ is always higher in winter (more heating, calmer air). Subtract that regular yearly pattern so a real change isn’t hidden by the calendar.
  4. Fit a trend and test it. Draw the long-term line and check it’s bigger than the random month-to-month wobble. Up = air getting dirtier, down = cleaner, flat = no real change.

That’s the whole method. Below, “How a scientist answers this” names the exact tools, and the Run it cell does it live on synthetic data so you can see each step work.

What you can find out

  • Whether your city’s NO₂ is rising or falling over the years.
  • The 2020 COVID drop. When lockdowns emptied the roads, NO₂ fell sharply almost everywhere. It’s a famous result, so it doubles as a sanity check: if your code doesn’t show it, the code is wrong, not the world.
  • Whether a policy worked, e.g. NO₂ stepping down after diesel bans or EV adoption.
  • The weekly rhythm, busy weekdays vs quieter weekends.
  • Haze and dust too. Swap in MODIS aerosol data (tiny airborne particles) for a second view.

What it can’t tell you

  • What you actually breathe at street level. The satellite measures the whole column of air from the ground up to space, not the concentration at nose height. The trend is trustworthy; for “is it safe to breathe right now” you need ground monitors (free: OpenAQ) or a model.
  • What caused the change. A rise doesn’t say whether it was traffic, a new factory, or a power plant. Pinning the blame needs extra maps (land use, emission inventories).
  • Hour-by-hour, in most places. The older satellites fly over once a day. Hour-by-hour air quality only exists for North America since 2023, from the new TEMPO instrument, which hovers over one region instead of orbiting.

Gotchas to watch for

  • Clouds block the view. On a cloudy day the satellite can’t see the ground, and it can lose 30–70% of a scene. The fix: average a whole month so the clear days fill the gaps. And always compare the same month across years (January vs January), never January vs July.
  • Don’t mix the two satellites carelessly. The old one (OMI, blocky ~25 km pixels) and the new one (TROPOMI, sharp ~5 km) average space differently, so a sudden jump where you stitch them can be the instrument, not the air. Beginner-safe fix: just use TROPOMI (2018→now) and skip the stitch.
  • The old satellite has a known defect. Since 2007 some of OMI’s detector rows are broken (a “stripe” across each scene) and must be discarded. The data marks them with a quality flag (XTrackQualityFlags) and any decent notebook drops them automatically. Only relevant if you use OMI.
  • Use the COVID dip as your check. Your finished pipeline should reproduce the 2020 drop. If it does, you’ve probably built it right.

The real-data code

The Run it cell above runs the method on synthetic data with no login. Below is the whole recipe against the real archive: search, open, average per month, deseasonalize, fit the trend, and plot it. It needs a free Earthdata Login.

import earthaccess, numpy as np, xarray as xr
import pymannkendall as mk
import matplotlib.pyplot as plt

earthaccess.login(strategy="netrc")

aoi = (72.7, 18.9, 73.1, 19.3)   # your city as (W, S, E, N) — here, Greater Mumbai
years = range(2019, 2025)

# 1. One NO2 number per month: open each TROPOMI granule, keep good-quality
#    pixels inside the box, and average the tropospheric NO2 column.
def monthly_no2(y, m):
    grans = earthaccess.search_data(
        short_name="S5P_L2__NO2___", bounding_box=aoi,
        temporal=(f"{y}-{m:02d}-01", f"{y}-{m:02d}-28"))
    vals = []
    for fh in earthaccess.open(grans):
        ds = xr.open_dataset(fh, group="PRODUCT")
        col = ds["nitrogendioxide_tropospheric_column"]
        keep = ((ds["qa_value"] > 0.75)
                & (ds.longitude >= aoi[0]) & (ds.longitude <= aoi[2])
                & (ds.latitude >= aoi[1]) & (ds.latitude <= aoi[3]))
        vals.append(float(col.where(keep).mean()))
    return np.nanmean(vals) if vals else np.nan

series = np.array([[monthly_no2(y, m) for m in range(1, 13)] for y in years])

# 2. Remove the seasonal cycle: subtract each calendar month's long-term mean.
anomaly = (series - np.nanmean(series, axis=0)).ravel()
clean = anomaly[~np.isnan(anomaly)]

# 3. Robust trend + significance (both shrug off the noisy retrievals).
r = mk.original_test(clean)
direction = "rising" if r.slope > 0 else "falling"
print(f"NO2 is {direction}: {r.slope:+.3e} per month, p={r.p:.3f}",
      "(significant)" if r.h else "(not distinguishable from noise)")

# 4. Plot it, and mark the 2020 COVID dip as a sanity check.
plt.plot(clean)
plt.axvline((2020 - min(years)) * 12, ls="--", c="k", label="2020 lockdowns")
plt.ylabel("NO2 anomaly"); plt.xlabel("month"); plt.legend(); plt.tight_layout(); plt.show()

# Before you trust it: compare against a CPCB / OpenAQ ground monitor, and remember
# the column is not the surface concentration you breathe (see the gotchas above).

Where the data comes from

All from NASA through one free login (Earthdata Login): OMI and TROPOMI come from the GES DISC archive, TEMPO from ASDC. The optional street-level reality check (OpenAQ ground monitors) is free and separate.

Sources

How a scientist answers this
Parameters
Tropospheric nitrogen-dioxide (NO2) column density — OMI (2004–present) for the full record, or TROPOMI (2018–present) for sharper recent detail. Monthly means over your city in molecules/cm2, deseasonalized (NO2 has a strong winter peak), reduced to a trend slope with a confidence interval and a significance test.
Method
Build an area-averaged monthly NO2 series, remove the seasonal cycle (a monthly climatology or harmonic fit), then fit an area-weighted Theil–Sen slope with a Mann–Kendall significance test — both robust to the noisy, skewed satellite retrievals. Optionally pair with MODIS aerosol optical depth for particulate pollution.
Validation
Cross-check the satellite trend against ground monitors (EPA AQS, OpenAQ) where they exist, and flag thin station coverage. Account for the OMI row-anomaly and TROPOMI's finer resolution when splicing the two records.
In plain EnglishMeasure the pollution sitting over your city every month for years, take out the regular winter-high / summer-low pattern, and see whether what's left is trending up or down — and whether that trend is bigger than the month-to-month noise.

Make it yours → Set your city's bounding box, the years, and the pollutant; the notebook handles the deseasonalizing and the trend test. Swap OMI for TROPOMI to trade record length for resolution.

🧪 Virtual Mock Lab stand-in data · runs in your browser · no login

A safe place to practise the method on OMNO2. The code is the real earthaccess workflow, but we feed it stand-in data shaped like the real product — so you can run it, change the numbers, and break it without an account. When you want the real data, use the recipe under “The real-data code” above, or download the notebook below.

editable · runs in your browser

Datasets used

Computed · real data Worked examples, computed end-to-end on real satellite data — not yet scientist-verified · expand ▾
Real trends, computed on Google Earth Engine (area-weighted Theil–Sen + Mann–Kendall).

We ran the analysis on real data for Indian regions and report exactly what came back — including the honest nulls and refusals. Each card shows the slope, significance, and (for rainfall) a gauge cross-check.

…and what it refuses to answer

How has tropospheric NO2 (air quality) over an Indian city changed over the last decade?
nitrogen dioxide (NO2) Delhi NCR 2019–2024
⊘ Refused

the satellite NO2 record (TROPOMI, 2018+) is only ~6 years -- too short to honestly call a decadal trend. OMI reaches 2004 but is not in this GEE pipeline. Refuse rather than mislead. — the validator refuses ill-posed questions rather than guessing. That discipline is the point.

validator-enforced refusal

See all verified answers →