Skip to content

Add Urban Heat Island mapping notebook with MODIS LST#1343

Open
Qandeel-01 wants to merge 3 commits into
opengeos:masterfrom
Qandeel-01:add-urban-heat-island-notebook
Open

Add Urban Heat Island mapping notebook with MODIS LST#1343
Qandeel-01 wants to merge 3 commits into
opengeos:masterfrom
Qandeel-01:add-urban-heat-island-notebook

Conversation

@Qandeel-01

@Qandeel-01 Qandeel-01 commented Jul 16, 2026

Copy link
Copy Markdown

Description

Adds docs/notebooks/117_urban_heat_island.ipynb — an end-to-end notebook for visualizing Urban Heat Island (UHI) effects using MODIS MOD11A2 Land Surface Temperature data and leafmap.

What this notebook covers

  • Searching and downloading MODIS MOD11A2 (8-day LST, 1 km) via earthaccess
  • Applying scale factor (×0.02) and Kelvin→Celsius conversion with rioxarray
  • Rendering LST on an interactive leafmap with a diverging colormap and colorbar
  • Building a split map to compare LST against satellite and street basemaps
  • Computing a simple Urban Heat Island intensity index (pixel LST − scene mean)

Study area

Lahore, Pakistan — a South Asian megacity with well-documented UHI intensity, using a June 2023 summer granule to capture peak thermal contrast between built-up and vegetated land.

Motivation

No UHI or thermal LST example currently exists in the docs (notebooks 00–116). This fills that gap with a workflow directly applicable to climate research, urban planning, and heat risk assessment.

Related notebooks

  • 116_hls_nasa_earthdata.ipynb — NASA Earthdata access (same earthaccess pattern)
  • 05_load_raster.ipynb — raster loading
  • 12_split_map.ipynb — split map widget

Summary by CodeRabbit

  • Documentation
    • Added a complete Jupyter notebook demonstrating urban heat island analysis for Lahore, Pakistan.
    • Shows how to retrieve MODIS land surface temperature data, preprocess it, and convert temperatures to Celsius.
    • Adds interactive maps for exploring temperature patterns and comparing satellite and street-map views.
    • Calculates and exports a simple urban heat island index with visualizations and references.

Demonstrates querying MODIS MOD11A2 LST via earthaccess, applying
scale factors with rioxarray, and visualizing Urban Heat Island
intensity interactively with leafmap split maps and colorbars.
@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Added a Jupyter notebook demonstrating MODIS MOD11A2 LST acquisition, preprocessing, GeoTIFF export, interactive mapping, split-map comparison, and scene-mean UHI index visualization for Lahore.

Changes

Urban Heat Island Workflow

Layer / File(s) Summary
Data acquisition and LST processing
docs/notebooks/117_urban_heat_island.ipynb
Installs dependencies, authenticates with NASA Earthdata, downloads a Lahore MODIS granule, and converts daytime LST values to Celsius.
LST visualization and export
docs/notebooks/117_urban_heat_island.ipynb
Plots and exports the processed LST raster, then displays it with leafmap and a split-map comparison.
UHI index and notebook completion
docs/notebooks/117_urban_heat_island.ipynb
Computes, exports, and visualizes the scene-mean UHI index, followed by workflow conclusions and references.

Estimated code review effort: 4 (Complex) | ~45 minutes

Poem

I’m a rabbit hopping through pixels of heat,
Lahore glows beneath my quick little feet.
MODIS brings warmth, maps bloom bright,
GeoTIFFs capture the summer light.
UHI hops where hot spots meet!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly reflects the main change: a new Urban Heat Island mapping notebook using MODIS LST.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 Ruff (0.15.21)
docs/notebooks/117_urban_heat_island.ipynb

Unexpected end of JSON input


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@mergify

mergify Bot commented Jul 16, 2026

Copy link
Copy Markdown

