Is the air in my city getting more polluted — and how many people are breathing the worst of it?
Draw a rectangle, then check which collections cover it. Most NASA data is global, so a big box matches a lot.
30.9, 29.7 → 31.6, 30.4 (Greater Cairo, Egypt)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 since 2018, and NASA serves a tidy monthly map of it. Stack those maps over the years and you can see whether your city's air is getting worse. Then lay free population data on top and count how many people live where it's dirtiest.
Is the air in my city getting more polluted — and how many people are breathing the worst of it?
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 since 2018, and NASA serves a tidy monthly map of it. Stack those maps over the years and you can see whether your city’s air is getting worse. Then lay free population data on top and count how many people live where it’s dirtiest.
Which data to use, and how
Use this dataset: HAQ TROPOMI NO₂ (short name
HAQ_TROPOMI_NO2_GLOBAL_M_L3). It’s a monthly gridded product covering the whole world from
2018 → today, already averaged into neat ~10 km squares. That’s why it’s the easy
starting point. No swath-stitching, no daily-file wrangling. Each month is one map you
open with one line.
Then do four steps (the runnable code further down does all of this; this is just the idea):
- Pick your city. Draw a small latitude/longitude box around it and choose the years you care about.
- One map per month. Grab the monthly NO₂ grid for each month and crop it to your box. Average the squares inside the box to get one number per month.
- Fit a trend. Line up those monthly numbers across the years and draw the long-term line. Up = air getting dirtier, down = cleaner, flat = no real change. (For Greater Cairo this comes out to a verified +26% from Jan 2019 to Jan 2024.)
- Count who breathes the worst. Lay a free population grid over the dirtiest squares and add up the people inside them (verified: ~22.9M live in the default Cairo box, worst around the district Qasr Al-Nile).
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 (verified for Greater Cairo: +26% from Jan 2019 to Jan 2024).
- Which parts of the metro are worst. The grid shows the high-NO₂ corridor: downtown, ring roads, the industrial edge.
- How many people live in the dirty-air zone. Overlay free population data and count the people sitting inside the high-NO₂ squares (verified: ~22.9M in the default Cairo box, worst around Qasr Al-Nile).
- Whether a big event changed things. The monthly cadence catches the 2020 COVID dip and step-changes after a new highway, plant, or lockdown.
- How your city stacks up against its neighbors. The grid is global, so you can rank nearby cities on the exact same scale.
- The pollutant that actually harms health (PM2.5). A free European model (CAMS) adds the fine airborne particles NO₂ can’t see (free Copernicus account).
What it can’t tell you
- Street-by-street “is my block polluted.” The grid is ~10 km per square. It captures the
city and its major corridors, not individual streets. For finer detail you’d switch to the
sharper raw swaths (
S5P_L2__NO2____HiR, ~5.5 km) or ground monitors (free: OpenAQ). - The pollution you actually inhale. The satellite measures the whole column of NO₂ from the ground up to space, not the concentration at nose height. Surface levels depend on weather and how the air mixes, so treat the map as relative and trend information, not a dose.
- Breathing-height ground truth. The optional PM2.5 step adds a model of surface particles, but confirming what someone actually breathes on a given street still needs ground monitors (OpenAQ). Both the satellite and the model are regional, not your block.
- Exactly who is exposed. The population layer is modeled, not a census. It gives sound exposure totals, but not household income, age, or health status for fine-grained equity work.
- What caused the trend. A rising column doesn’t say whether traffic, industry, or a power plant drove it. Pinning the blame needs extra maps (built-up land use, emission inventories).
Gotchas to watch for
- Column, not surface. The satellite sees the NO₂ column (all the air stacked above the city), while what you breathe is the slice near the ground. They usually move together, but the number on the map isn’t the number in your lungs, so lean on it for relative and trend questions rather than absolute dose.
- The grid is coarse (~10 km). Each square blends a big chunk of the metro, so reading one square as “this neighborhood” will mislead you. The product resolves the city and its main corridors, not streets; when you need finer, switch to the raw L2 HiR swaths or OpenAQ.
- Clouds and seasons. The satellite can’t see through cloud, so a clear winter month gives a more complete map than a cloudy or monsoon month. The way around this is to always compare the same month across years (January vs January), never January vs July.
- NO₂ isn’t the whole story. NO₂ tracks combustion, but the biggest health burden is PM2.5 (fine particles), which only the separate CAMS step covers. Even that is a model, so where lives are at stake, confirm with ground monitors (OpenAQ).
- Population is modeled. The free population grid is an estimate, not a census. The exposure totals are order-of-magnitude sound, good enough to say “~23 million,” but don’t quote them down to the last person.
The real-data code
The Run it cell above runs this method on synthetic data (no login). Below is the whole recipe against the real archive: search the monthly NO₂ grids, open them, average per January, fit the trend, overlay free WorldPop population to count who breathes the worst, and plot it. It needs a free Earthdata Login; the population and boundary layers are free and need no login.
import earthaccess, numpy as np, xarray as xr
import requests, rasterio
from rasterio.windows import from_bounds
from rasterio.warp import reproject, Resampling
import geopandas as gpd
from shapely.geometry import Point
import matplotlib.pyplot as plt
earthaccess.login(strategy="netrc")
aoi = (30.9, 29.7, 31.6, 30.4) # Greater Cairo, Egypt as (W, S, E, N)
years = [2019, 2020, 2021, 2022, 2023, 2024]
# 1. One NO2 map per January: open the monthly L3 grid and crop it to the box.
def city_no2(year, ret_grid=False):
r = earthaccess.search_data(short_name="HAQ_TROPOMI_NO2_GLOBAL_M_L3",
temporal=(f"{year}-01-01", f"{year}-02-01"), count=1)
ds = xr.open_dataset(earthaccess.open(r[:1])[0])
da, la, lo = ds["Tropospheric_NO2"], ds["Latitude"], ds["Longitude"]
sub = (da.where((la >= aoi[1]) & (la <= aoi[3]), drop=True)
.where((lo >= aoi[0]) & (lo <= aoi[2]), drop=True))
return sub if ret_grid else float(np.nanmean(sub.values))
series = np.array([city_no2(y) for y in years])
for y, v in zip(years, series):
print(f"Cairo Jan {y}: {v:.2e} molec/cm^2")
# 2. Fit the long-term line (least-squares slope) and the headline percent change.
slope, intercept = np.polyfit(years, series, 1)
change = (series[-1] - series[0]) / series[0] * 100
direction = "rising" if slope > 0 else "falling"
print(f"NO2 is {direction}: {slope:+.2e} per yr | Jan {years[0]}->Jan {years[-1]}: {change:+.0f}%")
# 3. Who breathes the worst — free WorldPop population (no NASA login).
meta = requests.get("https://www.worldpop.org/rest/data/pop/wpic1km?iso3=EGY").json()
pop_url = next(f for f in meta["data"][-1]["files"] if f.endswith(".tif"))
open("egy_pop_1km.tif", "wb").write(requests.get(pop_url).content)
with rasterio.open("egy_pop_1km.tif") as src:
win = from_bounds(*aoi, transform=src.transform)
pop = src.read(1, window=win).astype("float64")
pop[pop == src.nodata] = np.nan
win_transform = src.window_transform(win)
total = np.nansum(pop)
print(f"People in AOI: {total:,.0f}") # verified ~22.9M for this Cairo box
# Resample the latest NO2 grid onto the population pixels, threshold the dirtiest
# quartile, and sum the people inside it.
g = city_no2(years[-1], ret_grid=True)
no2_on_pop = np.empty_like(pop)
reproject(g.values, no2_on_pop,
src_transform=rasterio.transform.from_bounds(
float(g.Longitude.min()), float(g.Latitude.min()),
float(g.Longitude.max()), float(g.Latitude.max()),
g.sizes[g.dims[-1]], g.sizes[g.dims[0]]),
src_crs="EPSG:4326", dst_transform=win_transform, dst_crs="EPSG:4326",
resampling=Resampling.bilinear)
dirty = no2_on_pop >= np.nanquantile(no2_on_pop, 0.75)
exposed = np.nansum(np.where(dirty, pop, 0))
print(f"In the dirtiest quartile: {exposed:,.0f} people ({exposed/total*100:.0f}% of AOI)")
adm = gpd.read_file(requests.get(
"https://www.geoboundaries.org/api/current/gbOpen/EGY/ADM2/").json()["gjDownloadURL"])
print("Worst-air district:", adm[adm.contains(Point(31.24, 30.05))].iloc[0]["shapeName"]) # Qasr Al-Nile
# 4. Plot the trend (left) and the people-weighted dirty-air zone (right).
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(11, 4))
ax1.plot(years, series, "o-"); ax1.plot(years, slope * np.array(years) + intercept, "--")
ax1.set_ylabel("NO2 column (molec/cm^2)"); ax1.set_xlabel("year"); ax1.set_title(f"{change:+.0f}%")
ax2.imshow(np.where(dirty, pop, np.nan), cmap="inferno"); ax2.set_title("people in dirtiest quartile")
plt.tight_layout(); plt.show()
# Before you trust it: cross-check the trend against an OpenAQ ground monitor, and remember
# the column is not the surface dose you breathe, and the population grid is modeled (see gotchas).
Where the data comes from
The NO₂ is the NASA/ESA half: HAQ TROPOMI NO₂ flies on ESA’s Sentinel-5P satellite, but NASA’s GES DISC archive hosts this tidy gridded version, reachable with one free Earthdata Login. The “who breathes it” half is free, non-NASA layers joined on your own machine: WorldPop population (Univ. Southampton), GHSL built-up surface (EU JRC) for telling traffic and industry zones apart, and geoBoundaries administrative boundaries (William & Mary) for naming the worst districts. None of these need a login. The optional surface PM2.5 comes from Europe’s Copernicus CAMS model (free ADS account).
Sources
- HAQ TROPOMI NO₂ L3 (GES DISC): https://disc.gsfc.nasa.gov/guides/HAQ_TROPOMI_NO2_GLOBAL_M_L3_2/summary
- Sentinel-5P TROPOMI mission: https://sentinels.copernicus.eu/web/sentinel/missions/sentinel-5p
- NASA Health & Air Quality (HAQ): https://www.earthdata.nasa.gov/topics/human-dimensions/air-quality
- WorldPop population (free, no login): https://www.worldpop.org/
- geoBoundaries (free CC-BY admin boundaries): https://www.geoboundaries.org/
- OpenAQ ground monitors (for surface validation): https://openaq.org/
- Copernicus CAMS — global PM2.5 / aerosol (free Atmosphere Data Store account): https://ads.atmosphere.copernicus.eu/
Make it yours → Set the city bounding box, the year range, and the high-NO2 pixel threshold used to define the dirty-air zone, and add CAMS PM2.5 for the health-relevant pollutant.
A safe place to practise the method on HAQ_TROPOMI_NO2_GLOBAL_M_L3. 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.