Exposure Assessment Explained
On this page
Problem statement
Epidemiology, health impact assessment and environmental justice all start from the same quantity: how much pollution each person breathes. Nobody measures that. What exists are concentration fields — station networks, models, satellite maps — and population data saying where people live. Exposure assessment is the set of assumptions that joins them, and each assumption moves the answer.
Three of them dominate. Where is a person assumed to be (at home, all the time)? At what spatial resolution is the concentration known (a 10 km cell, a street)? And at what scale is the result reported (a grid cell, a district, a city)? Change any one and the same data gives a different exposure — often in a direction that matters for who is counted as highly exposed.
Quick answer
The standard, practical definition is residential exposure: the outdoor concentration at each person's home, usually as an annual mean, summarised as a population-weighted mean and as the share of people above a threshold.
import numpy as np, rasterio
with rasterio.open("berlin_no2_on_pop_grid.tif") as src: # from the population-weighted guide
pop, no2 = src.read(1), src.read(2)
lived = pop > 0
print(f"{pop[lived].sum():,.0f} residents in {lived.sum():,} cells of ~1 km")
print(f"area mean of the NO2 field over populated cells: {no2[lived].mean():.1f} µg/m³")
print(f"population-weighted mean: {np.average(no2[lived], weights=pop[lived]):.1f} µg/m³")
for threshold in (10, 12, 13):
share = pop[lived & (no2 > threshold)].sum() / pop[lived].sum()
print(f"share of residents above {threshold} µg/m³: {share:.0%}")
3,583,694 residents in 1,609 cells of ~1 km
area mean of the NO2 field over populated cells: 11.5 µg/m³
population-weighted mean: 12.3 µg/m³
share of residents above 10 µg/m³: 86%
share of residents above 12 µg/m³: 63%
share of residents above 13 µg/m³: 36%
Every number here is conditional: on CAMS at about 10 km, on residents counted where they sleep, and on 1 km cells. The threshold shares in particular swing with each of those choices.
Define exposure, match resolution, weight by population
1. State the exposure definition
Residential annual mean, personal exposure, time-activity weighted, or peak exposure. Most studies use the first because it can be computed for everyone; it is a proxy, and saying so is part of the result.
2. Match the concentration field's resolution to the pollutant
NO2 varies within tens of metres of roads, so a 10 km model underestimates the spread of exposure. PM2.5 is more regional; coarse fields do better. Choose the field accordingly and report its resolution.
3. Weight by population, not by area
Area means count empty land. Population weighting counts people, and it is almost always higher in cities.
4. Report the distribution, not just the mean
The share of people above a guideline, and how that share changes with the threshold, is what health and equity questions need.
5. Beware of reporting scale
Aggregating to districts before computing threshold shares hides everyone whose cell is above the threshold in a district whose average is below it — the modifiable areal unit problem in its exposure form.
Who counts as exposed in Berlin
The data used on this page
berlin_no2_on_pop_grid.tif is written by How to calculate population-weighted exposure in Python: GHSL 2020 residents on its 30 arc-second grid clipped to Berlin (band 1) and the 2023 annual mean NO2 from the CAMS European ensemble, via Open-Meteo, at the nearest 0.1° lattice point (band 2). If the file is not in the working directory, the setup rebuilds it the same way. District boundaries and roads come from OpenStreetMap through an Overpass mirror, read as it answered on 9 October 2026.
import io
import os
import zipfile
import numpy as np
import osmnx as ox
import rasterio
import rasterio.mask
import requests
# OpenStreetMap as Overpass returned it on 9 October 2026, saved as osmnx's own cache so the counts
# below do not drift as OSM is edited; delete the cache folder to query live instead
if not os.path.exists("cache"):
reply = requests.get("https://data.spatialworkflow.io/osm-cache-exposure-assessment-explained-2026-10-09.zip", timeout=300)
reply.raise_for_status()
with zipfile.ZipFile(io.BytesIO(reply.content)) as archive:
archive.extractall("cache")
ox.settings.overpass_url = "https://maps.mail.ru/osm/tools/overpass/api"
ox.settings.requests_timeout = 600
ox.settings.max_query_area_size = 50e9
if not os.path.exists("berlin_no2_on_pop_grid.tif"):
berlin = ox.geocode_to_gdf("Berlin, Germany")
tile = ("https://jeodpp.jrc.ec.europa.eu/ftp/jrc-opendata/GHSL/GHS_POP_GLOBE_R2023A/"
"GHS_POP_E2020_GLOBE_R2023A_4326_30ss/V1-0/tiles/GHS_POP_E2020_GLOBE_R2023A_4326_30ss_V1_0_R4_C20.zip")
zipfile.ZipFile(io.BytesIO(requests.get(tile, timeout=300).content)).extractall("ghsl")
with rasterio.open("ghsl/GHS_POP_E2020_GLOBE_R2023A_4326_30ss_V1_0_R4_C20.tif") as src:
grid, grid_tf = rasterio.mask.mask(src, berlin.geometry, crop=True, nodata=0)
grid = np.where(grid[0] > 0, grid[0], 0.0)
r, c = np.nonzero(grid)
lon, lat = rasterio.transform.xy(grid_tf, r, c)
lons, lats = np.round(np.arange(13.05, 13.80, 0.1), 2), np.round(np.arange(52.35, 52.70, 0.1), 2)
pts = [(la, lo) for la in lats for lo in lons]
annual = {}
for i in range(0, len(pts), 20):
batch = pts[i:i + 20]
j = requests.get("https://air-quality-api.open-meteo.com/v1/air-quality", params={
"latitude": ",".join(str(p[0]) for p in batch), "longitude": ",".join(str(p[1]) for p in batch),
"hourly": "nitrogen_dioxide", "start_date": "2023-01-01", "end_date": "2023-12-31",
"domains": "cams_europe"}, timeout=300).json()
for p, reply in zip(batch, j):
annual[p] = np.nanmean(np.array(reply["hourly"]["nitrogen_dioxide"], dtype=float))
la_i = np.clip(np.round((np.array(lat) - lats[0]) / 0.1).astype(int), 0, len(lats) - 1)
lo_i = np.clip(np.round((np.array(lon) - lons[0]) / 0.1).astype(int), 0, len(lons) - 1)
field = np.zeros_like(grid, dtype="float32")
field[r, c] = [annual[(lats[a], lons[b])] for a, b in zip(la_i, lo_i)]
with rasterio.open("berlin_no2_on_pop_grid.tif", "w", driver="GTiff", height=grid.shape[0],
width=grid.shape[1], count=2, dtype="float32", crs="EPSG:4326",
transform=grid_tf, nodata=0) as dst:
dst.write(grid.astype("float32"), 1)
dst.write(field, 2)
print("berlin_no2_on_pop_grid.tif ready")
berlin_no2_on_pop_grid.tif ready
Example 1 — the reporting scale changes who is "exposed"
import geopandas as gpd, osmnx as ox, pandas as pd
from rasterio.features import rasterize
ox.settings.http_user_agent = "spatialworkflow-examples/1.0"
d = ox.features_from_place("Berlin, Germany", {"boundary": "administrative", "admin_level": "9"})
d = d[(d.admin_level == "9") & d.geom_type.isin(["Polygon", "MultiPolygon"])].reset_index()
with rasterio.open("berlin_no2_on_pop_grid.tif") as src:
zone = rasterize(((g, i + 1) for i, g in enumerate(d.geometry)), out_shape=pop.shape,
transform=src.transform, fill=0)
df = pd.DataFrame({"zone": zone[lived], "pop": pop[lived], "no2": no2[lived]})
df = df[df.zone > 0]
district_mean = df.groupby("zone").apply(lambda g: np.average(g.no2, weights=g["pop"]),
include_groups=False)
df["district_no2"] = df.zone.map(district_mean)
for threshold in (12, 12.5, 13):
cell = df.loc[df.no2 > threshold, "pop"].sum() / df["pop"].sum()
dist = df.loc[df.district_no2 > threshold, "pop"].sum() / df["pop"].sum()
print(f"above {threshold:>4} µg/m³: {cell:4.0%} of residents by 1 km cell, "
f"{dist:4.0%} by district average")
above 12 µg/m³: 63% of residents by 1 km cell, 67% by district average
above 12.5 µg/m³: 52% of residents by 1 km cell, 34% by district average
above 13 µg/m³: 36% of residents by 1 km cell, 23% by district average
The same people, the same field. Reported by district, a threshold either catches whole districts or none of their residents; reported by cell, it catches the people who actually live above it. The difference can run in either direction, and it is largest near the threshold.
Example 2 — how many people live next to a busy road
from scipy.signal import fftconvolve
roads = ox.features_from_bbox((13.08, 52.33, 13.77, 52.68),
{"highway": ["motorway", "trunk", "primary", "secondary"]})
roads = roads[roads.geom_type.isin(["LineString", "MultiLineString"])].to_crs(25833)
from shapely.geometry import box
import rasterio.warp, rasterio.enums
with rasterio.open("berlin_no2_on_pop_grid.tif") as src:
bounds = gpd.GeoSeries([box(*src.bounds)], crs=4326).to_crs(25833).total_bounds
res = 10
x0, y0, x1, y1 = bounds
w, h = int((x1 - x0) / res), int((y1 - y0) / res)
tf10 = rasterio.transform.from_origin(x0, y1, res, res)
near = rasterize(((g.buffer(50), 1) for g in roads.geometry), out_shape=(h, w),
transform=tf10, fill=0).astype("float32")
# fraction of each ~1 km population cell that lies within 50 m of such a road
with rasterio.open("berlin_no2_on_pop_grid.tif") as src:
frac = np.zeros(pop.shape, dtype="float32")
rasterio.warp.reproject(near, frac, src_transform=tf10, src_crs="EPSG:25833",
dst_transform=src.transform, dst_crs=src.crs,
resampling=rasterio.enums.Resampling.average)
near_road = (pop * frac)[lived].sum()
print(f"residents living within 50 m of a major or secondary road, if spread evenly "
f"within each cell: {near_road:,.0f} ({near_road / pop[lived].sum():.0%})")
residents living within 50 m of a major or secondary road, if spread evenly within each cell: 553,550 (15%)
About 550,000 people — 15% of residents — on this rough estimate. A 10 km model gives them the same exposure as their neighbours a street away. Road-side NO2 can be several µg/m³ higher — Berlin's seven kerbside stations averaged 24.6 µg/m³ in 2023 against 11.5 at its ten background stations, measured here — so the coarse field misclassifies a large, specific group — not at random, but everyone who lives on a main road.
Example 3 — the exposure distribution
order = np.argsort(no2[lived])
cum = np.cumsum(pop[lived][order]) / pop[lived].sum()
values = no2[lived][order]
for q in (0.1, 0.25, 0.5, 0.75, 0.9):
print(f"{q:.0%} of residents live in cells at or below {values[np.searchsorted(cum, q)]:.1f} µg/m³")
print(f"spread between the 10th and 90th percentile resident: "
f"{values[np.searchsorted(cum, 0.9)] - values[np.searchsorted(cum, 0.1)]:.1f} µg/m³")
10% of residents live in cells at or below 10.0 µg/m³
25% of residents live in cells at or below 11.0 µg/m³
50% of residents live in cells at or below 12.9 µg/m³
75% of residents live in cells at or below 13.5 µg/m³
90% of residents live in cells at or below 13.9 µg/m³
spread between the 10th and 90th percentile resident: 4.0 µg/m³
The whole exposure distribution on a 10 km field spans a few µg/m³ — less than the traffic increment at a single kerb. That narrowness is a property of the field, not of Berlin, and it is why the resolution of the concentration data has to be reported with every exposure figure.
Residence, resolution and reporting scale
Why residential exposure dominates
Residence is known for everyone, from censuses and registers; activity patterns are known only from surveys of a few thousand people. Cohort studies link health records to home addresses, so the exposure metric that can be matched to outcomes is the residential one. Its errors are well studied: it tends to understate contrasts, which biases health effects towards zero rather than inventing them.
Why resolution creates misclassification
When the concentration field is smoother than reality, everyone in a cell gets the cell's mean. People by main roads are underestimated and people in quiet courtyards overestimated. In a regression of health on exposure, that is measurement error; in an equity analysis, it is the difference between seeing an injustice and missing it.
Why the reporting scale matters
Aggregation is a smoothing step of its own. District averages compress the exposure distribution; thresholds applied after aggregation count districts, not people. The rule is the same as for population weighting: compute on the finest grid you trust, aggregate the results, not the inputs.
Indoor sources, mobility and ecological inference
- Indoor sources (gas cooking for NO2, wood burning for PM2.5) can exceed outdoor contributions for some households.
- Residential mobility: long-term studies need address histories, not a single address.
- Night-time population grids place people at home; daytime population grids exist for some countries.
- Uncertainty propagation: exposure errors should carry into health impact estimates as ranges.
- Ecological inference: district-level correlations between exposure and deprivation do not describe individuals.
Internal links
- How to calculate population-weighted exposure — the grid used here
- How to build a land-use regression model for NO2 — a field with road detail
- Modifiable areal unit problem explained — the reporting-scale effect in general
- Air quality monitoring networks explained — the kerbside increment
- Satellite NO2 explained — a field at neighbourhood scale
- Dasymetric mapping in Python — placing people more precisely than a grid
- How to estimate population exposure to heat — heat weighted by residents
FAQ
What is exposure assessment?
Estimating how much of a pollutant people are exposed to, by combining concentration data with information about where people are.
What is residential exposure?
The outdoor concentration at a person's home, usually as an annual mean. It is the standard proxy in population studies.
Why use population-weighted rather than area-weighted means?
Because exposure is about people; area weighting counts empty land as heavily as dense districts.
Does the resolution of the pollution data matter?
Yes. Coarse fields misclassify people near roads, and the misclassification is not random.
Should I report exposure by district?
Compute on the finest grid you trust and aggregate the results. Thresholds applied to district averages count the wrong people.
Is residential exposure the same as what people breathe?
No. It ignores time indoors, at work and in transit, which can raise or lower personal exposure; time-activity models need survey data that population grids do not carry.