Source code: Mann-Kendall trend test on rasters in Python: Theil-Sen NDVI trends with xarray
Runnable Python scripts, requirements and console output for the tutorial “Mann-Kendall trend test on rasters in Python: Theil-Sen NDVI trends with…
Last updated
These are the exact scripts used to produce every figure and number in Mann-Kendall trend test on rasters in Python: Theil-Sen NDVI trends with xarray. Download them, install the requirements into a fresh virtual environment and run them from this folder.
- ndvi_trend_mann_kendall.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 ndvi_trend_mann_kendall.pyndvi_trend_mann_kendall.py
"""Pixel-wise NDVI trend analysis in Python: Mann-Kendall and Theil-Sen with xarray.apply_ufunc on25 years of MODIS Terra NDVI (MOD13A1 v6.1) over the Loess Plateau around Yan'an, China, with theseasonal Mann-Kendall test, serial-correlation corrections and Benjamini-Hochberg FDR control.
TechnicalGIS tutorial: https://www.technicalgis.com/tutorials/raster-trend-analysis-mann-kendall-python/Data: MODIS/Terra Vegetation Indices 16-Day L3 Global 500 m (MOD13A1 v061), NASA LP DAAC, served by theMicrosoft Planetary Computer. MODIS data are freely reusable without restriction."""import sysimport timeimport warningsfrom concurrent.futures import ThreadPoolExecutorfrom importlib.metadata import versionfrom pathlib import Path
import numpy as npimport pandas as pdimport xarray as xrimport rioxarray # noqa: F401 (registers the .rio accessor)import rasteriofrom rasterio.warp import transform_boundsfrom rasterio.windows import from_boundsimport pystac_clientimport planetary_computerimport pymannkendall as pmkfrom scipy.stats import normfrom statsmodels.stats.multitest import multipletestsimport matplotlibmatplotlib.use("Agg") # render to files only; no GUI backendimport matplotlib.pyplot as pltfrom matplotlib.colors import ListedColormap, BoundaryNorm, TwoSlopeNormfrom matplotlib.patches import Patch
warnings.filterwarnings("ignore")plt.rcParams.update({"figure.dpi": 150, "savefig.bbox": "tight", "font.size": 9, "figure.facecolor": "white", "axes.facecolor": "white"})
DATA = Path("data"); DATA.mkdir(exist_ok=True)OUT = Path("output"); OUT.mkdir(exist_ok=True)SEED = 42rng = np.random.default_rng(SEED)BBOX = (108.5, 36.0, 110.5, 38.5) # lon/lat: Yan'an (south) to Yulin (north), Shaanxi, ChinaYEARS = range(2001, 2025) # 24 complete years (2025 is incomplete in the catalogue)SEASON = [5, 6, 7, 8, 9] # growing season, May-SeptemberALPHA = 0.05CITIES = {"Yan'an": (109.49, 36.59), "Yulin": (109.73, 38.29), "Suide": (110.25, 37.50)}
def section(title): print(f"\n=== {title} ===")
section("versions")print(f"python {sys.version.split()[0]} | numpy {version('numpy')} | xarray {version('xarray')} | " f"rasterio {version('rasterio')} | pymannkendall {version('pymannkendall')} | scipy {version('scipy')}")
# ---------------------------------------------------------------- Step 1: find and read the MODIS compositessection("MOD13A1 v6.1 items on the Planetary Computer")cube_file = DATA / "mod13a1_ndvi_cube.npz"if not cube_file.exists(): catalog = pystac_client.Client.open("https://planetarycomputer.microsoft.com/api/stac/v1", modifier=planetary_computer.sign_inplace) items = catalog.search(collections=["modis-13A1-061"], bbox=BBOX, datetime=f"{YEARS[0]}-01-01/{YEARS[-1] + 1}-12-31").item_collection() items = sorted([it for it in items if it.id.startswith("MOD13A1")], key=lambda it: it.properties["start_datetime"]) start = [pd.Timestamp(it.properties["start_datetime"]).tz_localize(None) for it in items] tiles = sorted({it.id.split(".")[2] for it in items}) print(f"{len(items)} Terra composites, tiles {tiles}, {start[0]:%Y-%m-%d} .. {start[-1]:%Y-%m-%d}")
with rasterio.open(items[0].assets["500m_16_days_NDVI"].href) as src: crs = src.crs win = from_bounds(*transform_bounds("EPSG:4326", crs, *BBOX, densify_pts=51), src.transform) win = win.round_offsets().round_lengths() transform = rasterio.windows.transform(win, src.transform)
def read(item): out = [] for key in ("500m_16_days_NDVI", "500m_16_days_pixel_reliability"): for attempt in range(5): try: with rasterio.open(item.assets[key].href) as src: out.append(src.read(1, window=win)) break except rasterio.errors.RasterioIOError: time.sleep(5 * (attempt + 1)) planetary_computer.sign_inplace(item) else: raise RuntimeError(f"could not read {item.id} {key}") return out
t0 = time.time() with rasterio.Env(GDAL_DISABLE_READDIR_ON_OPEN="EMPTY_DIR", GDAL_HTTP_MAX_RETRY=5, GDAL_HTTP_RETRY_DELAY=2): with ThreadPoolExecutor(16) as pool: arrays = list(pool.map(read, items)) print(f"read {len(items)} x 2 windows of {win.height} x {win.width} pixels in {time.time() - t0:.0f} s") np.savez_compressed(cube_file, ndvi=np.stack([a[0] for a in arrays]), reliability=np.stack([a[1] for a in arrays]), time=np.array(start, dtype="datetime64[s]"), x=transform.c + transform.a * (np.arange(win.width) + 0.5), y=transform.f + transform.e * (np.arange(win.height) + 0.5), crs=crs.to_wkt())z = np.load(cube_file)cube = xr.Dataset({"ndvi": (("time", "y", "x"), z["ndvi"]), "reliability": (("time", "y", "x"), z["reliability"])}, coords={"time": pd.DatetimeIndex(z["time"]), "y": z["y"], "x": z["x"]})crs = rasterio.crs.CRS.from_wkt(str(z["crs"]))dup = cube.indexes["time"].duplicated()print(f"cube: {cube.sizes['time']} composites x {cube.sizes['y']} x {cube.sizes['x']} pixels (500 m, MODIS sinusoidal); " f"{dup.sum()} duplicate composites (reprocessed copies) dropped")cube = cube.isel(time=~dup)expected = pd.DatetimeIndex([pd.Timestamp(f"{y}-01-01") + pd.Timedelta(days=16 * k) for y in range(2001, 2026) for k in range(23)])missing = expected.difference(cube.indexes["time"])print(f"composites missing from the catalogue: {', '.join(missing.strftime('%Y-%m-%d'))}")print(f"-> 2025 lacks July-November, so the analysis uses {YEARS[0]}-{YEARS[-1]} ({len(YEARS)} growing seasons)")cube = cube.sel(time=cube.time.dt.year.isin(list(YEARS)))
# ---------------------------------------------------------------- Step 2: QA maskingsection("QA masking with the pixel reliability layer")rel = cube.reliabilityfor k, name in [(-1, "fill / no data"), (0, "good"), (1, "marginal"), (2, "snow or ice"), (3, "cloudy")]: print(f" reliability {k:>2} ({name}): {float((rel == k).mean()):.1%} of pixel-composites in the window")ndvi = cube.ndvi.where((rel >= 0) & (rel <= 1) & (cube.ndvi > -2000)) * 1e-4ndvi = ndvi.assign_coords(time=cube.time + pd.Timedelta(days=8)) # date each composite by its mid-point
# ---------------------------------------------------------------- Step 3: aggregate to remove seasonalitysection("growing-season aggregation")gs = ndvi.where(ndvi.time.dt.month.isin(SEASON), drop=True)n_valid = gs.notnull().groupby("time.year").sum()annual = gs.groupby("time.year").mean().where(n_valid >= 5) # need 5 of ~10 compositesmonthly = gs.resample(time="MS").mean()monthly = monthly.where(monthly.time.dt.month.isin(SEASON), drop=True)monthly = monthly.assign_coords(year=("time", monthly.time.dt.year.data), month=("time", monthly.time.dt.month.data))monthly = monthly.set_index(time=["year", "month"]).unstack("time").transpose("year", "month", "y", "x")mean_ndvi = annual.mean("year")years_ok = annual.notnull().sum("year")# the sinusoidal window is a parallelogram in lon/lat: keep only pixels whose centre is inside BBOXlon, lat = rasterio.warp.transform(crs, "EPSG:4326", *[a.ravel() for a in np.meshgrid(cube.x, cube.y)])lon = np.reshape(lon, mean_ndvi.shape); lat = np.reshape(lat, mean_ndvi.shape)inside = xr.DataArray((lon >= BBOX[0]) & (lon <= BBOX[2]) & (lat >= BBOX[1]) & (lat <= BBOX[3]), dims=("y", "x"))mean_ndvi = mean_ndvi.where(inside)land = inside & (mean_ndvi > 0.1) & (years_ok == len(YEARS))print(f"growing-season composites per year: {gs.time.size / len(YEARS):.1f} on average; median valid (QA-passed) " f"per pixel-year inside the box {float(n_valid.where(inside).median()):.0f}")print(f"pixels analysed: {int(land.sum()):,} of {int(inside.sum()):,} inside the box (dropped: mean growing-season " f"NDVI <= 0.1 or any year with < 5 valid composites)")annual = annual.where(land)monthly = monthly.where(land)
# ---------------------------------------------------------------- Step 4: vectorised Mann-Kendall + Theil-Sendef mk_theil_sen(x, t): """Mann-Kendall test and Theil-Sen slope along the last axis of x (time), for any leading shape. Missing values are skipped pairwise. Variance ignores ties (fine for continuous NDVI means).""" i, j = np.triu_indices(x.shape[-1], 1) d = x[..., j] - x[..., i] s = np.nansum(np.sign(d), axis=-1) n = np.sum(~np.isnan(x), axis=-1) var = n * (n - 1) * (2 * n + 5) / 18.0 z = np.where(s > 0, (s - 1) / np.sqrt(var), np.where(s < 0, (s + 1) / np.sqrt(var), 0.0)) p = 2 * norm.sf(np.abs(z)) slope = np.nanmedian(d / (t[j] - t[i]), axis=-1) return np.stack([s, var, z, p, slope], axis=-1)
def seasonal_mk(x, t): """Seasonal Mann-Kendall (Hirsch et al. 1982): x has dims (..., year, season). S and Var(S) are summed over seasons; the slope is the median of all within-season pairwise slopes.""" i, j = np.triu_indices(x.shape[-2], 1) s = var = 0 slopes = [] for m in range(x.shape[-1]): xm = x[..., m] d = xm[..., j] - xm[..., i] s = s + np.nansum(np.sign(d), axis=-1) n = np.sum(~np.isnan(xm), axis=-1) var = var + n * (n - 1) * (2 * n + 5) / 18.0 slopes.append(d / (t[j] - t[i])) z = np.where(s > 0, (s - 1) / np.sqrt(var), np.where(s < 0, (s + 1) / np.sqrt(var), 0.0)) p = 2 * norm.sf(np.abs(z)) slope = np.nanmedian(np.concatenate(slopes, axis=-1), axis=-1) return np.stack([s, var, z, p, slope], axis=-1)
section("vectorised Mann-Kendall and Theil-Sen (annual growing-season means)")t_years = np.asarray(annual.year, dtype=float)t0 = time.time()stats = xr.apply_ufunc(mk_theil_sen, annual.chunk({"y": 64, "x": 64}), kwargs={"t": t_years}, input_core_dims=[["year"]], output_core_dims=[["stat"]], dask="parallelized", output_dtypes=[float], dask_gufunc_kwargs={"output_sizes": {"stat": 5}})stats = stats.assign_coords(stat=["S", "varS", "z", "p", "slope"]).compute()print(f"{int(land.sum()):,} pixels in {time.time() - t0:.1f} s")p_mk = stats.sel(stat="p").where(land)slope = stats.sel(stat="slope").where(land) * 10 # NDVI per decade
# cross-check against pymannkendall on random pixelsyy, xx = np.nonzero(land.values)pick = rng.choice(len(yy), 6, replace=False)print("cross-check with pymannkendall.original_test (pixel: S ours/pmk, p ours/pmk, slope ours/pmk):")for k in pick: series = annual.values[:, yy[k], xx[k]] ref = pmk.original_test(series) ours = stats.values[yy[k], xx[k]] print(f" ({yy[k]:>3},{xx[k]:>3}): S {ours[0]:+5.0f}/{ref.s:+5.0f} p {ours[3]:.4f}/{ref.p:.4f} " f"slope {ours[4]:+.5f}/{ref.slope:+.5f}")
section("seasonal Mann-Kendall (monthly means, May-September as seasons)")sstats = xr.apply_ufunc(seasonal_mk, monthly.chunk({"y": 64, "x": 64}), kwargs={"t": t_years}, input_core_dims=[["year", "month"]], output_core_dims=[["stat"]], dask="parallelized", output_dtypes=[float], dask_gufunc_kwargs={"output_sizes": {"stat": 5}})sstats = sstats.assign_coords(stat=["S", "varS", "z", "p", "slope"]).compute()print("cross-check with pymannkendall.seasonal_test(period=5):")for k in pick[:3]: mser = monthly.values[:, :, yy[k], xx[k]] # (year, month) ref = pmk.seasonal_test(mser.reshape(-1), period=len(SEASON)) ours = sstats.values[yy[k], xx[k]] print(f" ({yy[k]:>3},{xx[k]:>3}): S {ours[0]:+5.0f}/{ref.s:+5.0f} p {ours[3]:.4f}/{ref.p:.4f} " f"slope {ours[4]:+.5f}/{ref.slope:+.5f}")p_smk = sstats.sel(stat="p").where(land)both = land.valuesagree = np.mean(np.sign(stats.sel(stat="z").values[both]) == np.sign(sstats.sel(stat="z").values[both]))print(f"annual MK and seasonal MK agree on the sign of the trend for {agree:.1%} of pixels; " f"p < 0.05 (unadjusted): annual {float((p_mk < ALPHA).sum() / land.sum()):.1%}, " f"seasonal {float((p_smk < ALPHA).sum() / land.sum()):.1%}")
# ---------------------------------------------------------------- Step 5: serial correlationsection("serial correlation")
def lag1(x): """Lag-1 autocorrelation along the last axis after removing a least-squares linear trend.""" t = np.arange(x.shape[-1], dtype=float) xd = x - np.nanmean(x, -1, keepdims=True) tt = t - t.mean() b = np.nansum(xd * tt, -1, keepdims=True) / np.sum(tt ** 2) e = xd - b * tt return np.nansum(e[..., 1:] * e[..., :-1], -1) / np.nansum(e ** 2, -1)
r1_annual = lag1(np.moveaxis(annual.values, 0, -1))[both]anom = ndvi.groupby("time.dayofyear") - ndvi.groupby("time.dayofyear").mean() # 16-day deseasonalised anomaliesanom_v = np.moveaxis(anom.fillna(0).values, 0, -1)[both]r1_16day = lag1(anom_v)crit = 1.96 / np.sqrt(len(YEARS))print(f"lag-1 autocorrelation, annual growing-season means: median {np.median(r1_annual):+.2f}; " f"|r1| > {crit:.2f} (approx. 5% test) for {np.mean(np.abs(r1_annual) > crit):.1%} of pixels")print(f"lag-1 autocorrelation, 16-day deseasonalised anomalies: median {np.median(r1_16day):+.2f}")
sub = rng.choice(len(yy), 2000, replace=False)tests = {"Original MK": pmk.original_test, "Hamed-Rao modified MK": pmk.hamed_rao_modification_test, "Yue-Wang modified MK": pmk.yue_wang_modification_test, "Pre-whitening (PW)": pmk.pre_whitening_modification_test, "Trend-free pre-whitening (TFPW)": pmk.trend_free_pre_whitening_modification_test}sig_share = {}for name, f in tests.items(): ps = np.array([f(annual.values[:, yy[k], xx[k]]).p for k in sub]) sig_share[name] = np.mean(ps < ALPHA) print(f" {name:<32} p < 0.05 for {sig_share[name]:.1%} of 2,000 random pixels")naive16 = np.array([pmk.original_test(anom_v[k]).p for k in sub[:500]])hr16 = np.array([pmk.hamed_rao_modification_test(anom_v[k]).p for k in sub[:500]])print(f" for contrast, MK on the 16-day anomaly series ({anom_v.shape[1]} values) of 500 pixels: p < 0.05 for " f"{np.mean(naive16 < ALPHA):.1%} (original) vs {np.mean(hr16 < ALPHA):.1%} (Hamed-Rao)")
# ---------------------------------------------------------------- Step 6: multiple testingsection("false discovery rate across pixels")pv = p_mk.values[both]m = len(pv)order = np.argsort(pv)ranked = pv[order] * m / np.arange(1, m + 1)q = np.minimum.accumulate(ranked[::-1])[::-1]q_bh = np.empty(m); q_bh[order] = np.minimum(q, 1)q_sm = multipletests(pv, alpha=ALPHA, method="fdr_bh")[1]print(f"own Benjamini-Hochberg vs statsmodels: max |difference| in q = {np.abs(q_bh - q_sm).max():.2e}")p_cut = pv[q_bh <= ALPHA].max()print(f"tests: {m:,}; p < 0.05 unadjusted: {np.sum(pv < ALPHA):,} ({np.mean(pv < ALPHA):.1%}); " f"expected false positives if no trend anywhere: {ALPHA * m:,.0f}")print(f"BH q < 0.05: {np.sum(q_bh < ALPHA):,} ({np.mean(q_bh < ALPHA):.1%}); equivalent p-value threshold {p_cut:.4f}")print(f"Bonferroni p < {ALPHA / m:.1e}: {np.sum(pv < ALPHA / m):,} ({np.mean(pv < ALPHA / m):.1%})")qmap = xr.full_like(p_mk, np.nan); qmap.values[both] = q_bhsl = slope.values[both]green = (q_bh < ALPHA) & (sl > 0); brown = (q_bh < ALPHA) & (sl < 0)print(f"significant greening: {green.sum():,} pixels ({green.mean():.1%}), median slope " f"{np.median(sl[green]):+.3f} NDVI/decade")print(f"significant browning: {brown.sum():,} pixels ({brown.mean():.1%}), median slope " f"{np.median(sl[brown]):+.3f} NDVI/decade")print(f"area-mean Theil-Sen slope: {np.mean(sl):+.4f} NDVI/decade; mean growing-season NDVI " f"{float(annual.sel(year=YEARS[0]).mean()):.3f} ({YEARS[0]}) -> {float(annual.sel(year=YEARS[-1]).mean()):.3f} ({YEARS[-1]})")
# significance is not magnitudesmall_sig = np.sum((q_bh < ALPHA) & (np.abs(sl) < 0.02))big_ns = np.sum((q_bh >= ALPHA) & (np.abs(sl) > 0.05))print(f"significant but small (|slope| < 0.02/decade): {small_sig:,}; large (> 0.05/decade) but not significant: {big_ns:,}")
# sensor sensitivity: Terra's orbit has drifted since 2020section("sensitivity to the end of the series (Terra orbit drift after 2020)")st19 = mk_theil_sen(np.moveaxis(annual.sel(year=slice(2001, 2019)).values, 0, -1)[both], t_years[:19])sig19 = st19[:, 3] < ALPHAprint(f"2001-2019: p < 0.05 for {sig19.mean():.1%} of pixels (full series {np.mean(pv < ALPHA):.1%}); " f"slope correlation with {YEARS[0]}-{YEARS[-1]} " f"{np.corrcoef(st19[:, 4] * 10, sl)[0, 1]:.2f}; same sign {np.mean(np.sign(st19[:, 4]) == np.sign(sl)):.1%}")
# ---------------------------------------------------------------- Figuressection("figures")
def to_geo(da, resampling=rasterio.enums.Resampling.nearest): da = da.rio.write_crs(crs).rio.set_spatial_dims(x_dim="x", y_dim="y") return da.rio.reproject("EPSG:4326", resampling=resampling).rio.clip_box(*BBOX)
def decorate(ax): for name, (lo, la) in CITIES.items(): ax.plot(lo, la, "o", ms=4, mfc="white", mec="black", mew=0.8) ax.text(lo + 0.04, la + 0.03, name, fontsize=7.5, bbox=dict(boxstyle="round,pad=0.15", fc="white", ec="none", alpha=0.8)) ax.set_xlim(BBOX[0], BBOX[2]); ax.set_ylim(BBOX[1], BBOX[3]) ax.set_xticks(np.arange(BBOX[0], BBOX[2] + 0.01, 0.5)) ax.set_aspect(1 / np.cos(np.radians(37.25))) ax.set_xlabel("Longitude (°E)"); ax.set_ylabel("Latitude (°N)")
def show(ax, da, **kw): g = to_geo(da) return ax.imshow(g.values, extent=(float(g.x.min()), float(g.x.max()), float(g.y.min()), float(g.y.max())), interpolation="nearest", **kw)
# Figure 1: mean NDVI and valid observationsfig, axes = plt.subplots(1, 2, figsize=(10, 6.2), constrained_layout=True)im = show(axes[0], mean_ndvi.where(mean_ndvi > -1), cmap="YlGn", vmin=0, vmax=0.8)fig.colorbar(im, ax=axes[0], shrink=0.75, label=f"Mean growing-season NDVI, {YEARS[0]}–{YEARS[-1]}")axes[0].set_title("Mean May–September NDVI (MOD13A1, 500 m)")good_share = (rel.sel(time=rel.time.dt.month.isin(SEASON)) == 0).mean("time").where(inside)im = show(axes[1], 100 * good_share, cmap="cividis", vmin=50, vmax=100)fig.colorbar(im, ax=axes[1], shrink=0.75, extend="min", label="May–September composites rated 'good' (reliability 0), %")axes[1].set_title("QA: share of good-quality composites")for ax in axes: decorate(ax)fig.savefig(OUT / "01-mean-ndvi-and-qa.png"); plt.close(fig)
# Figure 2: Theil-Sen slope mapfig, ax = plt.subplots(figsize=(6.4, 6.6), constrained_layout=True)lim = 0.15im = show(ax, slope, cmap="BrBG", norm=TwoSlopeNorm(0, -lim, lim))cb = fig.colorbar(im, ax=ax, shrink=0.8, extend="both")cb.set_label("Theil-Sen slope (NDVI units per decade)")ax.set_title(f"Growing-season NDVI trend, {YEARS[0]}–{YEARS[-1]}")decorate(ax)fig.savefig(OUT / "02-theil-sen-slope.png"); plt.close(fig)
# Figure 3: significance after FDR control vs family-wise (Bonferroni) controlcat_cmap = ListedColormap(["#a6611a", "#dfc27d", "#e8e8e8", "#80cdc1", "#018571"])fig, axes = plt.subplots(1, 2, figsize=(10.5, 6.2), constrained_layout=True)for ax, pmap, cut, title in [(axes[0], qmap, ALPHA, "Benjamini-Hochberg FDR: q < 0.05"), (axes[1], p_mk, ALPHA / m, "Bonferroni: p < 0.05 / m")]: sig_px = pmap < cut cat = xr.where(sig_px, xr.where(slope > 0, 4, 0), xr.where(slope > 0, 3, xr.where(slope < 0, 1, 2))).where(land) show(ax, cat, cmap=cat_cmap, norm=BoundaryNorm(np.arange(6) - 0.5, 5)) ax.set_title(f"{title} ({float(sig_px.sum() / land.sum()):.1%} of pixels)") decorate(ax)fig.legend(handles=[Patch(color=c, label=l) for c, l in zip( cat_cmap.colors[::-1], ["Significant greening", "Greening, not significant", "No change (slope 0)", "Browning, not significant", "Significant browning"])], loc="outside lower center", ncols=5, fontsize=8)fig.savefig(OUT / "03-significant-trends-fdr.png"); plt.close(fig)
# Figure 4: time series at example pixelssl_full = slope.values; q_full = qmap.valuescands = { "Strong significant greening": np.nanargmax(np.where(q_full < 0.001, sl_full, np.nan)), "Significant browning": np.nanargmin(np.where(q_full < ALPHA, sl_full, np.nan)), "Large slope, not significant": np.nanargmax(np.where(q_full > 0.2, np.abs(sl_full), np.nan)), "Small slope, significant": np.nanargmin(np.where(q_full < 0.01, np.abs(sl_full), np.nan)),}fig, axes = plt.subplots(2, 2, figsize=(11, 6.4), sharex=True, constrained_layout=True)lon_lat = {}for ax, (title, flat) in zip(axes.flat, cands.items()): iy, ix = np.unravel_index(flat, sl_full.shape) lo, la = rasterio.warp.transform(crs, "EPSG:4326", [float(cube.x[ix])], [float(cube.y[iy])]) lon_lat[title] = (lo[0], la[0]) raw = ndvi[:, iy, ix] ax.plot(raw.time, raw, ".", color="#bbbbbb", ms=2, label="16-day composites (QA-passed)") a = annual[:, iy, ix] ax.plot(pd.to_datetime([f"{y}-07-15" for y in a.year.values]), a, "o-", color="#0072B2", ms=3.5, lw=1, label="May–September mean") st = stats.values[iy, ix] tt = t_years icpt = np.median(a.values - st[4] * tt) ax.plot(pd.to_datetime([f"{y}-07-15" for y in tt.astype(int)]), icpt + st[4] * tt, "--", color="#D55E00", lw=1.5, label="Theil-Sen line") ax.set_title(f"{title}\n{la[0]:.2f}°N {lo[0]:.2f}°E: slope {st[4] * 10:+.3f}/decade, " f"p = {st[3]:.2g}, q = {q_full[iy, ix]:.2g}", fontsize=8.5) ax.set_ylabel("NDVI"); ax.grid(color="0.92")h, l = axes[0, 0].get_legend_handles_labels()fig.legend(h, l, loc="outside lower center", ncols=3, fontsize=8)fig.savefig(OUT / "04-example-time-series.png"); plt.close(fig)for k, v in lon_lat.items(): print(f"example pixel '{k}': {v[1]:.3f} N, {v[0]:.3f} E")
# Figure 5: serial correlation and the modified testsfig, axes = plt.subplots(1, 2, figsize=(11, 4.2), constrained_layout=True)bins = np.linspace(-0.8, 1, 46)axes[0].hist(r1_16day, bins=bins, color="#E69F00", alpha=0.8, label="16-day deseasonalised anomalies")axes[0].hist(r1_annual, bins=bins, color="#0072B2", alpha=0.8, label="Annual growing-season means")axes[0].axvspan(-crit, crit, color="0.85", zorder=0, label=f"|r$_1$| < {crit:.2f} (n = {len(YEARS)})")axes[0].set_xlabel("Lag-1 autocorrelation of detrended series"); axes[0].set_ylabel("Pixels")axes[0].legend(fontsize=7.5); axes[0].set_title("Aggregation removes most serial correlation")names = list(sig_share)axes[1].barh(range(len(names)), [100 * sig_share[n] for n in names], color="#56B4E9")for i, n in enumerate(names): axes[1].text(100 * sig_share[n] + 0.5, i, f"{100 * sig_share[n]:.1f}%", va="center", fontsize=8)axes[1].set_yticks(range(len(names)), names); axes[1].invert_yaxis()axes[1].set_xlabel("Pixels with p < 0.05 (%, 2,000 random pixels)")axes[1].set_xlim(0, max(sig_share.values()) * 115)axes[1].set_title("Mann-Kendall variants on the annual series")fig.savefig(OUT / "05-serial-correlation.png"); plt.close(fig)
# Figure 6: significance vs magnitudefig, ax = plt.subplots(figsize=(7.5, 4.6), constrained_layout=True)lp = -np.log10(np.maximum(q_bh, 1e-12))ymax = np.ceil(lp.max()) + 0.5hb = ax.hexbin(sl, lp, gridsize=70, bins="log", cmap="viridis", mincnt=1, extent=(-0.2, 0.25, 0, ymax))ax.axhline(-np.log10(ALPHA), color="#D55E00", lw=1)ax.text(-0.195, -np.log10(ALPHA) + 0.15, "q = 0.05", color="#D55E00", fontsize=8)for xv in (-0.02, 0.02): ax.axvline(xv, color="0.4", ls=":", lw=0.8)ax.set_xlim(-0.2, 0.25); ax.set_ylim(0, ymax)ax.set_xlabel("Theil-Sen slope (NDVI units per decade)"); ax.set_ylabel("−log10(BH-adjusted q)")fig.colorbar(hb, ax=ax, label="Pixels per hexagon")ax.set_title("Significance is not magnitude: q depends on noise as well as slope")fig.savefig(OUT / "06-significance-vs-magnitude.png"); plt.close(fig)print("wrote 6 figures to output/")