Tick the box to add this pull request to the merge queue (same as @mergifyio queue).

  • Queue this pull request

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/notebooks/117_urban_heat_island.ipynb`:
- Around line 143-151: Update the notebook’s daytime LST processing around
lst_raw to load the QC_Day subdataset, decode bits 0–1, and mask lst_raw to
retain only good-quality daytime pixels before applying the 0.02 scale factor or
computing lst_celsius, scene means, and UHI. Preserve the existing fill-value
masking while ensuring all downstream calculations use the quality-filtered LST.
- Around line 143-151: Clip lst_raw to bbox immediately after loading and
squeezing it, before applying the scale factor; ensure the resulting
Lahore-specific lst_celsius is used for export and scene_mean aggregation.
- Around line 258-271: Update the split-map setup around m2.split_map to use
lst_output as one pane instead of adding it afterward with m2.add_raster. Keep
the satellite basemap as the opposite pane and preserve the existing LST
colormap, layer name, and opacity settings when configuring the raster side.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: f1308c9a-3d3b-446f-9f68-4f07e1c03676

📥 Commits

Reviewing files that changed from the base of the PR and between 4785fc0 and f6bf5a9.

📒 Files selected for processing (1)
  • docs/notebooks/117_urban_heat_island.ipynb

Comment on lines +143 to +151
"# Open the daytime LST subdataset\n",
"lst_raw = rxr.open_rasterio(\n",
" f'HDF4_EOS:EOS_GRID:\"{hdf_path}\":MODIS_Grid_8Day_1km_LST:LST_Day_1km',\n",
" masked=True,\n",
").squeeze()\n",
"\n",
"# Apply scale factor and convert to Celsius\n",
"lst_kelvin = lst_raw.where(lst_raw != 0) * 0.02\n",
"lst_celsius = lst_kelvin - 273.15\n",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

What are the official MOD11A2 version 061 QC_Day bit definitions and recommended quality mask?

💡 Result:

For the MOD11A2 version 061 product, the QC_Day (Quality Control for daytime Land Surface Temperature and emissivity) science data set is stored as an 8-bit unsigned integer (uint8) [1][2]. The bit definitions are as follows [1][3]: Bits 0-1: Mandatory QA flags 00: LST produced, good quality, not necessary to examine more detailed QA 01: LST produced, other quality, recommend examination of more detailed QA 10: LST not produced due to cloud effects 11: LST not produced primarily due to reasons other than cloud (e.g., ocean, poor input data) Bits 2-3: Data quality flag 00: Good data quality 01: Other quality data 10: TBD 11: TBD Bits 4-5: Emissivity error flag 00: Average emissivity error <= 0.01 01: Average emissivity error <= 0.02 10: Average emissivity error <= 0.04 11: Average emissivity error > 0.04 Bits 6-7: LST error flag 00: Average LST error <= 1K 01: Average LST error <= 2K 10: Average LST error <= 3K 11: Average LST error > 3K Recommended Quality Mask: To ensure high-quality data, the standard practice is to filter pixels using the Mandatory QA flags (bits 0-1). A common recommendation is to select pixels where bits 0-1 are set to 00 (binary), indicating that LST was produced and is of good quality, meaning it is not necessary to examine more detailed QA [1][3]. Users are advised to use the QC_Day SDS in conjunction with the corresponding LST_Day_1km SDS [1]. If the LST_Day_1km pixel has a fill value of 0, the QC_Day mandatory flags will typically be 10 or 11 [1].

Citations:


🏁 Script executed:

python3 - <<'PY'
import json, pathlib

path = pathlib.Path("docs/notebooks/117_urban_heat_island.ipynb")
nb = json.loads(path.read_text())

for i, cell in enumerate(nb.get("cells", [])):
    if cell.get("cell_type") != "code":
        continue
    src = "".join(cell.get("source", []))
    if "LST_Day_1km" in src or "QC_Day" in src or "lst_celsius" in src or "uhi" in src.lower():
        print(f"\n--- cell {i} ---")
        print(src)
PY

Repository: opengeos/leafmap

Length of output: 1750


Mask QC_Day before computing LST and UHI.

where(lst_raw != 0) only removes fill pixels. QC_Day bits 0-1 also flag cloud-contaminated and other low-quality retrievals, and those pixels still feed into lst_celsius, the scene mean, and the UHI index. Load QC_Day and keep only good-quality daytime pixels before scaling or averaging.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/notebooks/117_urban_heat_island.ipynb` around lines 143 - 151, Update
the notebook’s daytime LST processing around lst_raw to load the QC_Day
subdataset, decode bits 0–1, and mask lst_raw to retain only good-quality
daytime pixels before applying the 0.02 scale factor or computing lst_celsius,
scene means, and UHI. Preserve the existing fill-value masking while ensuring
all downstream calculations use the quality-filtered LST.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect established rioxarray clipping patterns in the repository.
rg -n -C3 --type=py --glob='*.ipynb' '\.rio\.clip(_box)?\s*\(' .

