Source code: Land surface temperature from Landsat in Python: mapping London's urban heat island
Runnable Python scripts, requirements and console output for the tutorial “Land surface temperature from Landsat in Python: mapping London's urban heat…
Last updated
These are the exact scripts used to produce every figure and number in Land surface temperature from Landsat in Python: mapping London's urban heat island. Download them, install the requirements into a fresh virtual environment and run them from this folder.
- lst_urban_heat_island.py
- requirements.txt
- console.txt: the console output recorded when we ran it
python -m venv .venv.venv\Scripts\activate # macOS/Linux: source .venv/bin/activatepip install -r requirements.txtpython lst_urban_heat_island.pylst_urban_heat_island.py
"""Urban heat island mapping with Landsat land surface temperature in Python: London, 18 July 2022.
Streams a Landsat 9 Collection 2 Level-2 scene from the Microsoft Planetary Computer, converts theST_B10 surface temperature band to degrees Celsius, masks cloud with QA_PIXEL, and then measuresthe surface urban heat island: LST by ESA WorldCover land cover and by NDVI, urban against ruralreference land, a west-east transect through central London, the hottest clusters overlaid onOpenStreetMap, and a check against air temperatures measured at weather stations.Run with: python lst_urban_heat_island.py"""import jsonimport platformimport timefrom importlib.metadata import versionfrom pathlib import Path
import geopandas as gpdimport matplotlib
matplotlib.use("Agg")import matplotlib.patheffects as peimport matplotlib.pyplot as pltfrom matplotlib.lines import Line2Dimport numpy as npimport osmnx as oximport pandas as pdimport planetary_computerimport pystac_clientimport rasterioimport rasterio.featuresimport requestsfrom pyproj import Transformerfrom rasterio.enums import Resamplingfrom rasterio.vrt import WarpedVRTfrom rasterio.windows import from_boundsfrom scipy import ndimage
HERE = Path(__file__).parentOUT, DATA, CACHE = HERE / "output", HERE / "data", HERE / "cache"for d in (OUT, DATA, CACHE): d.mkdir(exist_ok=True)plt.rcParams.update({"figure.dpi": 150, "savefig.bbox": "tight", "font.size": 9})pd.set_option("display.width", 140)HEADERS = {"User-Agent": "TechnicalGIS tutorial (https://www.technicalgis.com)"}
print("=== versions ===")print(f"python {platform.python_version()} | pystac-client {pystac_client.__version__} | " f"planetary-computer {version('planetary-computer')} | rasterio {rasterio.__version__} | " f"osmnx {ox.__version__} | numpy {np.__version__} | pandas {pd.__version__}")
def section(title: str) -> None: print(f"\n=== {title} ===")
# --- Step 1: find a clear Landsat 8/9 scene on a hot day ------------------------------------STAC_URL = "https://planetarycomputer.microsoft.com/api/stac/v1"AOI = (-0.95, 51.15, 0.65, 51.85) # lon/lat: Greater London plus a rural margin on every sideCENTRE = (-0.1276, 51.5072) # Trafalgar Square / Charing Cross, the traditional centre of London
catalog = pystac_client.Client.open(STAC_URL, modifier=planetary_computer.sign_inplace)search = catalog.search(collections=["landsat-c2-l2"], bbox=AOI, datetime="2022-06-01/2022-08-31", query={"platform": {"in": ["landsat-8", "landsat-9"]}, "eo:cloud_cover": {"lt": 10}})summer = sorted(search.items(), key=lambda i: i.datetime)section("STAC search: clear Landsat 8/9 scenes over London, summer 2022")for it in summer: p = it.properties print(f"{it.datetime:%Y-%m-%d %H:%M} UTC {p['platform']} path/row {p['landsat:wrs_path']}/{p['landsat:wrs_row']}" f" cloud {p['eo:cloud_cover']:5.2f}%")item = next(it for it in summer if it.id == "LC09_L2SP_201024_20220718_02_T1")p = item.propertiesprint(f"\nusing {item.id}: {item.datetime:%d %B %Y %H:%M} UTC, sun elevation {p['view:sun_elevation']:.1f} deg, " f"{p['proj:code']}")st = item.assets["lwir11"].extra_fields["raster:bands"][0]print(f"ST_B10 (asset 'lwir11'): scale {st['scale']}, offset {st['offset']}, unit {st['unit']}, nodata {st['nodata']}")
# --- Step 2: read the bands, apply scale and offset, convert to degrees Celsius --------------------CRS = p["proj:code"] # EPSG:32631, the scene's native UTM zoneto_utm = Transformer.from_crs("EPSG:4326", CRS, always_xy=True)xs, ys = to_utm.transform([AOI[0], AOI[2], AOI[0], AOI[2]], [AOI[1], AOI[1], AOI[3], AOI[3]])BOUNDS = (min(xs), min(ys), max(xs), max(ys))BANDS = ["lwir11", "qa_pixel", "qa", "red", "green", "blue", "nir08"]
raw_file = CACHE / f"{item.id}.npz"if not raw_file.exists(): raw = {} for b in BANDS: with rasterio.open(item.assets[b].href) as src: win = from_bounds(*BOUNDS, src.transform).round_offsets().round_lengths() raw[b] = src.read(1, window=win) transform = src.window_transform(win) raw["transform"] = np.array(transform)[:6] np.savez_compressed(raw_file, **raw)raw = dict(np.load(raw_file))TRANSFORM = rasterio.Affine(*raw.pop("transform"))HEIGHT, WIDTH = raw["lwir11"].shapex0, y1 = TRANSFORM.c, TRANSFORM.fx1, y0 = x0 + WIDTH * TRANSFORM.a, y1 + HEIGHT * TRANSFORM.eEXTENT = (x0, x1, y0, y1)PX_KM2 = 30 * 30 / 1e6
dn = raw["lwir11"]lst_k = np.where(dn > 0, dn * 0.00341802 + 149.0, np.nan) # Collection 2 Level-2 ST scale and offsetlst = lst_k - 273.15 # kelvin to degrees Celsiusst_unc = np.where(raw["qa"] >= 0, raw["qa"] * 0.01, np.nan) # ST_QA: uncertainty in kelvin
def sr(band: str) -> np.ndarray: """Collection 2 surface reflectance: DN * 0.0000275 - 0.2.""" v = raw[band] return np.where(v > 0, v * 2.75e-5 - 0.2, np.nan)
red, nir = sr("red"), sr("nir08")with np.errstate(divide="ignore", invalid="ignore"): ndvi = (nir - red) / (nir + red)section("surface temperature")print(f"grid {WIDTH} x {HEIGHT} pixels of 30 m ({(x1 - x0) / 1000:.0f} x {(y1 - y0) / 1000:.0f} km)")print(f"DN {np.nanmin(dn[dn > 0])}..{dn.max()} -> {np.nanmin(lst_k):.1f}..{np.nanmax(lst_k):.1f} K -> " f"{np.nanmin(lst):.1f}..{np.nanmax(lst):.1f} degC")
# --- Step 3: mask cloud, shadow and water with QA_PIXEL ----------------------------------------------qa = raw["qa_pixel"]bit = lambda b: (qa >> b) & 1 == 1 # noqa: E731QA_BITS = {"fill": 0, "dilated cloud": 1, "cirrus": 2, "cloud": 3, "cloud shadow": 4, "snow": 5, "clear": 6, "water": 7}for name, b in QA_BITS.items(): print(f"QA_PIXEL bit {b} ({name}): {bit(b).mean():.2%}")bad = bit(0) | bit(1) | bit(2) | bit(3) | bit(4) | bit(5)bad = ndimage.binary_dilation(bad, iterations=3) # 90 m buffer: thermal pixels are really 100 mwater = bit(7)ok = ~bad & ~water & np.isfinite(lst)print(f"usable land pixels: {ok.mean():.1%} | ST_QA uncertainty over land: median {np.nanmedian(st_unc[ok]):.2f} K, " f"95th pct {np.nanpercentile(st_unc[ok], 95):.2f} K")with rasterio.open(OUT / "lst_london_2022-07-18.tif", "w", driver="GTiff", width=WIDTH, height=HEIGHT, count=1, dtype="float32", crs=CRS, transform=TRANSFORM, nodata=np.nan, compress="deflate") as dst: dst.write(np.where(ok, lst, np.nan).astype("float32"), 1)
# --- Step 4: Greater London boundary and ESA WorldCover land cover ---------------------------------------gl_file = DATA / "greater_london.gpkg"if not gl_file.exists(): ox.settings.cache_folder = CACHE / "osmnx" ox.geocode_to_gdf("Greater London, England")[["geometry"]].to_file(gl_file, driver="GPKG")gl = gpd.read_file(gl_file).to_crs(CRS)in_gl = rasterio.features.geometry_mask(gl.geometry, (HEIGHT, WIDTH), TRANSFORM, invert=True)
wc_file = CACHE / "worldcover_2021_30m.npy"if not wc_file.exists(): wc = np.zeros((HEIGHT, WIDTH), dtype="uint8") for wi in catalog.search(collections=["esa-worldcover"], bbox=AOI, query={"esa_worldcover:product_version": {"eq": "2.0.0"}}).items(): with rasterio.open(wi.assets["map"].href) as src, WarpedVRT(src, crs=CRS, transform=TRANSFORM, width=WIDTH, height=HEIGHT, resampling=Resampling.mode) as vrt: tile = vrt.read(1) wc = np.where(tile > 0, tile, wc) np.save(wc_file, wc)wc = np.load(wc_file)WC = {10: "Tree cover", 20: "Shrubland", 30: "Grassland", 40: "Cropland", 50: "Built-up", 60: "Bare / sparse", 80: "Water", 90: "Wetland"}
section("LST by land cover inside Greater London (ESA WorldCover 2021)")rows = []for code, name in WC.items(): m = ok & in_gl & (wc == code) if m.sum() * PX_KM2 < 5: # skip classes covering less than 5 km2 of London continue v = lst[m] rows.append({"land cover": name, "km2": round(m.sum() * PX_KM2, 1), "median_C": np.median(v), "p10_C": np.percentile(v, 10), "p90_C": np.percentile(v, 90), "median_NDVI": np.median(ndvi[m])})by_lc = pd.DataFrame(rows).sort_values("median_C", ascending=False).round(2)print(by_lc.to_string(index=False))by_lc.to_csv(OUT / "lst_by_landcover.csv", index=False)
# --- Step 5: urban against rural: the surface urban heat island intensity ----------------------------------built_1km = ndimage.uniform_filter((wc == 50).astype("float32"), size=33) # built-up share within ~1 kmdist_gl_km = ndimage.distance_transform_edt(~in_gl) * 0.03rural = ok & ~in_gl & (dist_gl_km > 5) & np.isin(wc, [30, 40]) & (built_1km < 0.05)urban = ok & in_gl & (wc == 50)suhi = np.median(lst[urban]) - np.median(lst[rural])section("surface urban heat island")print(f"urban: built-up pixels in Greater London, {urban.sum() * PX_KM2:,.0f} km2, median LST {np.median(lst[urban]):.1f} C")print(f"rural: grass/cropland > 5 km outside London with < 5% built-up within 1 km, {rural.sum() * PX_KM2:,.0f} km2, " f"median LST {np.median(lst[rural]):.1f} C")print(f"surface urban heat island intensity (median urban - median rural): {suhi:.1f} C")inner = ok & in_gl & (np.hypot(*np.meshgrid(np.arange(WIDTH) * 30 + x0 + 15 - to_utm.transform(*CENTRE)[0], y1 - 15 - np.arange(HEIGHT)[:, None] * 30 - to_utm.transform(*CENTRE)[1], sparse=True)) < 5000)print(f"all land within 5 km of Charing Cross: median LST {np.median(lst[inner]):.1f} C")
gl_ok = ok & in_glbins = np.arange(-0.1, 0.95, 0.1)idx = np.digitize(ndvi[gl_ok], bins)ndvi_tab = pd.DataFrame({"ndvi": ndvi[gl_ok], "lst": lst[gl_ok], "bin": idx})ndvi_tab = ndvi_tab[(ndvi_tab["bin"] > 0) & (ndvi_tab["bin"] < len(bins))]summary = ndvi_tab.groupby("bin").agg(ndvi_from=("ndvi", "min"), n=("lst", "size"), median_C=("lst", "median")).round(2)summary["ndvi_from"] = bins[summary.index - 1].round(1)slope, intercept = np.polyfit(ndvi[gl_ok], lst[gl_ok], 1)r = np.corrcoef(ndvi[gl_ok], lst[gl_ok])[0, 1]print("\nLST by NDVI band inside Greater London:")print(summary.to_string())print(f"linear fit: LST = {intercept:.1f} {slope:+.1f} x NDVI (r = {r:.2f}, {slope / 10:+.1f} C per 0.1 NDVI)")
# --- Step 6: a west-east transect through central London ------------------------------------------------------section("west-east transect along 51.507 N")cx, cy = to_utm.transform(*CENTRE)row = int((y1 - cy) // 30)tx = x0 + 15 + np.arange(WIDTH) * 30t_lst = pd.Series(np.where(ok[row], lst[row], np.nan))t_ndvi = pd.Series(np.where(ok[row], ndvi[row], np.nan))t_lst_s = t_lst.rolling(31, center=True, min_periods=10).median() # ~1 km running mediant_ndvi_s = t_ndvi.rolling(31, center=True, min_periods=10).median()km = (tx - cx) / 1000inside_row = in_gl[row]edges_gl = km[np.flatnonzero(np.diff(inside_row.astype(int)))]print(f"transect length {km[-1] - km[0]:.0f} km; Greater London boundary crossed at {', '.join(f'{e:+.1f}' for e in edges_gl)} km")for lo, hi, name in ((-60, -40, "rural west (Berkshire)"), (-5, 5, "central London"), (40, 60, "rural east (Essex)")): sel = (km >= lo) & (km < hi) print(f"{name:<24} {lo:+} to {hi:+} km: median LST {np.nanmedian(t_lst[sel]):.1f} C, NDVI {np.nanmedian(t_ndvi[sel]):.2f}")
# --- Step 7: hotspots, named with OpenStreetMap ---------------------------------------------------------------------section("hotspots (top 2% of Greater London land pixels)")thr = np.percentile(lst[gl_ok], 98)hot = gl_ok & (lst >= thr)hot = ndimage.binary_closing(hot, iterations=1) & gl_oklab, n = ndimage.label(hot)sizes = ndimage.sum(hot, lab, index=np.arange(1, n + 1))print(f"threshold {thr:.1f} C; {n:,} patches, {np.sum(sizes * PX_KM2 * 100 >= 5)} of at least 5 ha")order = np.argsort(sizes)[::-1][:10] + 1to_ll = Transformer.from_crs(CRS, "EPSG:4326", always_xy=True)geo_cache = CACHE / "nominatim.json"names = json.loads(geo_cache.read_text()) if geo_cache.exists() else {}hs = []for k, lid in enumerate(order, 1): m = lab == lid rr, cc = np.nonzero(m) j = np.argmax(lst[rr, cc]) hx, hy = x0 + (cc[j] + 0.5) * 30, y1 - (rr[j] + 0.5) * 30 lon, lat = to_ll.transform(hx, hy) key = f"{lat:.5f},{lon:.5f}" if key not in names: resp = requests.get("https://nominatim.openstreetmap.org/reverse", headers=HEADERS, timeout=30, params={"lat": lat, "lon": lon, "format": "jsonv2", "zoom": 17}) names[key] = resp.json() time.sleep(1.1) # Nominatim usage policy: at most one request a second a = names[key].get("address", {}) place = next((a[f] for f in ("suburb", "town", "village", "neighbourhood") if f in a), "") feature = next((a[f] for f in ("industrial", "retail", "commercial", "leisure", "park", "amenity") if f in a), "") hs.append({"rank": k, "area_ha": round(m.sum() * PX_KM2 * 100, 1), "max_C": round(float(lst[m].max()), 1), "mean_C": round(float(lst[m].mean()), 1), "place": place, "borough": a.get("city_district", "").replace("London Borough of ", ""), "near": feature or a.get("road", ""), "built_%": round(100 * float((wc[m] == 50).mean())), "grass_%": round(100 * float(np.isin(wc[m], [30, 40]).mean())), "tree_%": round(100 * float((wc[m] == 10).mean())), "NDVI": round(float(np.median(ndvi[m])), 2), "x": hx, "y": hy, "lat": round(lat, 5), "lon": round(lon, 5)})geo_cache.write_text(json.dumps(names))hs = pd.DataFrame(hs)print(hs.drop(columns=["x", "y", "lat", "lon"]).to_string(index=False))hs.drop(columns=["x", "y"]).to_csv(OUT / "hotspots.csv", index=False)all_hot = hot & (np.isin(lab, order))print(f"ten largest hotspots together: {all_hot.sum() * PX_KM2 * 100:,.0f} ha, " f"{100 * (wc[all_hot] == 50).mean():.0f}% built-up, {100 * np.isin(wc[all_hot], [30, 40]).mean():.0f}% grass or crops, " f"{100 * (wc[all_hot] == 10).mean():.0f}% trees (WorldCover)")
# OpenStreetMap context for a zoomed map around the largest hotspot.zx, zy = hs.loc[0, "x"], hs.loc[0, "y"]ZOOM = (zx - 4000, zy - 3000, zx + 4000, zy + 3000)zoom_ll = to_ll.transform([ZOOM[0], ZOOM[2]], [ZOOM[1], ZOOM[3]])osm_file = DATA / "osm_zoom.gpkg"if not osm_file.exists(): ox.settings.cache_folder = CACHE / "osmnx" tags = {"landuse": ["residential", "industrial", "commercial", "retail", "railway"], "leisure": ["park", "golf_course"], "highway": ["motorway", "trunk", "primary"]} osm = None for attempt in range(4): # public Overpass servers are often busy: retry with a mirror for url in ("https://overpass-api.de/api", "https://overpass.kumi.systems/api"): ox.settings.overpass_url = url try: osm = ox.features_from_bbox((zoom_ll[0][0], zoom_ll[1][0], zoom_ll[0][1], zoom_ll[1][1]), tags) break except Exception as exc: print(f"{url} failed: {str(exc).splitlines()[0][:70]}") if osm is not None: break time.sleep(30 * (attempt + 1)) keep = [c for c in ("landuse", "leisure", "highway", "name") if c in osm.columns] osm = osm.reset_index()[keep + ["geometry"]] osm.to_file(osm_file, driver="GPKG")osm = gpd.read_file(osm_file).to_crs(CRS)zr = (slice(int((y1 - ZOOM[3]) // 30), int((y1 - ZOOM[1]) // 30)), slice(int((ZOOM[0] - x0) // 30), int((ZOOM[2] - x0) // 30)))zone_ok = ok[zr]zone_hot = hot[zr]print(f"\nOpenStreetMap features in the 8 x 6 km box around hotspot 1: {len(osm):,}")print(f"{'OSM tag':<22} {'km2':>5} {'median LST':>11} {'share of hotspot pixels':>24}")for col, vals in (("landuse", ["residential", "industrial", "commercial", "retail"]), ("leisure", ["park", "golf_course"])): if col not in osm.columns: continue for v in vals: polys = osm[(osm[col] == v) & osm.geom_type.isin(["Polygon", "MultiPolygon"])] if len(polys): mm = rasterio.features.geometry_mask(polys.geometry, (HEIGHT, WIDTH), TRANSFORM, invert=True)[zr] & zone_ok if mm.sum() > 50: print(f"{col + '=' + v:<22} {mm.sum() * PX_KM2:5.2f} {np.median(lst[zr][mm]):9.1f} C " f"{100 * (mm & zone_hot).sum() / zone_hot.sum():23.0f}%")
# --- Step 8: land surface temperature is not air temperature ------------------------------------------------------section("LST against air temperature at weather stations (Meteostat hourly, 11:00 UTC)")STATIONS = {"03772": ("Heathrow", 51.4833, -0.45), "03672": ("Northolt", 51.55, -0.4167), "03781": ("Kenley", 51.30, -0.0833), "EGKB0": ("Biggin Hill", 51.3167, 0.0333)}met = []for sid, (name, lat, lon) in STATIONS.items(): f = CACHE / f"meteostat_{sid}_2022.csv.gz" if not f.exists(): f.write_bytes(requests.get(f"https://bulk.meteostat.net/v2/hourly/2022/{sid}.csv.gz", headers=HEADERS, timeout=60).content) obs = pd.read_csv(f, header=None, usecols=[0, 1, 2], names=["date", "hour", "temp"]) t = obs[(obs["date"] == "2022-07-18") & (obs["hour"].isin([10, 11]))]["temp"] sx, sy = to_utm.transform(lon, lat) rr, cc = int((y1 - sy) // 30), int((sx - x0) // 30) win = (slice(rr - 16, rr + 17), slice(cc - 16, cc + 17)) # ~1 km box: station coordinates are rounded v = lst[win][ok[win]] grass = lst[win][ok[win] & np.isin(wc[win], [30, 40])] met.append({"station": name, "air_10UTC_C": t.iloc[0], "air_11UTC_C": t.iloc[1], "LST_median_1km_C": round(float(np.median(v)), 1), "LST_grass_median_C": round(float(np.median(grass)), 1) if grass.size > 20 else np.nan})met = pd.DataFrame(met)met["LST_minus_air"] = (met["LST_median_1km_C"] - met["air_11UTC_C"]).round(1)print(met.to_string(index=False))
# --- Figures --------------------------------------------------------------------------------------------------------LST_CMAP = plt.get_cmap("inferno").copy()LST_CMAP.set_bad("#bfc7cc")lst_show = np.where(ok, lst, np.nan)vmin, vmax = np.nanpercentile(lst_show, [1, 99.5])halo = [pe.withStroke(linewidth=2.5, foreground="white")]
def frame(ax, xlim=(x0, x1), ylim=(y0, y1), bar_km=10): ax.set_xticks([]), ax.set_yticks([]) ax.set_xlim(*xlim), ax.set_ylim(*ylim) xr, yb = xlim[1] - (xlim[1] - xlim[0]) * 0.04, ylim[0] + (ylim[1] - ylim[0]) * 0.05 ax.plot([xr - bar_km * 1000, xr], [yb, yb], color="black", lw=3, solid_capstyle="butt") ax.text(xr - bar_km * 500, yb + (ylim[1] - ylim[0]) * 0.015, f"{bar_km} km", ha="center", va="bottom", fontsize=8, path_effects=halo)
true = np.dstack([sr("red"), sr("green"), sr("blue")])true = np.clip(np.nan_to_num(true) * 3.2, 0, 1) ** (1 / 1.3)fig, axes = plt.subplots(1, 2, figsize=(13, 4.6), constrained_layout=True)axes[0].imshow(true, extent=EXTENT)gl.boundary.plot(ax=axes[0], color="white", lw=0.8)frame(axes[0])axes[0].set_title(f"Landsat 9 true colour, {item.datetime:%d %B %Y %H:%M} UTC", fontsize=9)im = axes[1].imshow(lst_show, cmap=LST_CMAP, vmin=vmin, vmax=vmax, extent=EXTENT, interpolation="nearest")gl.boundary.plot(ax=axes[1], color="#2a9df4", lw=0.8)frame(axes[1])axes[1].set_title("Land surface temperature (ST_B10), grey = water or masked", fontsize=9)fig.colorbar(im, ax=axes[1], shrink=0.85, label="LST (°C)")fig.savefig(OUT / "01-true-colour-lst.png", dpi=110)plt.close(fig)
fig, axes = plt.subplots(1, 2, figsize=(13, 4.4), constrained_layout=True, gridspec_kw={"width_ratios": [1.1, 1]})order_lc = by_lc["land cover"].tolist()code_of = {v: k for k, v in WC.items()}data = [lst[ok & in_gl & (wc == code_of[n])] for n in order_lc]lc_cols = {"Built-up": "#c92a2a", "Bare / sparse": "#a0785a", "Cropland": "#e8b923", "Grassland": "#94c973", "Shrubland": "#6c9a3d", "Tree cover": "#2b7a3b", "Wetland": "#4aa3a2", "Water": "#1c7ed6"}bp = axes[0].boxplot(data, vert=False, showfliers=False, patch_artist=True, widths=0.6, tick_labels=[f"{n} ({by_lc.loc[by_lc['land cover'] == n, 'km2'].iloc[0]:,.0f} km²)" for n in order_lc])for patch, n in zip(bp["boxes"], order_lc): patch.set_facecolor(lc_cols.get(n, "grey")) patch.set_alpha(0.85)for med in bp["medians"]: med.set_color("black")axes[0].invert_yaxis()axes[0].set_xlabel("LST (°C), Greater London, 18 July 2022")axes[0].set_title("LST by ESA WorldCover class (box = 25th–75th percentile)", fontsize=9)axes[0].spines[["top", "right"]].set_visible(False)hb = axes[1].hexbin(ndvi[gl_ok], lst[gl_ok], gridsize=60, cmap="Blues", mincnt=5, bins="log", extent=(-0.2, 0.9, 20, 55))xx = np.linspace(-0.1, 0.85, 10)axes[1].plot(xx, intercept + slope * xx, color="#c92a2a", lw=1.5, label=f"fit: {slope / 10:+.1f} °C per 0.1 NDVI (r = {r:.2f})")axes[1].set_xlabel("NDVI")axes[1].set_ylabel("LST (°C)")axes[1].set_title("Greener pixels are cooler (Greater London)", fontsize=9)axes[1].legend(loc="upper right", fontsize=8)fig.colorbar(hb, ax=axes[1], shrink=0.85, label="pixels (log scale)")fig.savefig(OUT / "02-lst-landcover-ndvi.png")plt.close(fig)
fig, ax = plt.subplots(figsize=(11, 4.4), constrained_layout=True)ax.plot(km, t_lst, color="#f59f00", lw=0.4, alpha=0.6, label="LST, 30 m pixels")ax.plot(km, t_lst_s, color="#c92a2a", lw=1.8, label="LST, 1 km running median")for e in edges_gl: ax.axvline(e, color="grey", lw=0.8, ls="--")if len(edges_gl): ax.axvspan(edges_gl[0], edges_gl[-1], color="grey", alpha=0.08, lw=0) ax.text((edges_gl[0] + edges_gl[-1]) / 2, 0.97, "Greater London", transform=ax.get_xaxis_transform(), ha="center", va="top", fontsize=8)ax.text(0, 0.03, "Charing Cross", transform=ax.get_xaxis_transform(), ha="center", fontsize=8)ax.set_xlabel("km east of Charing Cross along latitude 51.507° N")ax.set_ylabel("LST (°C)")ax2 = ax.twinx()ax2.plot(km, t_ndvi_s, color="#2b8a3e", lw=1.3, label="NDVI, 1 km running median")ax2.set_ylabel("NDVI", color="#2b8a3e")ax2.set_ylim(-0.2, 1.4)h1, l1 = ax.get_legend_handles_labels()h2, l2 = ax2.get_legend_handles_labels()ax.legend(h1 + h2, l1 + l2, loc="upper left", fontsize=8, framealpha=0.9)ax.spines["top"].set_visible(False)ax2.spines["top"].set_visible(False)fig.savefig(OUT / "03-urban-rural-transect.png")plt.close(fig)
fig, axes = plt.subplots(1, 2, figsize=(13, 5.0), constrained_layout=True, gridspec_kw={"width_ratios": [1.25, 1]})gx0, gy0, gx1, gy1 = gl.total_boundsaxes[0].imshow(lst_show, cmap=LST_CMAP, vmin=vmin, vmax=vmax, extent=EXTENT, interpolation="nearest")axes[0].imshow(np.ma.masked_where(~hot, hot), cmap="cool", extent=EXTENT, interpolation="nearest", alpha=1)gl.boundary.plot(ax=axes[0], color="#2a9df4", lw=0.8)for _, h in hs.iterrows(): axes[0].annotate(str(h["rank"]), (h["x"], h["y"]), xytext=(5, 5), textcoords="offset points", fontsize=8, fontweight="bold", path_effects=halo)axes[0].add_patch(plt.Rectangle((ZOOM[0], ZOOM[1]), ZOOM[2] - ZOOM[0], ZOOM[3] - ZOOM[1], fill=False, ec="#2a9df4", lw=1.2))frame(axes[0], (gx0 - 2000, gx1 + 2000), (gy0 - 2000, gy1 + 2000))axes[0].set_title(f"Hotspots: top 2% of Greater London land pixels (≥ {thr:.1f} °C, cyan), ten largest numbered", fontsize=9)ax = axes[1]ax.imshow(lst_show, cmap=LST_CMAP, vmin=vmin, vmax=vmax, extent=EXTENT, interpolation="nearest", alpha=0.9)polys = osm[osm.geom_type.isin(["Polygon", "MultiPolygon"])]if "landuse" in polys.columns: polys[polys["landuse"].isin(["industrial", "commercial", "retail"])].boundary.plot(ax=ax, color="white", lw=0.8)if "leisure" in polys.columns: polys[polys["leisure"].isin(["park", "golf_course"])].boundary.plot(ax=ax, color="#69db7c", lw=1.0)if "highway" in osm.columns: osm[osm["highway"].isin(["motorway", "trunk", "primary"]) & osm.geom_type.isin(["LineString", "MultiLineString"]) ].plot(ax=ax, color="#4dabf7", lw=0.9)ax.contour(hot, levels=[0.5], extent=(x0, x1, y1, y0), colors="cyan", linewidths=0.9)frame(ax, (ZOOM[0], ZOOM[2]), (ZOOM[1], ZOOM[3]), bar_km=1)ax.legend(handles=[Line2D([], [], color="cyan", label=f"hotspot (≥ {thr:.1f} °C)"), Line2D([], [], color="white", label="OSM industrial / retail"), Line2D([], [], color="#69db7c", label="OSM park / golf"), Line2D([], [], color="#4dabf7", label="OSM main road")], loc="lower left", fontsize=7, framealpha=0.85, facecolor="#555555", labelcolor="white")ax.set_title(f"Zoom on hotspot 1: {hs.loc[0, 'place']} ({hs.loc[0, 'borough']}), 8 × 6 km", fontsize=9)fig.savefig(OUT / "04-hotspots-osm.png")plt.close(fig)
fig, ax = plt.subplots(figsize=(8, 4.0), constrained_layout=True)yy = np.arange(len(met))ax.barh(yy + 0.2, met["air_11UTC_C"], height=0.38, color="#1c7ed6", label="air temperature, 11:00 UTC (station)")ax.barh(yy - 0.2, met["LST_median_1km_C"], height=0.38, color="#e8590c", label="LST, median within ~1 km (Landsat 9, 10:52 UTC)")for i, rrow in met.iterrows(): ax.text(rrow["LST_median_1km_C"] + 0.4, i - 0.2, f"{rrow['LST_median_1km_C']:.1f}", va="center", fontsize=8) ax.text(rrow["air_11UTC_C"] + 0.4, i + 0.2, f"{rrow['air_11UTC_C']:.1f}", va="center", fontsize=8)ax.set_yticks(yy, met["station"])ax.invert_yaxis()ax.set_xlabel("°C on 18 July 2022")ax.set_xlim(0, max(met["LST_median_1km_C"].max(), met["air_11UTC_C"].max()) * 1.18)ax.legend(loc="upper center", bbox_to_anchor=(0.5, -0.14), ncol=2, fontsize=8, frameon=False)ax.spines[["top", "right"]].set_visible(False)fig.savefig(OUT / "05-lst-vs-air-temperature.png")plt.close(fig)
section("outputs")for f in sorted(OUT.iterdir()): if f.name != "console.txt": print(f"{f.name:<32} {f.stat().st_size / 1024:8.0f} KB")