Source code: Measuring Lake Mead's shrinking shoreline with Sentinel-2, STAC and Python

Runnable Python scripts, requirements and console output for the tutorial “Measuring Lake Mead's shrinking shoreline with Sentinel-2, STAC and Python”.

Last updated

These are the exact scripts used to produce every figure and number in Measuring Lake Mead's shrinking shoreline with Sentinel-2, STAC and Python. Download them, install the requirements into a fresh virtual environment and run them from this folder.

Run it
python -m venv .venv
.venv\Scripts\activate # macOS/Linux: source .venv/bin/activate
pip install -r requirements.txt
python lake_mead_ndwi.py

lake_mead_ndwi.py

lake_mead_ndwi.py
"""Mapping Lake Mead's shrinking shoreline with Sentinel-2, STAC and NDWI.
Searches the Element 84 Earth Search STAC API for cloud-free Sentinel-2 L2A
scenes over Lake Mead, streams the green / NIR / SWIR / SCL bands from Cloud
Optimised GeoTIFFs at reduced resolution, masks clouds, computes NDWI and
MNDWI and tracks open-water area year by year.
Run with: python lake_mead_ndwi.py
"""
from collections import defaultdict
from pathlib import Path
import geopandas as gpd
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import rasterio
from matplotlib.colors import ListedColormap
from pyproj import Transformer
from pystac_client import Client
from rasterio.enums import Resampling
from rasterio.transform import from_origin
from rasterio.vrt import WarpedVRT
HERE = Path(__file__).parent
OUT = HERE / "output"
OUT.mkdir(exist_ok=True)
plt.rcParams.update({"figure.dpi": 150, "savefig.bbox": "tight", "font.size": 9})
pd.set_option("display.width", 120)
STAC_URL = "https://earth-search.aws.element84.com/v1"
BBOX = (-114.87, 35.98, -114.05, 36.55) # lon/lat around Lake Mead
TILES = {"11SPA", "11SQA", "11SPV", "11SQV"} # the four MGRS tiles that cover the lake
YEARS = range(2019, 2026)
RES = 60 # analysis resolution in metres
CRS = "EPSG:32611" # UTM zone 11N, native CRS of both tiles
CLOUD_CLASSES = [3, 8, 9, 10] # SCL: cloud shadow, cloud medium/high, cirrus
# Build a fixed analysis grid so every year lines up pixel-for-pixel.
to_utm = Transformer.from_crs("EPSG:4326", CRS, always_xy=True)
xs, ys = to_utm.transform([BBOX[0], BBOX[2], BBOX[0], BBOX[2]], [BBOX[1], BBOX[1], BBOX[3], BBOX[3]])
x0, x1 = np.floor(min(xs) / RES) * RES, np.ceil(max(xs) / RES) * RES
y0, y1 = np.floor(min(ys) / RES) * RES, np.ceil(max(ys) / RES) * RES
WIDTH, HEIGHT = int((x1 - x0) / RES), int((y1 - y0) / RES)
TRANSFORM = from_origin(x0, y1, RES, RES)
def section(title: str) -> None:
print(f"\n=== {title} ===")
def tile_id(item) -> str:
p = item.properties
return f"{p['mgrs:utm_zone']}{p['mgrs:latitude_band']}{p['mgrs:grid_square']}"
def read_band(href: str, resampling=Resampling.average) -> np.ndarray:
"""Warp a (remote) COG onto the analysis grid. GDAL uses the COG overviews."""
with rasterio.open(href) as src, WarpedVRT(src, crs=CRS, transform=TRANSFORM, width=WIDTH,
height=HEIGHT, resampling=resampling, nodata=0) as vrt:
return vrt.read(1)
def scaled(item, band: str) -> np.ndarray:
"""Read a reflectance band and convert digital numbers to surface reflectance.
Since processing baseline 04.00 ESA adds a +1000 DN offset to L2A data. Earth
Search v1 re-encodes those COGs with the offset already removed and flags this
with `earthsearch:boa_offset_applied`; in that case only the scale is needed.
"""
asset = item.assets[band]
rb = asset.extra_fields.get("raster:bands", [{}])[0]
offset = 0.0 if item.properties.get("earthsearch:boa_offset_applied") else rb.get("offset", 0.0)
dn = read_band(asset.href).astype("float32")
refl = dn * rb.get("scale", 1.0) + offset
refl[dn == 0] = np.nan
return refl
# --- Step 1: search the catalogue -------------------------------------------
catalog = Client.open(STAC_URL)
rows = []
for year in YEARS:
search = catalog.search(collections=["sentinel-2-l2a"], bbox=BBOX,
datetime=f"{year}-07-01/{year}-09-30",
query={"eo:cloud_cover": {"lt": 5}})
by_date = defaultdict(dict)
for item in search.items():
tid = tile_id(item)
if tid not in TILES:
continue
# Some dates have been processed twice (e.g. *_0_L2A and a reprocessed *_1_L2A);
# keep the item with the newest processing baseline.
prev = by_date[item.datetime.date()].get(tid)
if prev is None or (item.properties.get("s2:processing_baseline", "")
> prev.properties.get("s2:processing_baseline", "")):
by_date[item.datetime.date()][tid] = item
complete = {d: v for d, v in by_date.items() if set(v) == TILES}
if not complete:
continue
# Prefer full-swath, clear dates: penalise missing pixels (edge of orbit) and cloud.
def badness(d):
return sum(i.properties.get("s2:nodata_pixel_percentage", 0) + i.properties["eo:cloud_cover"]
for i in complete[d].values())
date = min(complete, key=badness)
items = complete[date]
rows.append({"year": year, "date": date, "items": items,
"cloud_pct": round(np.mean([i.properties["eo:cloud_cover"] for i in items.values()]), 2),
"nodata_pct": round(np.mean([i.properties.get("s2:nodata_pixel_percentage", 0)
for i in items.values()]), 2),
"baseline": items["11SQA"].properties.get("s2:processing_baseline"),
"offset_applied": items["11SQA"].properties.get("earthsearch:boa_offset_applied")})
scenes = pd.DataFrame(rows)
section("selected scenes")
print(scenes.drop(columns="items").to_string(index=False))
example = rows[0]["items"]["11SQA"]
section("example item")
print(example.id)
print("assets:", ", ".join(k for k in example.assets if not k.endswith("-jp2")))
print("green raster:bands:", example.assets["green"].extra_fields["raster:bands"])
print("earthsearch:boa_offset_applied:", example.properties.get("earthsearch:boa_offset_applied"))
latest = rows[-1]["items"]["11SQA"]
print("latest green raster:bands:", latest.assets["green"].extra_fields["raster:bands"])
# --- Step 2: NDWI + cloud mask per year ---------------------------------------
pixel_km2 = RES * RES / 1e6
results, water_masks, rgb = [], {}, {}
for row in rows:
green = np.full((HEIGHT, WIDTH), np.nan, "float32")
nir, swir = green.copy(), green.copy()
scl = np.zeros((HEIGHT, WIDTH), "uint8")
for item in row["items"].values(): # mosaic the tiles: first valid pixel wins
g, n, sw = scaled(item, "green"), scaled(item, "nir"), scaled(item, "swir16")
s = read_band(item.assets["scl"].href, Resampling.nearest)
fill = np.isnan(green) & ~np.isnan(g)
green[fill], nir[fill], swir[fill], scl[fill] = g[fill], n[fill], sw[fill], s[fill]
with np.errstate(invalid="ignore", divide="ignore"):
ndwi = (green - nir) / (green + nir) # McFeeters (1996)
mndwi = (green - swir) / (green + swir) # Xu (2006): less sensitive to turbidity and glint
cloudy = np.isin(scl, CLOUD_CLASSES)
valid = ~np.isnan(ndwi) & ~np.isnan(mndwi) & ~cloudy
water = (mndwi > 0) & valid
water_masks[row["year"]] = water
results.append({"year": row["year"], "date": row["date"],
"ndwi_km2": round(((ndwi > 0) & valid).sum() * pixel_km2, 1),
"water_km2": round(water.sum() * pixel_km2, 1),
"scl_water_km2": round(((scl == 6) & valid).sum() * pixel_km2, 1),
"cloud_masked_km2": round(cloudy.sum() * pixel_km2, 1),
"valid_pct": round(valid.mean() * 100, 1)})
print(f"processed {row['date']}: NDWI water = {results[-1]['ndwi_km2']} km², "
f"MNDWI water = {results[-1]['water_km2']} km²")
if row["year"] in (rows[0]["year"], 2022):
tci = np.zeros((3, HEIGHT, WIDTH), "uint8")
for item in row["items"].values():
with rasterio.open(item.assets["visual"].href) as src, WarpedVRT(
src, crs=CRS, transform=TRANSFORM, width=WIDTH, height=HEIGHT,
resampling=Resampling.average, nodata=0) as vrt:
t = vrt.read()
empty = tci.sum(axis=0) == 0
tci[:, empty] = t[:, empty]
rgb[row["year"]] = np.moveaxis(tci, 0, -1)
if row["year"] == 2022:
ndwi_2022, mndwi_2022 = ndwi, mndwi
table = pd.DataFrame(results)
section("water area by year")
print(table.to_string(index=False))
table.to_csv(OUT / "water_area.csv", index=False)
first, last = rows[0]["year"], rows[-1]["year"]
lost = water_masks[first] & ~water_masks[2022]
section("change")
print(f"water in {first} but not 2022: {lost.sum() * pixel_km2:.1f} km²")
print(f"water in 2022 but not {first}: {(water_masks[2022] & ~water_masks[first]).sum() * pixel_km2:.1f} km²")
print(f"change {first} -> {last}: {table.water_km2.iloc[-1] - table.water_km2.iloc[0]:+.1f} km²")
# --- Step 3: figures -------------------------------------------------------------
extent = [x0, x1, y0, y1]
fig, axes = plt.subplots(1, 2, figsize=(11, 4.6))
for ax, year in zip(axes, (first, 2022)):
ax.imshow(rgb[year], extent=extent)
ax.set_title(f"Sentinel-2 true colour, {scenes.loc[scenes.year == year, 'date'].iloc[0]}")
ax.set_xticks([])
ax.set_yticks([])
fig.savefig(OUT / "01-true-colour.png")
plt.close(fig)
date_2022 = scenes.loc[scenes.year == 2022, "date"].iloc[0]
fig, axes = plt.subplots(1, 2, figsize=(11, 4.4))
for ax, arr, name in zip(axes, (ndwi_2022, mndwi_2022), ("NDWI (green, NIR)", "MNDWI (green, SWIR1)")):
im = ax.imshow(arr, cmap="RdBu", vmin=-0.8, vmax=0.8, extent=extent)
ax.set_title(f"{name}, {date_2022}")
ax.set_xticks([])
ax.set_yticks([])
fig.colorbar(im, ax=axes, label="Index value (water > 0, blue)", shrink=0.8)
fig.savefig(OUT / "02-ndwi-2022.png")
plt.close(fig)
change = np.zeros((HEIGHT, WIDTH), "uint8")
change[water_masks[first] & water_masks[2022]] = 1
change[lost] = 2
change[water_masks[2022] & ~water_masks[first]] = 3
fig, ax = plt.subplots(figsize=(8, 5.6))
ax.imshow(change, cmap=ListedColormap(["#f4f1ea", "#1d6fa3", "#e8590c", "#2f9e44"]),
vmin=0, vmax=3, extent=extent, interpolation="nearest")
handles = [plt.Rectangle((0, 0), 1, 1, color=c) for c in ("#1d6fa3", "#e8590c", "#2f9e44")]
ax.legend(handles, [f"Water in {first} and 2022", f"Water in {first}, dry in 2022", f"New water in 2022"],
loc="lower right", fontsize=8)
ax.set_title(f"Shoreline retreat between {first} and 2022 (MNDWI > 0)")
ax.set_xticks([])
ax.set_yticks([])
fig.savefig(OUT / "03-change-map.png")
plt.close(fig)
fig, ax = plt.subplots(figsize=(8.5, 3.6))
xpos = np.arange(len(table))
for off, col, colour, label in ((-0.27, "water_km2", "#1d6fa3", "MNDWI > 0"),
(0.0, "ndwi_km2", "#74c0fc", "NDWI > 0"),
(0.27, "scl_water_km2", "#ced4da", "SCL class 6 (water)")):
bars = ax.bar(xpos + off, table[col], width=0.26, color=colour, label=label)
if col == "water_km2":
for b, v in zip(bars, table[col]):
ax.text(b.get_x() + b.get_width() / 2, v + 2, f"{v:.0f}", ha="center", fontsize=7)
ax.set_xticks(xpos)
ax.set_xticklabels([f"{y}\n{d:%d %b}" for y, d in zip(table.year, table.date)])
ax.set_ylabel("Open water (km²)")
cols = ["water_km2", "ndwi_km2", "scl_water_km2"]
ax.set_ylim(table[cols].min().min() * 0.85, table[cols].max().max() * 1.08)
ax.legend(ncol=3, fontsize=8, loc="upper right")
ax.set_title("Lake Mead open-water area from summer Sentinel-2 scenes")
ax.grid(axis="y", alpha=0.3)
fig.savefig(OUT / "04-water-area-timeseries.png")
plt.close(fig)
# Save the 2022 water mask as a GeoTIFF for use in QGIS.
with rasterio.open(OUT / "water_2022.tif", "w", driver="GTiff", height=HEIGHT, width=WIDTH, count=1,
dtype="uint8", crs=CRS, transform=TRANSFORM, compress="deflate") as dst:
dst.write(water_masks[2022].astype("uint8"), 1)