Repository: opengeos/leafmap

Length of output: 786


🏁 Script executed:

python3 - <<'PY'
import json, pathlib

path = pathlib.Path('docs/notebooks/117_urban_heat_island.ipynb')
nb = json.loads(path.read_text())

for i, cell in enumerate(nb['cells']):
    if cell.get('cell_type') != 'code':
        continue
    src = ''.join(cell.get('source', []))
    if any(k in src for k in ['bbox', 'scene_mean', 'LST_Day_1km', 'clip', 'export', 'open_rasterio']):
        print(f"\n--- cell {i} ---")
        print(src)
PY

Repository: opengeos/leafmap

Length of output: 1224


🏁 Script executed:

python3 - <<'PY'
import json, pathlib

path = pathlib.Path('docs/notebooks/117_urban_heat_island.ipynb')
nb = json.loads(path.read_text())

targets = ['bbox', 'scene_mean', 'clip', 'export', 'open_rasterio', 'LST_Day_1km', 'QC_Day']
for i, cell in enumerate(nb['cells']):
    if cell.get('cell_type') != 'code':
        continue
    src = ''.join(cell.get('source', []))
    if any(t in src for t in targets):
        print(f"\n--- cell {i} ---")
        print(src)
PY

Repository: opengeos/leafmap

Length of output: 1224


Clip the MODIS granule before computing Lahore statistics
bounding_box only narrows discovery; lst_celsius and scene_mean still run on the full tile, so the exported UHI raster is not Lahore-specific. Clip to bbox before scaling, exporting, and aggregating.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/notebooks/117_urban_heat_island.ipynb` around lines 143 - 151, Clip
lst_raw to bbox immediately after loading and squeezing it, before applying the
scale factor; ensure the resulting Lahore-specific lst_celsius is used for
export and scene_mean aggregation.

Comment on lines +258 to +271
"left_layer = leafmap.basemap_to_tiles(leafmap.basemaps.SATELLITE)\n",
"right_layer = leafmap.basemap_to_tiles(leafmap.basemaps.OpenStreetMap)\n",
"\n",
"m2.split_map(\n",
" left_layer=left_layer,\n",
" right_layer=right_layer,\n",
")\n",
"\n",
"m2.add_raster(\n",
" lst_output,\n",
" colormap=\"RdYlBu_r\",\n",
" layer_name=\"LST Day (°C)\",\n",
" opacity=0.7,\n",
")\n",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect the local split_map contract and raster-layer helpers.
rg -n -C5 --type=py 'def split_map\s*\(|def add_raster\s*\(' leafmap

Repository: opengeos/leafmap

Length of output: 5164


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Notebook context =="
python3 - <<'PY'
import json
from pathlib import Path

nb = json.loads(Path("docs/notebooks/117_urban_heat_island.ipynb").read_text())
for i, cell in enumerate(nb["cells"]):
    if cell.get("cell_type") == "code":
        src = "".join(cell.get("source", []))
        if "split_map(" in src or "add_raster(" in src or "lst_output" in src:
            print(f"\n--- cell {i} ---")
            print(src)
PY

echo "== split_map/add_raster definitions =="
sed -n '1933,1985p' leafmap/leafmap.py
echo
sed -n '2843,2915p' leafmap/leafmap.py
echo
sed -n '3254,3325p' leafmap/foliumap.py
echo
sed -n '602,675p' leafmap/foliumap.py

Repository: opengeos/leafmap

Length of output: 14439


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== split_map raster handling =="
sed -n '1985,2085p' leafmap/leafmap.py

echo
echo "== common tile helpers =="
rg -n -C4 'def (cog_tile|get_local_tile_layer)\(' leafmap/common.py
sed -n '1,220p' leafmap/common.py | sed -n '1,220p'

Repository: opengeos/leafmap

Length of output: 12363


Use the LST raster as one side of the split map.

m2.add_raster(...) overlays LST on top of both panes. If this example is meant to show LST vs. satellite, pass lst_output into split_map for one side and keep the other side as the basemap.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/notebooks/117_urban_heat_island.ipynb` around lines 258 - 271, Update
the split-map setup around m2.split_map to use lst_output as one pane instead of
adding it afterward with m2.add_raster. Keep the satellite basemap as the
opposite pane and preserve the existing LST colormap, layer name, and opacity
settings when configuring the raster side.

