Repository navigation
Add Urban Heat Island mapping notebook with MODIS LST #1343
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
giswqs
merged 6 commits into
opengeos:master
from
Qandeel-01:add-urban-heat-island-notebook
Aug 2, 2026
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
1dd84e8
Add Urban Heat Island mapping notebook with MODIS LST
Qandeel-01 f6bf5a9
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] d1dcf32
Merge branch 'master' into add-urban-heat-island-notebook
giswqs f76c42c
Fix API bugs identified in code review
Qandeel-01 0b039e6
Merge branch 'master' into add-urban-heat-island-notebook
giswqs c9db89e
Address review feedback and add notebook to docs
giswqs File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,363 @@ | ||
| { | ||
| "cells": [ | ||
| { | ||
| "cell_type": "markdown", | ||
| "metadata": {}, | ||
| "source": [ | ||
| "# Urban Heat Island Mapping with MODIS Land Surface Temperature\n", | ||
| "\n", | ||
| "[](https://colab.research.google.com/github/opengeos/leafmap/blob/master/docs/notebooks/117_urban_heat_island.ipynb)\n", | ||
| "\n", | ||
| "This notebook demonstrates how to visualize and analyze **Urban Heat Island (UHI)** effects using MODIS MOD11A2 Land Surface Temperature (LST) data and [leafmap](https://leafmap.org).\n", | ||
| "\n", | ||
| "The Urban Heat Island effect describes how urban areas exhibit significantly higher temperatures than surrounding rural land due to human activity, impervious surfaces, and reduced vegetation. LST derived from thermal infrared satellite sensors is one of the most widely used proxies for quantifying UHI intensity.\n", | ||
| "\n", | ||
| "**Outline:**\n", | ||
| "- Install and import dependencies\n", | ||
| "- Download MODIS MOD11A2 LST data via `earthaccess`\n", | ||
| "- Load and preprocess the LST GeoTIFF\n", | ||
| "- Clip to the study area, apply the scale factor, and mask fill/low-quality pixels\n", | ||
| "- Visualize LST on an interactive leafmap\n", | ||
| "- Compute a split map comparing urban vs. rural thermal patterns\n", | ||
| "- Export results" | ||
| ] | ||
| }, | ||
| { | ||
| "cell_type": "markdown", | ||
| "metadata": {}, | ||
| "source": [ | ||
| "## Install dependencies\n", | ||
| "\n", | ||
| "Uncomment if running in Google Colab or a fresh environment." | ||
| ] | ||
| }, | ||
| { | ||
| "cell_type": "code", | ||
| "execution_count": null, | ||
| "metadata": {}, | ||
| "outputs": [], | ||
| "source": [ | ||
| "# %pip install leafmap earthaccess rioxarray matplotlib" | ||
| ] | ||
| }, | ||
| { | ||
| "cell_type": "markdown", | ||
| "metadata": {}, | ||
| "source": [ | ||
| "## Import libraries" | ||
| ] | ||
| }, | ||
| { | ||
| "cell_type": "code", | ||
| "execution_count": null, | ||
| "metadata": {}, | ||
| "outputs": [], | ||
| "source": [ | ||
| "import matplotlib.pyplot as plt\n", | ||
| "import rioxarray as rxr\n", | ||
| "import earthaccess\n", | ||
| "import leafmap" | ||
| ] | ||
| }, | ||
| { | ||
| "cell_type": "markdown", | ||
| "metadata": {}, | ||
| "source": [ | ||
| "## Authenticate with NASA Earthdata\n", | ||
| "\n", | ||
| "A free NASA Earthdata account is required. Register at [urs.earthdata.nasa.gov](https://urs.earthdata.nasa.gov). Credentials are stored in `~/.netrc` after first login." | ||
| ] | ||
| }, | ||
| { | ||
| "cell_type": "code", | ||
| "execution_count": null, | ||
| "metadata": {}, | ||
| "outputs": [], | ||
| "source": [ | ||
| "earthaccess.login(persist=True)" | ||
| ] | ||
| }, | ||
| { | ||
| "cell_type": "markdown", | ||
| "metadata": {}, | ||
| "source": [ | ||
| "## Search for MODIS MOD11A2 LST data\n", | ||
| "\n", | ||
| "MOD11A2 provides 8-day composite Land Surface Temperature at 1 km resolution. We search for a summer granule over Lahore, Pakistan — one of South Asia's most prominent Urban Heat Island cities." | ||
| ] | ||
| }, | ||
| { | ||
| "cell_type": "code", | ||
| "execution_count": null, | ||
| "metadata": {}, | ||
| "outputs": [], | ||
| "source": "# Lahore, Pakistan bounding box [west, south, east, north]\nbbox = (73.8, 31.3, 74.6, 31.7)\n\nresults = earthaccess.search_data(\n short_name=\"MOD11A2\",\n version=\"061\",\n bounding_box=bbox,\n temporal=(\"2023-06-01\", \"2023-06-30\"),\n)\n\nprint(f\"Found {len(results)} granule(s)\")\nif not results:\n raise ValueError(\n \"No granules found — check your Earthdata credentials and the search parameters.\"\n )\nresults[0]" | ||
| }, | ||
| { | ||
| "cell_type": "markdown", | ||
| "metadata": {}, | ||
| "source": [ | ||
| "## Download the LST granule" | ||
| ] | ||
| }, | ||
| { | ||
| "cell_type": "code", | ||
| "execution_count": null, | ||
| "metadata": {}, | ||
| "outputs": [], | ||
| "source": [ | ||
| "files = earthaccess.download(results[:1], local_path=\"modis_lst\")\n", | ||
| "# earthaccess may return sidecar files (e.g. .hdf.xml) alongside the data file,\n", | ||
| "# and the ordering is not guaranteed, so select the HDF explicitly.\n", | ||
| "hdf_path = next((f for f in files if str(f).endswith(\".hdf\")), None)\n", | ||
| "if hdf_path is None:\n", | ||
| " raise ValueError(\"Download failed — no HDF file returned by earthaccess.\")\n", | ||
| "print(f\"Downloaded: {hdf_path}\")" | ||
| ] | ||
| }, | ||
| { | ||
| "cell_type": "markdown", | ||
| "metadata": {}, | ||
| "source": [ | ||
| "## Load and preprocess LST\n", | ||
| "\n", | ||
| "A MOD11A2 granule covers an entire MODIS tile (~1200 km across), so we first clip it to the Lahore bounding box — otherwise the scene statistics below would describe the whole tile rather than the city.\n", | ||
| "\n", | ||
| "MOD11A2 stores LST as scaled integers. The LST Day layer (`LST_Day_1km`) must be multiplied by **0.02** to convert to Kelvin, then subtract 273.15 for Celsius. Fill value **0** is masked out, and the `QC_Day` layer is used to drop cloud-contaminated and other low-quality retrievals." | ||
| ] | ||
| }, | ||
| { | ||
| "cell_type": "code", | ||
| "execution_count": null, | ||
| "metadata": {}, | ||
| "outputs": [], | ||
| "source": [ | ||
| "grid = f'HDF4_EOS:EOS_GRID:\"{hdf_path}\":MODIS_Grid_8Day_1km_LST:'\n", | ||
| "\n", | ||
| "# Open the daytime LST subdataset and its quality layer\n", | ||
| "lst_raw = rxr.open_rasterio(grid + \"LST_Day_1km\", masked=True).squeeze()\n", | ||
| "qc_day = rxr.open_rasterio(grid + \"QC_Day\").squeeze()\n", | ||
| "\n", | ||
| "# Clip the granule to the Lahore bounding box before computing any statistics\n", | ||
| "lst_raw = lst_raw.rio.clip_box(*bbox, crs=\"EPSG:4326\")\n", | ||
| "qc_day = qc_day.rio.clip_box(*bbox, crs=\"EPSG:4326\")\n", | ||
| "\n", | ||
| "# QC_Day bits 0-1 are the mandatory QA flags; 00 means good-quality LST was\n", | ||
| "# produced. Anything else is cloud-contaminated or otherwise unreliable.\n", | ||
| "good_quality = (qc_day & 0b11) == 0\n", | ||
| "\n", | ||
| "# Apply scale factor and convert to Celsius\n", | ||
| "lst_kelvin = lst_raw.where((lst_raw != 0) & good_quality) * 0.02\n", | ||
| "lst_celsius = lst_kelvin - 273.15\n", | ||
| "\n", | ||
| "print(\n", | ||
| " f\"LST range: {float(lst_celsius.min()):.1f} °C to {float(lst_celsius.max()):.1f} °C\"\n", | ||
| ")\n", | ||
| "lst_celsius" | ||
| ] | ||
| }, | ||
| { | ||
| "cell_type": "markdown", | ||
| "metadata": {}, | ||
| "source": [ | ||
| "## Quick static plot\n", | ||
| "\n", | ||
| "A preliminary view before loading into leafmap." | ||
| ] | ||
| }, | ||
| { | ||
| "cell_type": "code", | ||
| "execution_count": null, | ||
| "metadata": {}, | ||
| "outputs": [], | ||
| "source": [ | ||
| "fig, ax = plt.subplots(figsize=(10, 7))\n", | ||
| "lst_celsius.plot(\n", | ||
| " ax=ax,\n", | ||
| " cmap=\"RdYlBu_r\",\n", | ||
| " robust=True,\n", | ||
| " cbar_kwargs={\"label\": \"Land Surface Temperature (°C)\"},\n", | ||
| ")\n", | ||
| "ax.set_title(\"MODIS MOD11A2 — Daytime LST, Lahore, June 2023\", fontsize=13)\n", | ||
| "ax.set_xlabel(\"Longitude\")\n", | ||
| "ax.set_ylabel(\"Latitude\")\n", | ||
| "plt.tight_layout()\n", | ||
| "plt.savefig(\"lahore_lst.png\", dpi=150)\n", | ||
| "plt.show()" | ||
| ] | ||
| }, | ||
| { | ||
| "cell_type": "markdown", | ||
| "metadata": {}, | ||
| "source": [ | ||
| "## Export preprocessed LST as GeoTIFF" | ||
| ] | ||
| }, | ||
| { | ||
| "cell_type": "code", | ||
| "execution_count": null, | ||
| "metadata": {}, | ||
| "outputs": [], | ||
| "source": [ | ||
| "lst_output = \"lahore_lst_celsius.tif\"\n", | ||
| "lst_celsius.rio.to_raster(lst_output)\n", | ||
| "print(f\"Saved: {lst_output}\")" | ||
| ] | ||
| }, | ||
| { | ||
| "cell_type": "markdown", | ||
| "metadata": {}, | ||
| "source": [ | ||
| "## Visualize on an interactive leafmap\n", | ||
| "\n", | ||
| "Load the LST raster with a diverging colormap. Warmer colours (red/orange) indicate urban heat pockets; cooler blues indicate vegetated or rural land." | ||
| ] | ||
| }, | ||
| { | ||
| "cell_type": "code", | ||
| "execution_count": null, | ||
| "metadata": {}, | ||
| "outputs": [], | ||
| "source": [ | ||
| "m = leafmap.Map(center=[31.5, 74.2], zoom=10)\n", | ||
| "m.add_basemap(\"OpenStreetMap\")\n", | ||
| "\n", | ||
| "m.add_raster(\n", | ||
| " lst_output,\n", | ||
| " colormap=\"RdYlBu_r\",\n", | ||
| " vmin=20,\n", | ||
| " vmax=50,\n", | ||
| " layer_name=\"LST Day (°C)\",\n", | ||
| " opacity=0.75,\n", | ||
| ")\n", | ||
| "\n", | ||
| "# RdYlBu_r: blue (cool) → yellow → red (hot)\n", | ||
| "lst_colors = [\"313695\", \"74ADD1\", \"E0F3F8\", \"FEE090\", \"F46D43\", \"D73027\"]\n", | ||
| "m.add_colorbar(colors=lst_colors, vmin=20, vmax=50)\n", | ||
| "\n", | ||
| "m" | ||
| ] | ||
| }, | ||
| { | ||
| "cell_type": "markdown", | ||
| "metadata": {}, | ||
| "source": [ | ||
| "## Split map — LST vs. satellite\n", | ||
| "\n", | ||
| "Swipe between the LST raster and the satellite basemap to correlate high-temperature zones with built-up surfaces." | ||
| ] | ||
| }, | ||
| { | ||
| "cell_type": "code", | ||
| "execution_count": null, | ||
| "metadata": {}, | ||
| "outputs": [], | ||
| "source": [ | ||
| "m2 = leafmap.Map(center=[31.5, 74.2], zoom=10)\n", | ||
| "m2.split_map(\n", | ||
| " left_layer=lst_output,\n", | ||
| " right_layer=\"SATELLITE\",\n", | ||
| " left_args={\"colormap\": \"RdYlBu_r\", \"vmin\": 20, \"vmax\": 50, \"opacity\": 0.9},\n", | ||
| " left_label=\"LST Day (°C)\",\n", | ||
| " right_label=\"Satellite\",\n", | ||
| ")\n", | ||
| "m2" | ||
| ] | ||
| }, | ||
| { | ||
| "cell_type": "markdown", | ||
| "metadata": {}, | ||
| "source": [ | ||
| "## Compute UHI intensity\n", | ||
| "\n", | ||
| "A simple UHI index: difference between each pixel and the mean LST of the study area. Positive values = warmer than average (urban heat zones)." | ||
| ] | ||
| }, | ||
| { | ||
| "cell_type": "code", | ||
| "execution_count": null, | ||
| "metadata": {}, | ||
| "outputs": [], | ||
| "source": [ | ||
| "scene_mean = float(lst_celsius.mean())\n", | ||
| "uhi_index = lst_celsius - scene_mean\n", | ||
| "\n", | ||
| "uhi_output = \"lahore_uhi_index.tif\"\n", | ||
| "uhi_index.rio.to_raster(uhi_output)\n", | ||
| "\n", | ||
| "print(f\"Scene mean LST: {scene_mean:.2f} °C\")\n", | ||
| "print(f\"Max UHI intensity: +{float(uhi_index.max()):.2f} °C\")\n", | ||
| "print(f\"Min UHI intensity: {float(uhi_index.min()):.2f} °C\")" | ||
| ] | ||
| }, | ||
| { | ||
| "cell_type": "markdown", | ||
| "metadata": {}, | ||
| "source": [ | ||
| "## Visualize UHI index" | ||
| ] | ||
| }, | ||
| { | ||
| "cell_type": "code", | ||
| "execution_count": null, | ||
| "metadata": {}, | ||
| "outputs": [], | ||
| "source": [ | ||
| "m3 = leafmap.Map(center=[31.5, 74.2], zoom=10)\n", | ||
| "m3.add_basemap(\"SATELLITE\")\n", | ||
| "\n", | ||
| "m3.add_raster(\n", | ||
| " uhi_output,\n", | ||
| " colormap=\"seismic\",\n", | ||
| " vmin=-5,\n", | ||
| " vmax=5,\n", | ||
| " layer_name=\"UHI Index (°C above mean)\",\n", | ||
| " opacity=0.75,\n", | ||
| ")\n", | ||
| "\n", | ||
| "# seismic: blue (below mean) → white → red (above mean / urban heat)\n", | ||
| "uhi_colors = [\"3333CC\", \"AAAAFF\", \"FFFFFF\", \"FFAAAA\", \"CC3333\"]\n", | ||
| "m3.add_colorbar(colors=uhi_colors, vmin=-5, vmax=5)\n", | ||
| "\n", | ||
| "m3" | ||
| ] | ||
| }, | ||
| { | ||
| "cell_type": "markdown", | ||
| "metadata": {}, | ||
| "source": [ | ||
| "## Summary\n", | ||
| "\n", | ||
| "This notebook demonstrated how to:\n", | ||
| "\n", | ||
| "1. Search and download MODIS MOD11A2 LST data using `earthaccess`\n", | ||
| "2. Apply scale factors and unit conversions with `rioxarray`\n", | ||
| "3. Visualize LST interactively with `leafmap` and a diverging colormap\n", | ||
| "4. Build a split map to compare LST against basemap layers\n", | ||
| "5. Compute a simple Urban Heat Island intensity index\n", | ||
| "\n", | ||
| "The workflow is applicable to any city worldwide — change `bbox` and `temporal` to explore different regions and seasons.\n", | ||
| "\n", | ||
| "## References\n", | ||
| "\n", | ||
| "- [MODIS MOD11A2 Product Page](https://lpdaac.usgs.gov/products/mod11a2v061/) — LP DAAC\n", | ||
| "- [earthaccess](https://earthaccess.readthedocs.io/) — NASA Earthdata Python library\n", | ||
| "- [leafmap](https://leafmap.org/) — Wu, 2021\n", | ||
| "- [rioxarray](https://corteva.github.io/rioxarray/)" | ||
| ] | ||
| } | ||
| ], | ||
| "metadata": { | ||
| "kernelspec": { | ||
| "display_name": "Python 3", | ||
| "language": "python", | ||
| "name": "python3" | ||
| }, | ||
| "language_info": { | ||
| "name": "python", | ||
| "version": "3.10.0" | ||
| } | ||
| }, | ||
| "nbformat": 4, | ||
| "nbformat_minor": 4 | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: opengeos/leafmap
Length of output: 18671
🌐 Web query:
leafmap raster extra localtileserver rioxarray GDAL HDF4 driver pip wheels💡 Result:
Leafmap, localtileserver, and rioxarray are common tools in the Python geospatial stack. Their relationship and requirements regarding raster data and GDAL drivers are as follows: Leafmap and Optional Dependencies: Leafmap provides extra install targets to manage its dependencies based on use case [1][2]. For raster data support, you can use: pip install "leafmap[raster]" This installs essential raster-handling libraries, including localtileserver, rioxarray, and relevant NetCDF/HDF5 support packages (e.g., netcdf4, h5netcdf, h5py) [2][3]. localtileserver and rioxarray: localtileserver uses rioxarray to enable visualization of multi-dimensional datasets like NetCDF, Zarr, and HDF5 [4][5]. You can explicitly install these dependencies for localtileserver via: pip install localtileserver[xarray] This ensures that rioxarray and xarray are available, allowing the server to handle data formats that rioxarray supports [4][5]. GDAL HDF4 Driver and Pip Wheels: The HDF4 format is a legacy data format [6][7]. The standard GDAL Python bindings available on PyPI (the gdal package) do not ship as pre-compiled "manylinux" or "macosx" wheels that bundle all native libraries; instead, they are source distributions that require a system-level installation of libgdal and its development headers (e.g., libgdal-dev) [8][9][10]. Because of this, whether the HDF4 driver is available depends on how your local system's GDAL library was compiled [6][10]. If you face difficulties with GDAL dependencies or specific driver support (like HDF4) via pip, the recommended approach is to use conda/mamba, which provides pre-built binaries with comprehensive driver support through the conda-forge channel [1][11]: mamba create -n geo leafmap geopandas localtileserver python -c conda-forge Alternatively, some newer community-driven packages (e.g., pyramids-gis) offer self-contained wheels that bundle GDAL/HDF4 native libraries on specific platforms to simplify installation, though these are independent of the standard GDAL pip package [7].
Citations:
Install the raster dependencies before using local rasters.
Uncommenting this command installs bare
leafmap, missinglocaltileserver; later cells callm.add_rasterandm.split_mapwith local GeoTIFFs. Useleafmap[raster], e.g."leafmap[raster]", since that extra includes the required raster backend dependencies. Also ensure GDAL has the HDF4 driver available forrxr.open_rasterio, because standard pip GDAL/Rasterio wheels do not guarantee all native drivers.Proposed setup fix
📝 Committable suggestion
🤖 Prompt for AI Agents