Source code: Monitoring reservoir water quality from space with Sentinel-2 and Python (Peak District case study)
Runnable Python scripts, requirements and console output for the tutorial “Monitoring reservoir water quality from space with Sentinel-2 and Python (Peak…
Last updated
These are the exact scripts used to produce every figure and number in Monitoring reservoir water quality from space with Sentinel-2 and Python (Peak District case study). Download them, install the requirements into a fresh virtual environment and run them from this folder.
- reservoir_water_quality.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 reservoir_water_quality.pyreservoir_water_quality.py
"""Monitoring reservoir water quality from space: Sentinel-2 over four Peak District reservoirs.
Gets reservoir outlines from OpenStreetMap, searches the Microsoft Planetary Computer STAC APIfor every Sentinel-2 L2A scene since 2019, masks cloud and shadow with the scene classification(SCL) band, keeps only strictly-water pixels well inside each shoreline, and computes relativewater-quality indices (NDCI for chlorophyll, NDTI and red reflectance for turbidity, a green/bluecolour ratio for peat colour). It then builds a multi-year time series and monthly climatology perreservoir, ranks the most anomalous dates, maps one summer date and compares the results withpublic Environment Agency samples.Run with: python reservoir_water_quality.py"""import ioimport platformimport timefrom concurrent.futures import ThreadPoolExecutor, as_completedfrom importlib.metadata import versionfrom pathlib import Path
import geopandas as gpdimport matplotlib
matplotlib.use("Agg")import matplotlib.dates as mdatesimport matplotlib.patheffects as peimport matplotlib.pyplot as pltimport numpy as npimport osmnx as oximport pandas as pdimport planetary_computerimport pystac_clientimport rasterioimport rasterio.featuresimport requestsfrom matplotlib.patches import Rectanglefrom rasterio.enums import Resamplingfrom rasterio.transform import from_originfrom rasterio.windows import from_boundsfrom scipy import ndimagefrom scipy.stats import spearmanrfrom shapely.geometry import box
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)
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: reservoir outlines from OpenStreetMap ---------------------------------RESERVOIRS = ["Howden", "Derwent", "Ladybower", "Torside"] # north to south, then LongdendaleCOLOURS = {"Howden": "#1d6fa3", "Derwent": "#2f9e44", "Ladybower": "#e8590c", "Torside": "#862e9c"}CRS = "EPSG:32630" # UTM zone 30N, the native grid of Sentinel-2 tile 30UWEINSET_M = 40 # shrink each outline by 40 m (two 20 m pixels) to avoid mixed shoreline pixels
outlines = DATA / "reservoirs.gpkg"if not outlines.exists(): ox.settings.cache_folder = CACHE / "osmnx" osm = None for attempt in range(5): # public Overpass servers are often busy: retry with backoff and 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((-1.95, 53.33, -1.62, 53.55), {"water": "reservoir"}) 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)) osm = osm[osm.geometry.type.isin(["Polygon", "MultiPolygon"]) & osm["name"].notna()] osm = osm[osm["name"].isin([f"{r} Reservoir" for r in RESERVOIRS])].reset_index() osm[["id", "name", "geometry"]].to_file(outlines, driver="GPKG")res = gpd.read_file(outlines).to_crs(CRS)res["reservoir"] = res["name"].str.replace(" Reservoir", "")res = res.dissolve("reservoir").loc[RESERVOIRS].reset_index()res["area_ha"] = res.area / 1e4res["core"] = res.buffer(-INSET_M)res["core_ha"] = res["core"].area / 1e4section("reservoir outlines (OpenStreetMap)")print(res[["reservoir", "id", "area_ha", "core_ha"]].round(1).to_string(index=False))
# --- Step 2: one analysis grid at 20 m ------------------------------------------------RES = 20x0, y0, x1, y1 = res.total_boundsx0, y0 = np.floor((x0 - 600) / RES) * RES, np.floor((y0 - 600) / RES) * RESx1, y1 = np.ceil((x1 + 600) / RES) * RES, np.ceil((y1 + 600) / RES) * RESWIDTH, HEIGHT = int((x1 - x0) / RES), int((y1 - y0) / RES)TRANSFORM = from_origin(x0, y1, RES, RES)BOUNDS = (x0, y0, x1, y1)# Label raster: 1..4 = core (inset) pixels of each reservoir, 0 = everything else.labels = rasterio.features.rasterize(((g, i + 1) for i, g in enumerate(res["core"])), out_shape=(HEIGHT, WIDTH), transform=TRANSFORM, fill=0, dtype="uint8")section("analysis grid")print(f"{WIDTH} x {HEIGHT} pixels of {RES} m ({(x1 - x0) / 1000:.1f} x {(y1 - y0) / 1000:.1f} km), {CRS}")for i, name in enumerate(RESERVOIRS, 1): print(f"{name:<10} {(labels == i).sum():>5} core water pixels")
# --- Step 3: search the Sentinel-2 L2A catalogue -----------------------------------------STAC_URL = "https://planetarycomputer.microsoft.com/api/stac/v1"PERIOD = "2019-01-01/2026-10-14"bbox_ll = gpd.GeoSeries([box(*BOUNDS)], crs=CRS).to_crs(4326).total_boundscatalog = pystac_client.Client.open(STAC_URL)search = catalog.search(collections=["sentinel-2-l2a"], bbox=bbox_ll, datetime=PERIOD, query={"eo:cloud_cover": {"lt": 95}, "s2:mgrs_tile": {"eq": "30UWE"}})items = list(search.items())# Some dates exist twice (an original and a reprocessed baseline): keep the newest processing.best = {}for it in items: key = (it.datetime.date(), it.properties["sat:relative_orbit"]) if key not in best or it.properties["s2:processing_baseline"] > best[key].properties["s2:processing_baseline"]: best[key] = ititems = sorted(best.values(), key=lambda i: i.datetime)section("STAC search")print(f"{len(items)} Sentinel-2 L2A scenes of tile 30UWE with < 95% cloud, " f"{items[0].datetime:%d %b %Y} to {items[-1].datetime:%d %b %Y}")print(pd.Series([i.properties["s2:processing_baseline"] for i in items]).value_counts().sort_index().to_dict())
# --- Step 4: read, mask and summarise every scene ----------------------------------------------BANDS = ["B02", "B03", "B04", "B05", "B06", "B08", "B11"]BAD_SCL = [0, 1, 3, 8, 9, 10, 11] # no data, saturated, cloud shadow, cloud (medium, high), cirrus, snowCLOUD_BUFFER_PX = 5 # grow cloud and shadow by 100 m: their edges are rarely classified wellMIN_CLEAR = 0.5 # need at least half of a reservoir's core pixels to be clearNIR_MAX = 0.04 # water is dark in near-infrared; exposed mud, weed and boats are notGDAL_ENV = dict(GDAL_DISABLE_READDIR_ON_OPEN="EMPTY_DIR", GDAL_HTTP_MAX_RETRY=5, GDAL_HTTP_RETRY_DELAY=2, GDAL_HTTP_MERGE_CONSECUTIVE_RANGES="YES")
def read_band(item, band: str, res: int = RES) -> np.ndarray: """Read one band over the analysis grid, averaged to 20 m (10 m bands) or as-is (20 m bands).""" href = planetary_computer.sign(item.assets[band].href) # sign at read time: tokens expire shape = (int(HEIGHT * RES / res), int(WIDTH * RES / res)) for attempt in range(4): try: with rasterio.Env(**GDAL_ENV), rasterio.open(href) as src: win = from_bounds(*BOUNDS, src.transform) return src.read(1, window=win, out_shape=shape, boundless=True, fill_value=0, resampling=Resampling.average if band != "SCL" else Resampling.nearest) except rasterio.errors.RasterioIOError: time.sleep(5 * (attempt + 1)) raise RuntimeError(f"could not read {band} of {item.id}")
def to_reflectance(dn: np.ndarray, item) -> np.ndarray: """L2A digital numbers to surface reflectance. Baseline 04.00+ (from 25 Jan 2022) adds a -1000 offset.""" offset = -1000 if float(item.properties["s2:processing_baseline"]) >= 4.0 else 0 return np.where(dn > 0, (dn.astype("float32") + offset) / 10000, np.nan)
def process(item) -> list[dict]: """Cloud-mask one scene and return the median water spectrum of each reservoir.""" p = item.properties base = {"date": item.datetime.strftime("%Y-%m-%d"), "item_id": item.id, "orbit": p["sat:relative_orbit"], "baseline": p["s2:processing_baseline"], "sun_zenith": round(p["s2:mean_solar_zenith"], 1)} scl = read_band(item, "SCL") bad = ndimage.binary_dilation(np.isin(scl, BAD_SCL[1:]), iterations=CLOUD_BUFFER_PX) | (scl == 0) clear_frac = {i: float((~bad[labels == i]).mean()) for i in range(1, len(RESERVOIRS) + 1)} rows = [dict(base, reservoir=n, clear_frac=round(clear_frac[i], 3)) for i, n in enumerate(RESERVOIRS, 1)] if max(clear_frac.values()) < MIN_CLEAR: return rows # too cloudy: skip the other bands entirely r = {b: to_reflectance(read_band(item, b), item) for b in BANDS} with np.errstate(divide="ignore", invalid="ignore"): mndwi = (r["B03"] - r["B11"]) / (r["B03"] + r["B11"]) water = ~bad & (r["B08"] < NIR_MAX) & np.isfinite(r["B11"]) # NIR-dark: no exposed bank, weed or boats for i, row in enumerate(rows, 1): core, w = labels == i, water & (labels == i) clear = core & ~bad row.update(n_pixels=int(w.sum()), water_frac=round(float(w.sum() / core.sum()), 3), scl_water_frac=round(float((scl[clear] == 6).mean()), 3) if clear.any() else np.nan, mndwi_pos_frac=round(float((mndwi[clear] > 0).mean()), 3) if clear.any() else np.nan) if clear_frac[i] >= MIN_CLEAR and w.sum() >= 20: row.update({f"rho_{b}": round(float(np.median(r[b][w])), 5) for b in BANDS}) return rows
cache_file = CACHE / "observations.csv"cached = pd.read_csv(cache_file) if cache_file.exists() else pd.DataFrame(columns=["item_id"])todo = [it for it in items if it.id not in set(cached["item_id"])]section("reading Sentinel-2 over the reservoirs")print(f"{len(items) - len(todo)} scenes already cached, {len(todo)} to read")new_rows, t0 = [], time.time()with ThreadPoolExecutor(max_workers=8) as pool: futures = [pool.submit(process, it) for it in todo] for k, fut in enumerate(as_completed(futures), 1): new_rows.extend(fut.result()) if k % 100 == 0 or k == len(todo): # save progress so an interrupted run can resume print(f" {k}/{len(todo)} scenes, {time.time() - t0:.0f} s") pd.concat([cached, pd.DataFrame(new_rows)], ignore_index=True).to_csv(cache_file, index=False)cached = pd.read_csv(cache_file)obs = cached.sort_values(["date", "reservoir"]).reset_index(drop=True)obs["date"] = pd.to_datetime(obs["date"])
# --- Step 5: quality control and water-quality indices ------------------------------------------MAX_ZENITH = 70 # low winter sun: long paths through the atmosphere and weak water signalMIN_WATER = 0.6 # at least 60% of a reservoir's core pixels must pass the cloud and water testsMAX_SWIR = 0.03 # water absorbs SWIR almost completely: a bright median B11 means haze, glint or thin cloudMIN_RHO = 0.003 # B2-B5 darker than this: ratios are dominated by noise (Sen2Cor water error ~0.002-0.005)BANDS_RHO = [f"rho_{b}" for b in BANDS]
def add_indices(df: pd.DataFrame) -> pd.DataFrame: """Relative indices computed from the median water spectrum of each reservoir and date.""" b = {k: df[f"rho_{k}"] for k in BANDS} df["ndci"] = (b["B05"] - b["B04"]) / (b["B05"] + b["B04"]) # chlorophyll-a proxy df["mci"] = b["B05"] - b["B04"] - (b["B06"] - b["B04"]) * (705 - 665) / (740 - 665) # red-edge peak height df["ndti"] = (b["B04"] - b["B03"]) / (b["B04"] + b["B03"]) # turbidity proxy df["red"] = b["B04"] # turbidity proxy (reflectance) df["green_blue"] = b["B03"] / b["B02"] # colour (CDOM, peat) proxy df["mndwi"] = (b["B03"] - b["B11"]) / (b["B03"] + b["B11"]) # kept only as a diagnostic return df
def qc_reason(row) -> str: if row["clear_frac"] < MIN_CLEAR or pd.isna(row["rho_B04"]): return "cloud or shadow" if row["sun_zenith"] > MAX_ZENITH: return "low sun" if row["water_frac"] < MIN_WATER: return "too few water pixels" if row["rho_B11"] > MAX_SWIR: return "haze or glint" if (row[BANDS_RHO[:-1]] <= 0).any(): return "negative reflectance" if (row[["rho_B02", "rho_B03", "rho_B04", "rho_B05"]] < MIN_RHO).any(): return "signal too dark" return "ok"
obs = add_indices(obs)obs["qc"] = obs.apply(qc_reason, axis=1)good = obs[obs["qc"] == "ok"].copy()section("quality control")print(pd.crosstab(obs["qc"], obs["reservoir"])[RESERVOIRS].to_string())print(f"\n{len(good)} usable reservoir-dates on {good['date'].nunique()} dates, " f"{good['date'].min():%d %b %Y} to {good['date'].max():%d %b %Y}")section("why MNDWI fails on peaty water")print(good.groupby("reservoir")[["rho_B03", "rho_B11", "mndwi", "scl_water_frac", "mndwi_pos_frac"]] .median().loc[RESERVOIRS].round(4).to_string())
# --- Step 6: anomalies: robust z-score against each reservoir's own month-of-year -----------------good["month"] = good["date"].dt.monthgrp = good.groupby(["reservoir", "month"])["ndci"]good["clim_med"] = grp.transform("median")good["clim_mad"] = grp.transform(lambda x: (x - x.median()).abs().median())good["clim_n"] = grp.transform("size")# Months with fewer than five observations fall back to the reservoir's whole-record statistics.whole = good.groupby("reservoir")["ndci"]few = good["clim_n"] < 5good.loc[few, "clim_med"] = whole.transform("median")[few]good.loc[few, "clim_mad"] = whole.transform(lambda x: (x - x.median()).abs().median())[few]good["ndci_z"] = (good["ndci"] - good["clim_med"]) / (1.4826 * good["clim_mad"])# If most reservoirs spike on the same day, suspect the atmosphere (haze, aerosol model) before the water.high = good[good["ndci_z"] > 2]good["n_high_same_day"] = good["date"].map(high.groupby("date").size()).fillna(0).astype(int)good["flag"] = np.where(good["n_high_same_day"] >= 3, "check atmosphere (≥3 reservoirs high)", np.where(good["ndci_z"] > 2, "investigate", ""))section("dates most worth investigating (NDCI robust z > 2)")cols = ["date", "reservoir", "ndci", "ndci_z", "ndti", "green_blue", "sun_zenith", "flag"]top = good.sort_values("ndci_z", ascending=False).head(12)[cols]print(top.assign(date=top["date"].dt.strftime("%Y-%m-%d")).round(3).to_string(index=False))
# --- Step 7: one tidy CSV per reservoir ---------------------------------------------------------keep = ["date", "reservoir", "item_id", "orbit", "baseline", "sun_zenith", "clear_frac", "water_frac", "n_pixels", *BANDS_RHO, "ndci", "mci", "ndti", "red", "green_blue", "mndwi", "qc"]tidy = obs[keep].merge(good[["item_id", "reservoir", "ndci_z", "flag"]], on=["item_id", "reservoir"], how="left")section("tidy CSV exports")for name in RESERVOIRS: f = OUT / f"{name.lower()}_sentinel2_water_quality.csv" t = tidy[tidy["reservoir"] == name] t.round(5).to_csv(f, index=False, date_format="%Y-%m-%d") print(f"{f.name:<42} {len(t):>4} rows, {int((t['qc'] == 'ok').sum()):>3} usable")
# --- Step 8: seasonal summary --------------------------------------------------------------------SEASON = {3: "spring", 4: "spring", 5: "spring", 6: "summer", 7: "summer", 8: "summer", 9: "autumn", 10: "autumn", 11: "autumn", 12: "winter", 1: "winter", 2: "winter"}good["season"] = good["month"].map(SEASON)section("median NDCI by season (n in brackets)")med = good.pivot_table(index="reservoir", columns="season", values="ndci", aggfunc="median")cnt = good.pivot_table(index="reservoir", columns="season", values="ndci", aggfunc="size")seasons = [s for s in ["spring", "summer", "autumn"] if s in med]print(pd.DataFrame({s: [f"{med.loc[r, s]:.3f} ({cnt.loc[r, s]:.0f})" for r in RESERVOIRS] for s in seasons}, index=RESERVOIRS).to_string())section("whole-record medians")summary = good.groupby("reservoir")[["ndci", "mci", "ndti", "red", "green_blue"]].median().loc[RESERVOIRS]summary["n"] = good.groupby("reservoir").size()print(summary.round(4).to_string())
# --- Step 9: maps for one clear summer date -------------------------------------------------------summer = good[good["month"].between(6, 9)]full = summer.groupby("date").agg(n=("reservoir", "size"), water=("water_frac", "min"), swir=("rho_B11", "mean"))MAP_DATE = full[full["n"] == len(RESERVOIRS)].sort_values(["water", "swir"], ascending=[False, True]).index[0]map_item = next(it for it in items if it.datetime.strftime("%Y-%m-%d") == MAP_DATE.strftime("%Y-%m-%d"))section("map date")print(f"{MAP_DATE:%d %B %Y} {map_item.id} sun zenith {map_item.properties['s2:mean_solar_zenith']:.1f}°")
scl = read_band(map_item, "SCL")bad = ndimage.binary_dilation(np.isin(scl, BAD_SCL[1:]), iterations=CLOUD_BUFFER_PX) | (scl == 0)r = {b: to_reflectance(read_band(map_item, b), map_item) for b in BANDS}water = (labels > 0) & ~bad & (r["B08"] < NIR_MAX)
def smooth(a: np.ndarray, mask: np.ndarray) -> np.ndarray: """3 x 3 mean over water pixels only (60 m), to tame pixel noise in very dark water.""" v = ndimage.uniform_filter(np.where(mask, a, 0), 3) w = ndimage.uniform_filter(mask.astype("float32"), 3) with np.errstate(divide="ignore", invalid="ignore"): return np.where(mask, v / w, np.nan)
s = {b: smooth(r[b], water) for b in ("B03", "B04", "B05")}with np.errstate(divide="ignore", invalid="ignore"): ndci_map = (s["B05"] - s["B04"]) / (s["B05"] + s["B04"]) ndti_map = (s["B04"] - s["B03"]) / (s["B04"] + s["B03"])rgb10 = np.dstack([to_reflectance(read_band(map_item, b, res=10), map_item) for b in ("B04", "B03", "B02")])rgb20 = np.dstack([r[b] for b in ("B04", "B03", "B02")])for name in RESERVOIRS: m = labels == RESERVOIRS.index(name) + 1 print(f"{name:<10} NDCI p10/median/p90 {np.nanpercentile(ndci_map[m], 10):6.3f} {np.nanmedian(ndci_map[m]):6.3f} " f"{np.nanpercentile(ndci_map[m], 90):6.3f} | NDTI median {np.nanmedian(ndti_map[m]):6.3f}")
def stretch(rgb: np.ndarray, lo=1, hi=99, gamma=0.8) -> np.ndarray: a, b = np.nanpercentile(rgb, [lo, hi]) return np.clip((rgb - a) / (b - a), 0, 1) ** gamma
EXTENT = (x0, x1, y0, y1)HALO = {"white": [pe.withStroke(linewidth=2, foreground="black")], "black": [pe.withStroke(linewidth=2, foreground="white")]}GROUPS = {"Upper Derwent Valley": ["Howden", "Derwent", "Ladybower"], "Longdendale": ["Torside"]}
def group_window(names, pad=500): gx0, gy0, gx1, gy1 = res[res["reservoir"].isin(names)].total_bounds return (max(gx0 - pad, x0), min(gx1 + pad, x1)), (max(gy0 - pad, y0), min(gy1 + pad, y1))
def map_panels(title: str, layer=None, cmap=None, vmin=None, vmax=None, label=None, fname=None): wins = [group_window(n) for n in GROUPS.values()] ratios = [(xl[1] - xl[0]) / (yl[1] - yl[0]) for xl, yl in wins] fig, axes = plt.subplots(1, 2, figsize=(10, 5.4), gridspec_kw={"width_ratios": ratios}, layout="constrained") for ax, (gname, names) in zip(axes, GROUPS.items()): if layer is None: ax.imshow(stretch(rgb10), extent=EXTENT) else: grey = np.nanmean(stretch(rgb20), axis=2) ax.imshow(grey, cmap="gray", extent=EXTENT, vmin=0, vmax=1.4) im = ax.imshow(layer, cmap=cmap, vmin=vmin, vmax=vmax, extent=EXTENT, interpolation="nearest") sub = res[res["reservoir"].isin(names)] sub.boundary.plot(ax=ax, color="white" if layer is None else "#333333", lw=0.8) if layer is None: gpd.GeoSeries(sub["core"], crs=CRS).boundary.plot(ax=ax, color="#ffd43b", lw=0.6, ls="--") for _, row in sub.iterrows(): c = row.geometry.representative_point() ax.annotate(row["reservoir"], (c.x, c.y), xytext=(14, 0), textcoords="offset points", fontsize=8, color="black", bbox=dict(boxstyle="round,pad=0.2", fc="white", ec="none", alpha=0.85)) xl, yl = group_window(names) ax.set_xlim(xl), ax.set_ylim(yl) ax.set_title(gname) ax.set_xticks([]), ax.set_yticks([]), ax.set_xlabel(""), ax.set_ylabel("") ink = "white" if layer is None else "black" h = (yl[1] - yl[0]) / 100 ax.add_patch(Rectangle((xl[0] + 200, yl[0] + 3 * h), 1000, h, color=ink)) ax.text(xl[0] + 700, yl[0] + 5 * h, "1 km", ha="center", va="bottom", fontsize=7, color=ink, path_effects=HALO[ink]) if layer is not None: fig.colorbar(im, ax=axes, shrink=0.75, label=label, location="bottom", aspect=40) fig.suptitle(f"{title}, {MAP_DATE:%d %B %Y}") fig.savefig(OUT / fname) plt.close(fig)
# Figure 1: locator map with a Great Britain inset.fig, ax = plt.subplots(figsize=(8, 7))ax.imshow(stretch(rgb20), extent=EXTENT)res.boundary.plot(ax=ax, color="white", lw=0.9)for _, row in res.iterrows(): c = row.geometry.centroid ax.annotate(f"{row['reservoir']}\n{row['area_ha']:.0f} ha", (c.x, c.y), xytext=(18, -4), textcoords="offset points", fontsize=8, color=COLOURS[row["reservoir"]], fontweight="bold", bbox=dict(boxstyle="round,pad=0.25", fc="white", ec=COLOURS[row["reservoir"]], lw=0.8))ax.set_xticks([]), ax.set_yticks([]), ax.set_xlabel(""), ax.set_ylabel("")ax.set_title(f"Peak District study reservoirs on Sentinel-2 true colour, {MAP_DATE:%d %B %Y}")ax.add_patch(Rectangle((x0 + 500, y0 + 500), 2000, 120, color="white"))ax.text(x0 + 1500, y0 + 700, "2 km", ha="center", va="bottom", color="white", fontsize=8, path_effects=HALO["white"])try: ne = DATA / "ne_50m_admin_0_countries.gpkg" if not ne.exists(): gpd.read_file("https://naciscdn.org/naturalearth/50m/cultural/ne_50m_admin_0_countries.zip").to_file(ne) uk = gpd.read_file(ne) uk = uk[uk["ADM0_A3"].isin(["GBR", "IRL", "IMN"])].to_crs(27700) inset = ax.inset_axes([0.76, 0.62, 0.24, 0.38]) uk.plot(ax=inset, color="#e9ecef", edgecolor="#868e96", lw=0.4) p = gpd.GeoSeries([box(*BOUNDS)], crs=CRS).to_crs(27700).centroid.iloc[0] inset.plot(p.x, p.y, "o", ms=6, mfc="#e8590c", mec="white") inset.set_xlim(-50_000, 700_000), inset.set_ylim(0, 1_000_000) inset.set_xticks([]), inset.set_yticks([]), inset.set_xlabel(""), inset.set_ylabel("") inset.set_facecolor("#d0ebff")except Exception as exc: # the inset is decoration: never fail the tutorial over it print(f"(GB inset skipped: {exc})")fig.savefig(OUT / "01-locator-map.png")plt.close(fig)
map_panels("Sentinel-2 true colour (B4, B3, B2 at 10 m); dashed = 40 m inset core", fname="02-true-colour.png")lo, hi = np.nanpercentile(ndci_map[labels > 0], [2, 98])map_panels("NDCI (chlorophyll-a proxy), 60 m smoothed", ndci_map, "Spectral_r", lo, hi, "NDCI = (B5 − B4) / (B5 + B4), relative", "03-ndci-map.png")lo, hi = np.nanpercentile(ndti_map[labels > 0], [2, 98])map_panels("NDTI (turbidity proxy), 60 m smoothed", ndti_map, "BrBG_r", lo, hi, "NDTI = (B4 − B3) / (B4 + B3), relative", "04-turbidity-map.png")
# Figure 5: multi-year NDCI time series.fig, axes = plt.subplots(len(RESERVOIRS), 1, figsize=(10, 7.5), sharex=True, sharey=True)for ax, name in zip(axes, RESERVOIRS): g = good[good["reservoir"] == name].sort_values("date") for yr in range(2019, 2027): ax.axvspan(pd.Timestamp(f"{yr}-06-01"), pd.Timestamp(f"{yr}-09-01"), color="#fff3bf", lw=0) ax.plot(g["date"], g["ndci"], "-", color=COLOURS[name], lw=0.6, alpha=0.5) ax.plot(g["date"], g["ndci"], "o", color=COLOURS[name], ms=3) hi_ = g[g["ndci_z"] > 2] ax.plot(hi_["date"], hi_["ndci"], "o", ms=8, mfc="none", mec="black", mew=0.9) ax.axhline(g["ndci"].median(), color=COLOURS[name], ls="--", lw=0.8) ax.text(0.005, 0.9, f"{name} (n = {len(g)})", transform=ax.transAxes, fontweight="bold", color=COLOURS[name], va="top") ax.grid(alpha=0.3)axes[0].set_ylim(good["ndci"].min() - 0.02, good["ndci"].max() + 0.02)axes[len(RESERVOIRS) // 2].set_ylabel("NDCI (relative)")axes[-1].xaxis.set_major_locator(mdates.YearLocator())axes[-1].xaxis.set_major_formatter(mdates.DateFormatter("%Y"))axes[0].set_title("Sentinel-2 NDCI per reservoir (median of core water pixels); yellow = June–August, " "circled = robust z > 2")fig.savefig(OUT / "05-ndci-timeseries.png")plt.close(fig)
# Figure 6: monthly climatology of three indices.fig, axes = plt.subplots(1, 3, figsize=(11, 3.8))specs = [("ndci", "NDCI (chlorophyll proxy)"), ("ndti", "NDTI (turbidity proxy)"), ("green_blue", "B3 / B2 (colour proxy)")]months = range(3, 10)for ax, (col, lab) in zip(axes, specs): for name in RESERVOIRS: g = good[(good["reservoir"] == name) & good["month"].isin(months)].groupby("month")[col] q = g.quantile([0.25, 0.5, 0.75]).unstack() q = q[g.size() >= 3] ax.plot(q.index, q[0.5], "-o", color=COLOURS[name], ms=3, label=name) ax.fill_between(q.index, q[0.25], q[0.75], color=COLOURS[name], alpha=0.12, lw=0) ax.set_xticks(list(months), ["Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep"]) ax.set_title(lab) ax.grid(alpha=0.3)axes[0].legend(fontsize=8, frameon=False)fig.suptitle("Monthly median and interquartile range, 2019–2026 (months with at least 3 usable dates)", y=1.02)fig.tight_layout()fig.savefig(OUT / "06-monthly-climatology.png")plt.close(fig)
# --- Step 10: sanity check against public Environment Agency samples ------------------------------EA = "https://environment.data.gov.uk/water-quality"POINTS = {"MD-49714900": "Howden", "MD-49714220": "Derwent", "MD-LSN0395": "Ladybower", "NW-88021743": "Torside"}DETERMINANDS = {"7887": "chlorophyll_ugl", "0072": "colour_hazen"}ea_file = DATA / "ea_samples.csv"if not ea_file.exists(): frames = [] for point, name in POINTS.items(): for code in DETERMINANDS: skip = 0 while True: resp = requests.get(f"{EA}/sampling-point/{point}/observation", headers={"Accept": "text/csv"}, params={"dateFrom": "2019-01-01", "dateTo": "2026-10-14", "determinand": code, "limit": 250, "skip": skip}, timeout=60) resp.raise_for_status() part = pd.read_csv(io.StringIO(resp.text), dtype=str) if resp.text.strip() else pd.DataFrame() if part.empty: break frames.append(part.assign(point=point, reservoir=name)) if len(part) < 250: break skip += 250 pd.concat(frames, ignore_index=True).to_csv(ea_file, index=False)ea = pd.read_csv(ea_file, dtype=str)ea["date"] = pd.to_datetime(ea["phenomenonTime"].str[:10])ea["censored"] = ea["result"].str.startswith("<")ea["value"] = pd.to_numeric(ea["result"].str.lstrip("<>"), errors="coerce")ea["var"] = ea["determinand.notation"].str.zfill(4).map(DETERMINANDS)ea = ea.dropna(subset=["var", "value"])section("Environment Agency samples (2019 onwards)")print(ea.groupby(["reservoir", "var"])["date"].agg(["size", "min", "max"]).rename(columns={"size": "n"}) .reset_index().assign(min=lambda d: d["min"].dt.strftime("%Y-%m-%d"), max=lambda d: d["max"].dt.strftime("%Y-%m-%d")).to_string(index=False))
MATCH_DAYS = 3pairs = []for _, s_ in ea.iterrows(): g = good[good["reservoir"] == s_["reservoir"]] dt = (g["date"] - s_["date"]).abs() if len(g) and dt.min() <= pd.Timedelta(days=MATCH_DAYS): m = g.loc[dt.idxmin()] pairs.append({"reservoir": s_["reservoir"], "var": s_["var"], "sample_date": s_["date"], "s2_date": m["date"], "value": s_["value"], "censored": s_["censored"], "ndci": m["ndci"], "mci": m["mci"], "green_blue": m["green_blue"], "ndti": m["ndti"]})pairs = pd.DataFrame(pairs)section(f"satellite vs sample matches (within ±{MATCH_DAYS} days)")chl, col = pairs[pairs["var"] == "chlorophyll_ugl"], pairs[pairs["var"] == "colour_hazen"]show = chl.assign(sample_date=chl["sample_date"].dt.strftime("%Y-%m-%d"), s2_date=chl["s2_date"].dt.strftime("%Y-%m-%d"))print(show[["reservoir", "sample_date", "s2_date", "value", "censored", "ndci", "mci"]].round(4).to_string(index=False))for lab, d, x in [("chlorophyll vs NDCI", chl, "ndci"), ("chlorophyll vs MCI", chl, "mci"), ("colour vs B3/B2", col, "green_blue")]: if len(d) >= 4: rho, pval = spearmanr(d["value"], d[x]) print(f"{lab:<22} n = {len(d):>2} Spearman rho = {rho:5.2f} p = {pval:.2f}") else: print(f"{lab:<22} n = {len(d):>2} too few matches for a correlation")
# Figure 7: the in-situ sanity check.fig, axes = plt.subplots(1, 2, figsize=(10, 4))for ax, (d, x, xl, yl) in zip(axes, [(chl, "ndci", "Sentinel-2 NDCI", "Chlorophyll-a, acetone extract (µg/L)"), (col, "green_blue", "Sentinel-2 B3 / B2", "Colour, filtered (Hazen)")]): for name in RESERVOIRS: g = d[d["reservoir"] == name] if len(g): ax.scatter(g[x], g["value"], color=COLOURS[name], s=28, label=f"{name} ({len(g)})", edgecolor="white", lw=0.5) cz = g[g["censored"]] ax.scatter(cz[x], cz["value"], s=60, facecolor="none", edgecolor="black", lw=0.7) if len(d) >= 4: rho, _ = spearmanr(d["value"], d[x]) ax.set_title(f"Spearman ρ = {rho:.2f}, n = {len(d)}") ax.set_xlabel(xl), ax.set_ylabel(yl) ax.grid(alpha=0.3) ax.legend(fontsize=7, frameon=False, loc="upper left", bbox_to_anchor=(1.0, 1.0))fig.suptitle(f"Sentinel-2 vs Environment Agency samples within ±{MATCH_DAYS} days (open circle = below detection limit)", y=1.02)fig.tight_layout()fig.savefig(OUT / "07-in-situ-check.png")plt.close(fig)
section("figures written")for f in sorted(OUT.glob("*.png")): print(f.name)