How to Estimate Population Exposure to Heat in Python

On this page

Problem statement

A surface temperature map shows where the city is hot. Heat policy needs where people are hot: how many residents live among the hottest surfaces, and in which districts. That means combining the heat map with a population grid — and the two come at very different resolutions (30 m heat, 100 m or 1 km population), which decides how the combination has to be done.

The result is a distribution: how the city's population is spread across the range of surface temperatures. Its upper tail — people living in the hottest tenth of the city — is the headline most programmes use.

Quick answer

Average the LST composite onto the population grid, then weight:

import io, zipfile, requests, numpy as np, rasterio
from rasterio.warp import reproject, Resampling
from rasterio.windows import from_bounds

name = "GHS_POP_E2020_GLOBE_R2023A_4326_30ss"
zipfile.ZipFile(io.BytesIO(requests.get(
    "https://jeodpp.jrc.ec.europa.eu/ftp/jrc-opendata/GHSL/GHS_POP_GLOBE_R2023A/"
    f"{name}/V1-0/tiles/{name}_V1_0_R5_C18.zip", timeout=600).content)).extractall("ghsl_es")
BOUNDS = (-3.85, 40.32, -3.55, 40.52)                           # the city of Madrid and its edge
with rasterio.open(f"ghsl_es/{name}_V1_0_R5_C18.tif") as src:
    win = from_bounds(*BOUNDS, src.transform)
    pop = np.clip(src.read(1, window=win), 0, None)
    ptf, pcrs = src.window_transform(win), src.crs

lst_on_pop = np.full(pop.shape, np.nan, dtype="float32")
with rasterio.open("madrid_lst_jja2023.tif") as src:            # from the composite guide
    reproject(rasterio.band(src, 1), lst_on_pop, dst_transform=ptf, dst_crs=pcrs,
              resampling=Resampling.average, src_nodata=np.nan, dst_nodata=np.nan)

ok = np.isfinite(lst_on_pop) & (pop > 0)
w, t = pop[ok], lst_on_pop[ok]
print(f"{w.sum():,.0f} people in {ok.sum():,} populated cells of ~0.7 km²")
print(f"area-weighted mean LST {t.mean():.1f} °C; population-weighted {np.average(t, weights=w):.1f} °C")
hot = t >= np.percentile(t, 90)
print(f"people in the hottest 10% of populated cells: {w[hot].sum():,.0f} ({w[hot].sum() / w.sum():.0%})")
4,285,905 people in 849 populated cells of ~0.7 km²
area-weighted mean LST 44.8 °C; population-weighted 43.1 °C
people in the hottest 10% of populated cells: 19,858 (0%)

The population-weighted mean says whether people live on hotter or cooler ground than the average cell, and in Madrid they live on cooler ground: 43.1 °C against 44.8 °C. The hottest tenth of populated cells holds 19,858 people, under half a percent of the 4.3 million — those cells are the sparsely populated fringe of dry farmland, airport and industry, the hottest surfaces at late morning.

Two grid maps of Madrid on the same cells: population per cell in blues on the left, darkest in the centre; summer surface temperature per cell on the right, coolest in the centre and hottest at the edges. Two grid maps of Madrid on the same cells: population per cell in blues on the left, darkest in the centre; summer surface temperature per cell on the right, coolest in the centre and hottest at the edges.
The two layers the weighting combines, cell by cell. The people (left) are concentrated in the centre; the hot ground (right) is at the edge, in farmland, industrial estates and the airport. That mismatch is why the population-weighted mean, 43.1 °C, is lower than the area-weighted 44.8 °C.

Heat on the population grid, valid cells, weighted statistics

1. Bring the heat map to the population grid

Average the 30 m LST within each population cell. Never interpolate population to 30 m: it is a count, and it would be invented at that resolution.

2. Keep cells with both people and valid LST

Cloud gaps and water leave cells without LST; unpopulated cells carry no exposure.

3. Compute weighted statistics

Population-weighted mean, percentiles and the share of people above thresholds.

4. Define "hot" relative to the city

The hottest decile of populated cells, or cells above the citywide 90th percentile. Absolute surface temperatures depend on the summer.

5. Break it down by district or group

Join to districts, age groups or deprivation indices to see who is most exposed — with the caveat that residential surface temperature is not personal exposure.

From a 30 m LST composite averaged onto a population grid to population-weighted statistics and the share of people in the hottest cells. heat to peopleLST 30 msummer compositeaverageonto the GHS-POP gridweightby residentstailshottest decileNever spread a population count onto 30 m pixels spatialworkflow.io
Heat to the population grid, never population to the heat grid.

Madrid's population across surface temperature

The data used on this page

The summer 2023 land surface temperature composite of Madrid from the composite guide: the median of every Landsat 8 and 9 Collection 2 Level-2 scene from June to August 2023 on the Microsoft Planetary Computer with more than a fifth of the area clear, on a 30 m UTM 30N grid, with the count of clear observations as a second band. The setup rebuilds it if madrid_lst_jja2023.tif is missing.

import os

import numpy as np
import planetary_computer
import pystac_client
import rasterio
from rasterio.transform import from_origin
from rasterio.warp import Resampling, reproject, transform_bounds

if not os.path.exists("madrid_lst_jja2023.tif"):        # the composite guide's code, without the prints
    AOI = (-4.05, 40.20, -3.35, 40.65)
    DST_CRS = "EPSG:32630"
    x0, y0, x1, y1 = transform_bounds("EPSG:4326", DST_CRS, *AOI)
    x0, y1 = np.floor(x0 / 30) * 30, np.ceil(y1 / 30) * 30
    W, H = int(np.ceil((x1 - x0) / 30)), int(np.ceil((y1 - y0) / 30))
    DST_TF = from_origin(x0, y1, 30, 30)
    cat = pystac_client.Client.open("https://planetarycomputer.microsoft.com/api/stac/v1")
    items = [i for i in cat.search(collections=["landsat-c2-l2"], bbox=list(AOI),
                                   datetime="2023-06-01/2023-08-31").items()
             if i.properties["platform"] in ("landsat-8", "landsat-9") and i.properties["eo:cloud_cover"] < 60]

    def on_grid(item, asset):
        out = np.zeros((H, W), dtype="uint16")
        with rasterio.open(planetary_computer.sign(item.assets[asset].href)) as src:
            reproject(rasterio.band(src, 1), out, dst_transform=DST_TF, dst_crs=DST_CRS,
                      resampling=Resampling.nearest, src_nodata=0 if asset == "lwir11" else 1, dst_nodata=0)
        return out

    layers = []
    for it in sorted(items, key=lambda i: i.datetime):
        dn, qa = on_grid(it, "lwir11"), on_grid(it, "qa_pixel")
        t = np.where(dn == 0, np.nan, dn * 0.00341802 + 149.0 - 273.15)
        t[(((qa >> 1) & 1) | ((qa >> 3) & 1) | ((qa >> 4) & 1)) == 1] = np.nan
        if np.isfinite(t).mean() > 0.2:
            layers.append(t)
    stack = np.stack(layers)
    profile = {"driver": "GTiff", "height": H, "width": W, "count": 2, "dtype": "float32",
               "crs": DST_CRS, "transform": DST_TF, "nodata": np.nan, "compress": "deflate"}
    with rasterio.open("madrid_lst_jja2023.tif", "w", **profile) as dst:
        dst.write(np.nanmedian(stack, axis=0).astype("float32"), 1)
        dst.write(np.isfinite(stack).sum(axis=0).astype("float32"), 2)
        dst.set_band_description(1, "median LST °C, Landsat 8/9, Jun–Aug 2023")
        dst.set_band_description(2, "clear observations")
with rasterio.open("madrid_lst_jja2023.tif") as src:
    print(src.shape, src.crs, src.descriptions or "", f"median {np.nanmedian(src.read(1)):.1f} °C")
(1682, 1994) EPSG:32630 ('median LST °C, Landsat 8/9, Jun–Aug 2023', 'clear observations') median 46.9 °C

Example 1 — the population across surface temperature

edges = np.percentile(t, [0, 20, 40, 60, 80, 100])
for lo, hi in zip(edges[:-1], edges[1:]):
    m = (t >= lo) & (t <= hi)
    print(f"LST {lo:5.1f}–{hi:5.1f} °C: {m.mean():4.0%} of cells, {w[m].sum() / w.sum():4.0%} of people")
LST  36.6– 42.4 °C:  20% of cells,  35% of people
LST  42.4– 43.7 °C:  20% of cells,  37% of people
LST  43.7– 45.4 °C:  20% of cells,  19% of people
LST  45.4– 47.2 °C:  20% of cells,   7% of people
LST  47.2– 53.8 °C:  20% of cells,   2% of people

If people were spread evenly over the city's surfaces, each quintile of cells would hold a fifth of them. Here the two coolest quintiles hold 72% of the people and the two hottest only 9%: daytime surface heat and residents are concentrated in different places.

Example 2 — sensitivity to the population grid resolution

coarse_pop = pop[: pop.shape[0] // 3 * 3, : pop.shape[1] // 3 * 3].reshape(
    pop.shape[0] // 3, 3, pop.shape[1] // 3, 3).sum(axis=(1, 3))
coarse_lst = np.nanmean(lst_on_pop[: pop.shape[0] // 3 * 3, : pop.shape[1] // 3 * 3].reshape(
    pop.shape[0] // 3, 3, pop.shape[1] // 3, 3), axis=(1, 3))
okc = np.isfinite(coarse_lst) & (coarse_pop > 0)
print(f"~0.7 km² cells: population-weighted LST {np.average(t, weights=w):.2f} °C")
print(f"~6 km² cells:   population-weighted LST "
      f"{np.average(coarse_lst[okc], weights=coarse_pop[okc]):.2f} °C")
top_c = coarse_lst[okc] >= np.percentile(coarse_lst[okc], 90)
print(f"share of people in the hottest decile: fine {w[hot].sum() / w.sum():.0%}, "
      f"coarse {coarse_pop[okc][top_c].sum() / coarse_pop[okc].sum():.0%}")
~0.7 km² cells: population-weighted LST 43.08 °C
~6 km² cells:   population-weighted LST 43.29 °C
share of people in the hottest decile: fine 0%, coarse 0%

The weighted mean barely changes with resolution, and at both resolutions the hottest decile holds almost no one. In a city where the hottest cells are populated the tail is where resolution matters: coarser cells average hot blocks with cooler ones, and the people in the hottest places disappear into their surroundings.

Example 3 — does exposure follow density?

dens = w / 0.7                                                 # people per km², approximately
r = np.corrcoef(np.log1p(dens), t)[0, 1]
print(f"correlation between log population density and LST across populated cells: {r:.2f}")
correlation between log population density and LST across populated cells: -0.61

The denser the cell, the cooler its late-morning surface. Madrid's dense centre has street trees, courtyards and the shade of tall buildings; its thinly populated edge is dry grassland, runways and warehouse roofs, the hottest surfaces of all. That reverses the usual expectation, and it is a daytime surface result: at night, when stored heat in dense fabric matters most for sleep, the ranking can differ, and air temperature — not LST — is what residents feel.

Share of people in each quintile of land surface temperature across populated cells in Madrid. share of people by LST quintile (each 20% of cells)36.6–42.4 °C35% of people42.4–43.7 °C37%43.7–45.4 °C19%45.4–47.2 °C7%47.2–53.8 °C2%020%40%Population-weighted mean 43.1 °C; area-weighted 44.8 °C spatialworkflow.io
Where the people are, across the city's surface temperatures.

Why weight by people, and why the tail matters

Why weight by population

Heat matters where people live. A hot airport apron or industrial estate raises a city's average LST without exposing many residents; a moderately hot, densely populated district may expose far more. Weighting by population turns a map of surfaces into a measure of residents' exposure.

Why the tail is the sensitive part

Averages are robust to resolution and grid choices; percentiles and threshold shares are not. The hottest places are small and dense, and their residents are identifiable only if both grids resolve them.

Why this is not personal exposure

Residents are placed at home; people spend their days elsewhere, indoors, where temperatures depend on buildings and cooling. Surface temperature is also not air temperature. The result locates where outdoor surfaces around homes are hottest, which is a planning target, not a health measurement.

Madrid residents in all populated cells, in the two hottest quintiles and in the hottest tenth of cells. residents by surface heat, late morningall populated cells4.3M9% of people on 40% of cells÷ 11two hottest quintiles385,700under 0.5%÷ 19hottest tenth of cells19,858band width is log-scaledDenser cells are cooler here (r = −0.61): a daytime surface result spatialworkflow.io
Madrid’s hottest late-morning surfaces are where few people live.

GHSL tiles, night heat and vulnerability

  • GHSL tiles are 10°; Madrid is in R5_C18 (40–50°N, 10°W–0°).
  • 100 m population grids (GHSL, WorldPop) resolve more of the tail than 1 km ones.
  • Night-time heat exposure needs night-time LST or air temperature models.
  • Vulnerability: age, health and housing quality change who is harmed by the same exposure.
  • Temporal mismatch: population 2020 and LST 2023 is normal; state both.

FAQ

How do I calculate how many people live in hot areas?

Average surface temperature onto the population grid and sum the population of cells above a threshold.

Should I resample population to the LST resolution?

No. Average LST onto the population grid; population is a count.

What counts as a hot area?

A relative threshold such as the citywide 90th percentile of populated cells is more comparable across years than an absolute one.

Why is population-weighted LST higher than the average?

Dense districts tend to be built-up and less green, with hotter surfaces.

Does grid resolution matter?

The mean hardly changes; the share of people in the hottest places does.

Is this the same as heat exposure for health?

No — it is residential surface heat, not personal air temperature exposure.