@giswqs

giswqs commented Jul 18, 2026

Copy link
Copy Markdown
Member

/claude-review

"source": [
"m2 = leafmap.Map(center=[31.5, 74.2], zoom=10)\n",
"\n",
"left_layer = leafmap.basemap_to_tiles(leafmap.basemaps.SATELLITE)\n",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug (high confidence): leafmap.basemap_to_tiles does not exist anywhere in the leafmap package (confirmed by searching the source tree — it's not defined in leafmap.py, basemaps.py, or re-exported in __init__.py). This cell will raise AttributeError: module 'leafmap' has no attribute 'basemap_to_tiles'.

The idiomatic pattern used elsewhere in this repo (docs/notebooks/12_split_map.ipynb) is to pass basemap name strings directly to split_map, which resolves them internally via leafmap.basemaps/get_basemap:

Suggested change
"left_layer = leafmap.basemap_to_tiles(leafmap.basemaps.SATELLITE)\n",
"left_layer = \"SATELLITE\"\n",
"right_layer = \"OpenStreetMap\"\n",

" right_layer=right_layer,\n",
")\n",
"\n",
"m2.add_raster(\n",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logic issue (medium confidence): The markdown above this cell says the split map lets users "swipe between the LST layer and the satellite basemap," but m2.add_raster(...) here adds the LST raster as a plain overlay on top of the already-built split view (SATELLITE vs. OpenStreetMap) — it isn't placed into either half of the split control. The rendered result won't demonstrate an LST-vs-satellite comparison as described; the LST layer will just float over both panes identically. Consider passing the LST GeoTIFF path as one of split_map's left_layer/right_layer arguments instead (e.g. left_layer=lst_output, right_layer="SATELLITE") to match the stated intent.

")\n",
"\n",
"print(f\"Found {len(results)} granule(s)\")\n",
"results[0]"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Robustness nit (low-medium confidence): results[0] is accessed without checking that results is non-empty. If the search returns zero granules (e.g., no coverage for this exact date range/bbox, or auth issues), this raises an unhandled IndexError right after printing "Found 0 granule(s)", which is confusing for a tutorial notebook. Same applies to files[0] a couple cells below. A brief assert results, "No granules found..." (or similar) would make the failure mode clearer for readers following along.

" opacity=0.75,\n",
")\n",
"\n",
"m3.add_colorbar(\n",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same add_colorbar misuse as flagged above (cmap="seismic" / label=... are not valid parameters) — this call will also raise TypeError: add_colorbar() missing 1 required positional argument: 'colors'.

Comment on lines +232 to +238
"m.add_colorbar(\n",
" cmap=\"RdYlBu_r\",\n",
" label=\"Land Surface Temperature (°C)\",\n",
" position=\"bottomright\",\n",
")\n",
"\n",
"m"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug (high confidence): add_colorbar does not accept cmap/label keyword arguments. Its signature is add_colorbar(colors, vmin=0, vmax=1.0, index=None, caption="", ...) (see leafmap/leafmap.py:2463), where colors is a required positional list of hex/RGB colors, not a matplotlib colormap name. Every other notebook in this repo (07_colorbar.ipynb, 25_map_title.ipynb, 28_publish_map.ipynb, 00_key_features.ipynb) calls it as add_colorbar(colors=colors, vmin=vmin, vmax=vmax). As written, this cell will raise TypeError: add_colorbar() missing 1 required positional argument: 'colors' when executed.

The same issue recurs at the m3.add_colorbar(...) call further down (cmap="seismic").

Example fix using caption instead of label and a real colors list:

Suggested change
"m.add_colorbar(\n",
" cmap=\"RdYlBu_r\",\n",
" label=\"Land Surface Temperature (°C)\",\n",
" position=\"bottomright\",\n",
")\n",
"\n",
"m"
"m.add_colorbar(\n",
" colors=[\"#2166ac\", \"#67a9cf\", \"#f7f7f7\", \"#ef8a62\", \"#b2182b\"],\n",
" vmin=float(lst_celsius.min()),\n",
" vmax=float(lst_celsius.max()),\n",
" caption=\"Land Surface Temperature (°C)\",\n",
" position=\"bottomright\",\n",

@github-actions

Copy link
Copy Markdown

Code review

This PR adds a new documentation notebook (docs/notebooks/117_urban_heat_island.ipynb) demonstrating an Urban Heat Island workflow with MODIS MOD11A2 LST data. No library code is touched — review focused on whether the notebook's leafmap API calls are correct and will execute successfully.

Bugs

  • add_colorbar(cmap=..., label=..., ...) uses non-existent parameters (high confidence). add_colorbar's real signature is add_colorbar(colors, vmin=0, vmax=1.0, ..., caption="", ...)colors is a required positional list of hex/RGB colors, and there is no cmap or label kwarg. Both calls (for m and m3) will raise TypeError: add_colorbar() missing 1 required positional argument: 'colors'. Every other notebook in the repo uses add_colorbar(colors=colors, vmin=vmin, vmax=vmax).
  • leafmap.basemap_to_tiles does not exist (high confidence). It isn't defined anywhere in the leafmap package. The split-map cell will raise AttributeError. The repo's own convention (12_split_map.ipynb) is to pass basemap name strings directly to split_map(left_layer=..., right_layer=...).
  • Split map doesn't compare LST vs. satellite as described (medium confidence). m2.add_raster(...) is called after split_map(...) and just overlays the LST raster on top of both panes equally, rather than placing it into one side of the split control — contradicting the markdown's claim that the widget lets users "swipe between the LST layer and the satellite basemap."
  • Unchecked empty results (low-medium confidence). results[0] and files[0] are indexed without verifying the list is non-empty; an empty search result (plausible given the fixed month/bbox) would raise an unhandled IndexError right after printing "Found 0 granule(s)".

Security

  • None found. No credentials, secrets, or unsafe input handling — earthaccess.login() uses the standard NASA Earthdata auth flow with local ~/.netrc storage, consistent with the existing 116_hls_nasa_earthdata.ipynb notebook.

Performance

  • None found. This is a documentation notebook operating on a single small raster; no obvious inefficiencies.

Quality

  • Given the two confirmed API-usage bugs above, the notebook has very likely not been executed end-to-end before submission — worth re-running top-to-bottom to catch these before merge.
  • Minor: rxr.open_rasterio(..., masked=True) already applies nodata masking from file metadata, and the code additionally does .where(lst_raw != 0) — not wrong (MOD11A2's fill value is 0), but a short comment on why both are needed would help readability (low confidence nit).

CLAUDE.md

  • No CLAUDE.md file exists in this repository, so no project-specific guidelines apply.

Comment on lines +232 to +238
"m.add_colorbar(\n",
" cmap=\"RdYlBu_r\",\n",
" label=\"Land Surface Temperature (°C)\",\n",
" position=\"bottomright\",\n",
")\n",
"\n",
"m"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug (high confidence): add_colorbar does not accept cmap/label keyword arguments. Its signature is add_colorbar(colors, vmin=0, vmax=1.0, index=None, caption="", ...) (see leafmap/leafmap.py:2463), where colors is a required positional list of hex/RGB colors, not a matplotlib colormap name. Every other notebook in this repo (07_colorbar.ipynb, 25_map_title.ipynb, 28_publish_map.ipynb, 00_key_features.ipynb) calls it as add_colorbar(colors=colors, vmin=vmin, vmax=vmax). As written, this cell will raise TypeError: add_colorbar() missing 1 required positional argument: 'colors' when executed.

The same issue recurs at the m3.add_colorbar(...) call below (cmap="seismic").

Example fix using caption instead of label and a real colors list:

Suggested change
"m.add_colorbar(\n",
" cmap=\"RdYlBu_r\",\n",
" label=\"Land Surface Temperature (°C)\",\n",
" position=\"bottomright\",\n",
")\n",
"\n",
"m"
"m.add_colorbar(\n",
" colors=[\"#2166ac\", \"#67a9cf\", \"#f7f7f7\", \"#ef8a62\", \"#b2182b\"],\n",
" vmin=float(lst_celsius.min()),\n",
" vmax=float(lst_celsius.max()),\n",
" caption=\"Land Surface Temperature (°C)\",\n",
" position=\"bottomright\",\n",

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants