Building an Urban Heat Island Index by Census Block
This guide, part of Remote Sensing Indices for Health Studies, builds the composite index that a city heat programme actually uses: one number per census block, ranked, with enough documentation that the ranking survives a council meeting.
Problem Context & Constraints
A heat vulnerability index is a policy instrument wearing a statistical costume. It combines several layers — surface temperature, tree canopy, impervious surface, sometimes air conditioning prevalence and age structure — into a score that decides where shade trees and cooling centres go. Each combination step embeds a value judgement about how much one factor matters relative to another, and those judgements are usually buried in an unstated weighting.
Three specific problems follow. Scale mismatch: surface temperature at 30 m, canopy at 1 m, population at block level, and air conditioning at survey-area level do not share a geography, and the index inherits the coarsest one whatever resolution it is rendered at. Standardisation choice: converting each layer to a comparable scale by z-score, by percentile rank or by min–max produces different rankings from identical inputs, and the difference is largest in the tails, which is where the programme acts. Reference ambiguity: “urban heat island” is a difference from a rural baseline, and the baseline — which rural area, measured when — is frequently left undefined, which makes the magnitude uninterpretable even when the ranking is useful.
The constraint that keeps this honest is separating the measurement from the value judgement. The layers and their standardisation are technical; the weights are policy. Both must be recorded, but only one of them is the analyst’s to choose.
Prerequisites
python3.11numpy1.26.4,pandas2.2.2,geopandas0.14.4,rasterio1.3.10- A night-time surface temperature composite derived as in the sibling guide
- Tree canopy and impervious surface layers with documented vintages
- Census block geometry and population, matched to the same vintage
- A declared rural reference area for the heat island differential
Step-by-Step Solution
Summarise each layer to blocks, standardise on one declared scale, apply published weights, and test how much the ranking moves under reasonable alternatives.
# Block-level heat vulnerability index with explicit weights and a rank stability check.
# Pinned: numpy==1.26.4, pandas==2.2.2, geopandas==0.14.4
import logging
import numpy as np
import pandas as pd
import geopandas as gpd
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("exposure.heat")
# Published weights. These come from the programme's governance body, not from
# this script, and they are part of the released documentation.
WEIGHTS = {"lst_night": 0.40, "canopy_deficit": 0.25,
"impervious": 0.20, "age65_share": 0.15}
def standardise(s: pd.Series, method: str = "percentile") -> pd.Series:
"""Put a layer on a common 0-1 scale. The METHOD changes the ranking tails."""
x = s.astype(float)
if method == "percentile":
return x.rank(pct=True, na_option="keep")
if method == "zscore":
return (x - x.mean()) / x.std(ddof=0)
if method == "minmax":
rng = x.max() - x.min()
return (x - x.min()) / rng if rng > 0 else x * 0.0
raise ValueError(f"unknown standardisation: {method}")
def heat_index(blocks: pd.DataFrame, weights: dict = WEIGHTS,
method: str = "percentile", min_pop: int = 20) -> pd.DataFrame:
"""Weighted composite index per block, with sparse blocks excluded."""
missing = [c for c in weights if c not in blocks.columns]
if missing:
raise KeyError(f"index inputs missing: {missing}")
if abs(sum(weights.values()) - 1.0) > 1e-9:
raise ValueError("weights must sum to one so the score is interpretable")
df = blocks.copy()
keep = df["population"] >= min_pop
log.info("blocks: %d total, %d below population floor excluded",
len(df), int((~keep).sum()))
df = df[keep].copy()
z = pd.DataFrame({c: standardise(df[c], method) for c in weights}, index=df.index)
df["heat_index"] = sum(z[c] * w for c, w in weights.items())
df["heat_rank"] = df["heat_index"].rank(ascending=False, method="min").astype(int)
df["heat_decile"] = pd.qcut(df["heat_index"], 10, labels=False, duplicates="drop") + 1
log.info("index computed for %d blocks, method=%s, weights=%s",
len(df), method, weights)
return df
def rank_stability(blocks: pd.DataFrame, weights: dict = WEIGHTS,
methods=("percentile", "zscore", "minmax"),
jitter: float = 0.10, n_draws: int = 200,
seed: int = 20260918) -> pd.DataFrame:
"""How often does a block stay in the top decile under reasonable alternatives?"""
rng = np.random.default_rng(seed)
base = heat_index(blocks, weights, methods[0])
top = set(base.loc[base["heat_decile"] == 10, "geoid"])
hits = {g: 0 for g in base["geoid"]}
trials = 0
for m in methods:
for _ in range(n_draws):
w = {k: max(0.0, v + rng.uniform(-jitter, jitter)) for k, v in weights.items()}
total = sum(w.values())
w = {k: v / total for k, v in w.items()}
d = heat_index(blocks, w, m)
for g in d.loc[d["heat_decile"] == 10, "geoid"]:
hits[g] = hits.get(g, 0) + 1
trials += 1
out = pd.DataFrame({"geoid": list(hits), "top_decile_frequency":
[hits[g] / trials for g in hits]})
out["in_base_top_decile"] = out["geoid"].isin(top)
stable = out[(out["in_base_top_decile"]) & (out["top_decile_frequency"] > 0.8)]
log.info("rank stability over %d variants: %d/%d base top-decile blocks stable",
trials, len(stable), len(top))
return out
The rank stability check is what turns this from an index into a defensible allocation. It perturbs the weights within a stated tolerance and swaps the standardisation method, then reports how often each block stays in the top decile. Blocks that remain there across every variant are robust priorities; blocks that appear in half the variants are on a boundary, and a programme that treats them as identical to the robust ones is claiming a precision the data do not support.
The population floor exists because a block with four residents can top the index on a hot parking lot. Excluding them is a policy choice too, and the floor belongs in the documentation alongside the weights.
Validation & Edge Cases
Blocks with no valid temperature cells. Small downtown blocks after masking can end up empty. Report them rather than filling them, and consider aggregating to block groups where the emptiness is widespread.
A canopy layer from a different year. High-resolution canopy products are updated irregularly. A 2018 canopy layer combined with a 2024 temperature composite will misrepresent any block where planting or removal happened, which is exactly where a programme wants to measure change.
Correlated inputs. Impervious surface and surface temperature correlate strongly, so weighting both effectively double-weights the same construct. Report the correlation matrix alongside the index; where two inputs correlate above roughly 0.8, the programme should decide whether it means to weight that construct twice.
Standardisation on a clipped extent. Percentile ranks are relative to whatever area is included. Adding the suburbs to a city-only index moves every rank. Fix the extent before computing and state it.
The rural reference. Where a heat island differential is reported rather than a rank, the rural reference area and its measurement dates must be stated. A differential against a nearby irrigated agricultural area and one against dry scrubland differ by several degrees.
INFO exposure.heat blocks: 14208 total, 2166 below population floor excluded
INFO exposure.heat index computed for 12042 blocks, method=percentile, weights={'lst_night': 0.4, 'canopy_deficit': 0.25, 'impervious': 0.2, 'age65_share': 0.15}
INFO exposure.heat rank stability over 600 variants: 812/1204 base top-decile blocks stable
The standardisation method matters most in the tails, which is exactly where a cooling programme allocates its resources:
Compliance Notes
Publish the index with its recipe. The record needs every input layer with vintage and native resolution, the summarisation method to blocks, the standardisation method, the weights with the body that approved them and the date, the population floor, the excluded-block count, the correlation matrix among inputs, and the rank stability results.
Heat indices drive resource allocation, so they attract scrutiny from the communities they rank. Two practices reduce avoidable conflict. Publish the block-level inputs alongside the score, so a neighbourhood association can see why their block scored as it did rather than only the score. And publish the stability analysis, because a block that sits on the boundary deserves to be described as such rather than presented as a precise fifty-first place.
Related Topics
- Deriving Land Surface Temperature for Heat Vulnerability — producing the thermal input this index depends on.
- Remote Sensing Indices for Health Studies — the parent guide on what each index measures.
- Calculating the Two-Step Floating Catchment Area Index — a composite index built with the same separation of measurement from policy weighting.