Add Urban Heat Island mapping notebook with MODIS LST#1343
Conversation
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.
📝 WalkthroughWalkthroughAdded a Jupyter notebook demonstrating MODIS MOD11A2 LST acquisition, preprocessing, GeoTIFF export, interactive mapping, split-map comparison, and scene-mean UHI index visualization for Lahore. ChangesUrban Heat Island Workflow
Estimated code review effort: 4 (Complex) | ~45 minutes Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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.ipynbUnexpected 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. Comment |
for more information, see https://pre-commit.ci
|
Tick the box to add this pull request to the merge queue (same as
|
There was a problem hiding this comment.
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
📒 Files selected for processing (1)
docs/notebooks/117_urban_heat_island.ipynb
| "# 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", |
There was a problem hiding this comment.
🎯 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:
- 1: https://ladsweb.modaps.eosdis.nasa.gov/filespec/MODIS/6/MOD11A2_c6
- 2: https://ladsweb.modaps.eosdis.nasa.gov/filespec/MODIS/61/MOD11A2
- 3: https://developers.google.cn/earth-engine/datasets/catalog/MODIS_061_MOD11A2
🏁 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)
PYRepository: 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)
PYRepository: 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)
PYRepository: 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.
| "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", |
There was a problem hiding this comment.
🎯 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*\(' leafmapRepository: 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.pyRepository: 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.
|
/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", |
There was a problem hiding this comment.
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:
| "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", |
There was a problem hiding this comment.
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]" |
There was a problem hiding this comment.
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", |
There was a problem hiding this comment.
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'.
| "m.add_colorbar(\n", | ||
| " cmap=\"RdYlBu_r\",\n", | ||
| " label=\"Land Surface Temperature (°C)\",\n", | ||
| " position=\"bottomright\",\n", | ||
| ")\n", | ||
| "\n", | ||
| "m" |
There was a problem hiding this comment.
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:
| "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", |
Code reviewThis PR adds a new documentation notebook ( Bugs
Security
Performance
Quality
CLAUDE.md
|
| "m.add_colorbar(\n", | ||
| " cmap=\"RdYlBu_r\",\n", | ||
| " label=\"Land Surface Temperature (°C)\",\n", | ||
| " position=\"bottomright\",\n", | ||
| ")\n", | ||
| "\n", | ||
| "m" |
There was a problem hiding this comment.
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:
| "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", |
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
earthaccessrioxarrayStudy 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 (sameearthaccesspattern)05_load_raster.ipynb— raster loading12_split_map.ipynb— split map widgetSummary by CodeRabbit