SUHI Rural Reference Gives the Wrong Answer

On this page

Problem statement

Your surface urban heat island comes out at +6 °C, a colleague's for the same city and summer at −2 °C, and neither calculation has a bug. The urban side of the subtraction is fairly stable; the rural side is whatever the analyst decided "surroundings" means. A ring that is mostly dry cropland, one that is mostly forest, one that reaches up into mountains or includes a reservoir — each gives a different reference temperature, and the heat island inherits the whole difference.

The rural reference is the least discussed choice in a SUHI calculation and usually the one that moves the answer most.

Quick answer

Compute the SUHI with several defensible rural references and report the range. Exclude water and land far above or below the city's elevation; state the land cover of the reference.

import numpy as np, rasterio, pystac_client, planetary_computer
from rasterio.warp import reproject, Resampling
from scipy import ndimage

with rasterio.open("madrid_lst_jja2023.tif") as src:              # from the composite guide
    lst, count = src.read(1), src.read(2)
    tf, crs, shape = src.transform, src.crs, (src.height, src.width)
cat = pystac_client.Client.open("https://planetarycomputer.microsoft.com/api/stac/v1")

def on_grid(collection, asset, resampling, dtype, **search):
    out = np.zeros(shape, dtype=dtype)
    for it in cat.search(collections=[collection], bbox=[-4.05, 40.20, -3.35, 40.65], **search).items():
        tmp = np.zeros(shape, dtype=dtype)
        with rasterio.open(planetary_computer.sign(it.assets[asset].href)) as src:
            reproject(rasterio.band(src, 1), tmp, dst_transform=tf, dst_crs=crs, resampling=resampling)
        out = np.where(out == 0, tmp, out)
    return out

wc = on_grid("esa-worldcover", "map", Resampling.mode, "uint8", datetime="2021")
dem = on_grid("cop-dem-glo-30", "data", Resampling.bilinear, "float32")

urban = wc == 50
lab, _ = ndimage.label(urban)
city = ndimage.binary_closing(lab == np.bincount(lab.ravel())[1:].argmax() + 1, iterations=10)
dist = ndimage.distance_transform_edt(~city) * 30 / 1000
valid = np.isfinite(lst) & (count >= 3)
u = np.nanmedian(lst[city & urban & valid])
city_elev = np.median(dem[city & urban])
print(f"urban median LST {u:.1f} °C at a median elevation of {city_elev:.0f} m")

ring = (dist > 5) & (dist <= 15) & valid & ~urban
refs = {
    "ring 5–15 km, anything unbuilt": ring,
    "ring 5–15 km, no water": ring & (wc != 80),
    "ring, no water, within ±100 m of city": ring & (wc != 80) & (abs(dem - city_elev) <= 100),
    "ring, cropland only": ring & (wc == 40),
    "ring, grassland only": ring & (wc == 30),
    "ring, tree cover only": ring & (wc == 10),
}
for name, m in refs.items():
    r = np.nanmedian(lst[m])
    print(f"{name:<40} {m.sum():>8,} px  rural {r:5.1f} °C  SUHI {u - r:+5.1f} °C")
urban median LST 44.4 °C at a median elevation of 648 m
ring 5–15 km, anything unbuilt            637,594 px  rural  47.9 °C  SUHI  -3.5 °C
ring 5–15 km, no water                    629,436 px  rural  48.0 °C  SUHI  -3.6 °C
ring, no water, within ±100 m of city     443,888 px  rural  48.3 °C  SUHI  -3.9 °C
ring, cropland only                       209,657 px  rural  48.8 °C  SUHI  -4.4 °C
ring, grassland only                      298,063 px  rural  48.6 °C  SUHI  -4.2 °C
ring, tree cover only                     102,677 px  rural  44.1 °C  SUHI  +0.3 °C

The same urban temperature, six rural references, a spread of 4.7 °C in the heat island, from −4.4 °C against cropland to +0.3 °C against woodland — a change of sign that depends only on what the analyst called rural.

Map of Madrid with the city in grey and the rural ring 5 to 15 km around it coloured by land cover: cropland yellow, grassland light green, tree cover dark green, water blue. Map of Madrid with the city in grey and the rural ring 5 to 15 km around it coloured by land cover: cropland yellow, grassland light green, tree cover dark green, water blue.
What “rural” contains. The ring around Madrid is cropland to the east and south, grassland and woodland in the Guadarrama foothills to the north-west. Choose the cropland and the city is 4.4 °C cooler; choose the trees and it is 0.3 °C warmer. The map is why the rural reference has to be stated.

Remove water, limit elevation, test several rings

1. Remove water

Reservoirs and rivers are cool in summer daytime and warm at night. Any water in the reference biases it.

2. Limit elevation difference

Surface temperature falls with altitude. Keep the reference within about 100 m of the city's elevation, or correct for a lapse rate.

3. Report the land cover of the reference

Dry cropland, grassland and forest have very different summer surface temperatures. Say which the reference is — or match it to what the city replaced.

4. Test several rings

Near rings may still be influenced by the city; far rings reach different climates or terrain. The spread across rings is part of the uncertainty.

5. Report a range

A single SUHI number hides the reference choice. Give the range across defensible references along with the preferred value.

Madrid surface heat island intensity computed with six different rural reference definitions. SUHI by rural reference, °Cring, cropland only−4.4 °Cring, grassland only−4.2 °Cring, ±100 m elevation−3.9 °Cring, no water−3.6 °Cring, anything unbuilt−3.5 °Cring, tree cover only+0.3 °C02.5°5°Urban median 44.4 °C throughout; only the reference changes spatialworkflow.io
One city, one summer, six rural references.

The reference by ring and by direction

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 — what the ring is made of

names = {10: "tree cover", 20: "shrubland", 30: "grassland", 40: "cropland", 60: "bare", 80: "water"}
m = ring
for code, n in names.items():
    share = (wc[m] == code).mean()
    if share > 0.01:
        print(f"{n:<11} {share:5.1%} of the ring, median LST {np.nanmedian(lst[m & (wc == code)]):5.1f} °C")
tree cover  16.1% of the ring, median LST  44.1 °C
shrubland    2.4% of the ring, median LST  44.6 °C
grassland   46.7% of the ring, median LST  48.6 °C
cropland    32.9% of the ring, median LST  48.8 °C
water        1.3% of the ring, median LST  33.1 °C

An "unbuilt" ring is a mixture: here 47% grassland and 33% cropland, both near 48.7 °C, and 16% tree cover at 44.1 °C. Its median temperature depends on the mixture — which in turn depends on where the city happens to sit.

Example 2 — the reference by direction

rows_, cols_ = np.indices(shape)
cy, cx = [np.median(a[city]) for a in (rows_, cols_)]
angle = np.degrees(np.arctan2(-(rows_ - cy), cols_ - cx)) % 360
for lo, name in ((45, "north"), (135, "west"), (225, "south"), (315, "east")):
    sector = ((angle - lo) % 360 < 90) & refs["ring 5–15 km, no water"]
    print(f"{name:<5}: {np.nanmedian(lst[sector]):5.1f} °C, elevation {np.median(dem[sector]):4.0f} m, "
          f"tree share {(wc[sector] == 10).mean():.0%}")
north:  48.2 °C, elevation  718 m, tree share 18%
west :  47.5 °C, elevation  633 m, tree share 20%
south:  47.2 °C, elevation  585 m, tree share 18%
east :  48.5 °C, elevation  704 m, tree share 10%

The countryside around a city is not uniform: the four sectors differ by 1.3 °C, by 130 m of elevation and by a factor of two in tree share. A reference drawn only from one side — as happens when a study area is clipped — takes that side's land cover and terrain with it.

Example 3 — the city against what it replaced

pre = ring & np.isin(wc, [30, 40])                                     # the open land Madrid expanded into
print(f"SUHI against open farmland and grassland: {u - np.nanmedian(lst[pre]):+.1f} °C")
print(f"SUHI against woodland:                    {u - np.nanmedian(lst[ring & (wc == 10)]):+.1f} °C")
SUHI against open farmland and grassland: -4.3 °C
SUHI against woodland:                    +0.3 °C

The question "how much warmer did building the city make this land?" wants a reference like the land that was built on. Around Madrid that is largely open farmland and grassland, not woodland — and against it the late-morning heat island is −4.3 °C, a cool island.

A city with a rural ring, showing water, high ground and different land covers that should be excluded or reported. what hides in the ringthe rural ring around Madridcitywoodland 16%water 1%hillsgrass 47% · crops 33%Exclude water and high ground; report the land cover mix spatialworkflow.io
What hides in the ring: water, hills and mixed land cover.

The reference matters more than the city

Why the reference matters more than the city

The urban temperature is an average over many similar built surfaces. The rural reference averages very different surfaces — irrigated fields, dry pasture, forest, bare soil — whose summer temperatures differ by more than the heat island itself. Small changes in the mix move the reference a lot.

Why daytime SUHI can be negative

In a dry summer, unirrigated grassland and harvested cropland have no water to evaporate and bake in the sun. The city has irrigated parks, trees and building shade. Against such a reference the city is cooler at midday; against woodland it is warmer.

Why elevation must be controlled

Air and surface temperatures fall with altitude — for air about 6.5 °C per kilometre on average. A reference that climbs into nearby hills is cooler for reasons unrelated to urbanisation.

Median surface temperature, elevation and tree share of the rural ring in four sectors around Madrid. the ring by directionsectormedian LSTelevationtree sharenorth48.2 °C718 m18%west47.5 °C633 m20%south47.2 °C585 m18%east48.5 °C704 m10%Against the open land Madrid replaced: −4.3 °C; against woodland: +0.3 °C spatialworkflow.io
A reference clipped to one side takes that side’s land and terrain with it.

Night-time islands and neighbouring towns

  • Night-time SUHI is less sensitive to the reference because rural surfaces converge at night; daytime values are most affected.
  • Seasons: harvest turns green cropland to bare stubble in weeks, changing the reference within one summer.
  • Neighbouring towns in the ring raise the reference; exclude all built-up land, not just the main city.
  • Multi-city studies need one rule applied everywhere, and even then comparisons across climates are loose.
  • Air-temperature UHI uses rural stations, which carry their own siting problems.

FAQ

Why is my SUHI negative?

The rural reference is hotter than the city — common in daytime in dry climates where the countryside is dry grass or bare soil.

What should the rural reference be?

Unbuilt land near the city, excluding water, within about 100 m of the city's elevation, with its land cover reported.

How far out should the rural ring be?

Typically a few to a few tens of kilometres beyond the urban edge; test several and report the spread.

Should I include forests in the rural reference?

Only if they represent the surroundings; report the land cover mix so readers can judge.

Does the reference problem affect night-time SUHI?

Less than daytime, but water and elevation still matter.

How do I make SUHI comparable across cities?

Apply one fixed rule everywhere and report the reference land cover; comparisons across climates remain approximate.