Source code: NDVI time series in Python with Sentinel-2, odc-stac and xarray
Runnable Python scripts, requirements and console output for the tutorial “NDVI time series in Python with Sentinel-2, odc-stac and xarray”.
Last updated
These are the exact scripts used to produce every figure and number in NDVI time series in Python with Sentinel-2, odc-stac and xarray. Download them, install the requirements into a fresh virtual environment and run them from this folder.
- ndvi_time_series.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_time_series.pyndvi_time_series.py
"""NDVI time series with Sentinel-2, STAC and odc-stac: five growing seasons of fields in Methwold Fen, Norfolk.
Searches the Microsoft Planetary Computer STAC API for every Sentinel-2 L2A scene of one tile from 2021to 2025, loads them lazily as a dask-backed xarray datacube with odc-stac, masks cloud and shadow withthe scene classification (SCL) band, computes NDVI, builds monthly median composites, extracts per-fieldtime series for crop blocks from the Rural Payments Agency's Crop Map of England (CROME) 2023, smoothsthem with a Savitzky-Golay filter and derives simple phenology dates for each crop.Run with: python ndvi_time_series.py"""import jsonimport platformimport timeimport warningsfrom importlib.metadata import versionfrom pathlib import Path
import daskimport geopandas as gpdimport imageio.v3 as iioimport matplotlib
matplotlib.use("Agg")import matplotlib.dates as mdatesimport matplotlib.pyplot as pltimport numpy as npimport odc.geo.xr # noqa: F401 (adds the .odc accessor to xarray objects)import odc.stacimport pandas as pdimport planetary_computerimport pystac_clientimport rasterio.featuresimport requestsimport xarray as xrfrom matplotlib.patches import Patchfrom scipy import ndimagefrom scipy.signal import savgol_filter
warnings.filterwarnings("ignore", category=RuntimeWarning) # all-NaN medians in fully cloudy monthsHERE = Path(__file__).parentOUT, DATA = HERE / "output", HERE / "data"for d in (OUT, DATA): 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()} | odc-stac {odc.stac.__version__} | xarray {xr.__version__} | " f"dask {dask.__version__} | pystac-client {pystac_client.__version__} | " f"planetary-computer {version('planetary-computer')} | numpy {np.__version__} | pandas {pd.__version__}")
def section(title: str) -> None: print(f"\n=== {title} ===")
# --- Step 1: crop blocks from the Crop Map of England (CROME) 2023 ---------------------------------BBOX = (0.40, 52.50, 0.46, 52.54) # Methwold Fen, Norfolk (lon/lat): about 4 x 4.5 km of Fenland fieldsCRS = "EPSG:32631" # UTM zone 31N, the native grid of Sentinel-2 tile 31UCUCROME = ("https://services-eu1.arcgis.com/KB6uNVj5ZcJr7jUP/ArcGIS/rest/services/" "Crop_Map_of_England_2023/FeatureServer/0/query")CROPS = {"AC66": "Winter wheat", "AC03": "Sugar beet", "AC44": "Potato", "AC17": "Maize", "PG01": "Grass"}COLOURS = {"Winter wheat": "#d4a017", "Sugar beet": "#c21f3a", "Potato": "#7b3fa0", "Maize": "#2f9e44", "Grass": "#1d6fa3"}MIN_FIELD_HA = 3
crome_file = DATA / "crome_2023.gpkg"if not crome_file.exists(): geom = json.dumps({"xmin": BBOX[0], "ymin": BBOX[1], "xmax": BBOX[2], "ymax": BBOX[3], "spatialReference": {"wkid": 4326}}) parts, offset = [], 0 while True: # the service returns at most 2000 hexagons per request: page through them r = requests.get(CROME, timeout=180, params={ "where": "1=1", "geometry": geom, "geometryType": "esriGeometryEnvelope", "inSR": 4326, "spatialRel": "esriSpatialRelIntersects", "outFields": "LUCODE,PROB,CROMEID", "outSR": 4326, "orderByFields": "OBJECTID", "resultOffset": offset, "resultRecordCount": 2000, "f": "geojson"}) r.raise_for_status() page = r.json() parts.append(gpd.GeoDataFrame.from_features(page["features"], crs=4326)) if len(page["features"]) < 2000: break offset += 2000 pd.concat(parts, ignore_index=True).to_file(crome_file, driver="GPKG")hexes = gpd.read_file(crome_file).to_crs(CRS)section("CROME 2023 hexagons")print(f"{len(hexes)} hexagons of {hexes.area.median() / 1e4:.2f} ha; " f"median classification probability {hexes['PROB'].median():.2f}")print(hexes["LUCODE"].value_counts().head(10).rename(lambda c: f"{c} {CROPS.get(c, '')}").to_string())
# Dissolve touching hexagons of the same crop into blocks: usually one field, sometimes neighbours with the same crop.print("median probability by crop:", hexes[hexes["LUCODE"].isin(CROPS)].groupby("LUCODE")["PROB"].median() .rename(CROPS).round(2).to_dict())blocks = hexes[hexes["LUCODE"].isin(CROPS)].dissolve("LUCODE").explode(index_parts=False).reset_index()[["LUCODE", "geometry"]]blocks["crop"] = blocks["LUCODE"].map(CROPS)blocks["geometry"] = blocks.buffer(-10) # shrink 10 m so edge pixels (hedges, tracks) drop outblocks = blocks[~blocks.is_empty].explode(index_parts=False)blocks["area_ha"] = blocks.area / 1e4blocks = blocks[blocks["area_ha"] >= MIN_FIELD_HA].sort_values(["crop", "area_ha"], ascending=[True, False])blocks = blocks.reset_index(drop=True)blocks["field_id"] = np.arange(1, len(blocks) + 1)section("crop blocks (fields) used for time series")print(blocks.groupby("crop")["area_ha"].agg(["count", "sum", "median"]).round(1).to_string())
# --- Step 2: search the Sentinel-2 L2A archive ------------------------------------------------------STAC_URL = "https://planetarycomputer.microsoft.com/api/stac/v1"PERIOD = "2021-01-01/2025-12-31"catalog = pystac_client.Client.open(STAC_URL, modifier=planetary_computer.sign_inplace)search = catalog.search(collections=["sentinel-2-l2a"], bbox=BBOX, datetime=PERIOD, query={"eo:cloud_cover": {"lt": 80}, "s2:mgrs_tile": {"eq": "31UCU"}})items = list(search.items())best = {} # a few dates exist twice (original and reprocessed): keep the newest processing baselinefor it in items: key = it.datetime.date() 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 31UCU with < 80% cloud, " f"{items[0].datetime:%d %b %Y} to {items[-1].datetime:%d %b %Y}")print(pd.Series([i.datetime.year for i in items]).value_counts().sort_index().to_dict())
# --- Step 3: a lazy datacube with odc-stac -----------------------------------------------------------odc.stac.configure_rio(cloud_defaults=True) # sensible GDAL settings for reading COGs over HTTPds = odc.stac.load( items, bands=["B02", "B03", "B04", "B08", "SCL"], bbox=BBOX, crs=CRS, resolution=10, chunks={"time": 1, "x": 1024, "y": 1024}, # one chunk per scene and band: nothing is read yet groupby="solar_day", dtype="uint16", nodata=0,)ds = ds.assign_coords(time=ds.time.dt.floor("D")) # one scene per day: keep just the datesection("lazy datacube")print(ds)print(f"{ds.nbytes / 1e9:.2f} GB if loaded in full, in {ds['B04'].data.npartitions} chunks per band")
# --- Step 4: SCL cloud mask, computed first because it is small -----------------------------------KEEP_SCL = [4, 5, 6, 7] # vegetation, bare soil, water, unclassifiedMIN_CLEAR = 0.2 # skip scenes where less than 20% of the area is cleardask.config.set(scheduler="threads", num_workers=16)scl_file = DATA / "scl.nc"if scl_file.exists(): scl = xr.load_dataset(scl_file)["SCL"]else: t0 = time.time() scl = ds["SCL"].compute() scl.to_netcdf(scl_file) print(f"read SCL for {scl.sizes['time']} dates in {time.time() - t0:.0f} s")cloudy = np.isin(scl.values, [3, 8, 9, 10]) # cloud shadow, cloud (medium, high), cirrusgrown = ndimage.binary_dilation(cloudy, iterations=3, axes=(1, 2)) # grow by 30 m: edges are often missedclear = scl.isin(KEEP_SCL) & ~xr.DataArray(grown, coords=scl.coords, dims=scl.dims)clear_frac = clear.mean(("y", "x")).to_series()section("clear-sky fraction per scene")print(f"{(clear_frac >= MIN_CLEAR).sum()} of {len(clear_frac)} dates have at least {MIN_CLEAR:.0%} clear pixels")by_month = clear_frac.groupby(clear_frac.index.month).apply(lambda s: (s >= MIN_CLEAR).mean())print("share of usable scenes by month (%):", (by_month * 100).round().astype(int).to_dict())
# --- Step 5: NDVI, with the processing-baseline offset ----------------------------------------------baseline = pd.Series({pd.Timestamp(i.datetime.date()): float(i.properties["s2:processing_baseline"]) for i in items})offset = xr.DataArray(np.where(baseline.reindex(ds.time.dt.floor("D").values).values >= 4.0, 1000, 0), dims="time", coords={"time": ds.time})
def ndvi_of(sub: xr.Dataset) -> xr.DataArray: """NDVI from L2A digital numbers. Baseline 04.00+ (from 25 Jan 2022) stores reflectance x 10000 + 1000.""" off = offset.sel(time=sub.time).astype("float32") red = (sub["B04"].astype("float32") - off) / 10000 nir = (sub["B08"].astype("float32") - off) / 10000 return ((nir - red) / (nir + red)).where((sub["B04"] > 0) & (sub["B08"] > 0))
keep_times = clear_frac.index[clear_frac >= MIN_CLEAR]ndvi_file = DATA / "ndvi.nc"if ndvi_file.exists(): ndvi = xr.load_dataset(ndvi_file)["ndvi"]else: t0 = time.time() lazy = ndvi_of(ds.sel(time=keep_times)).where(clear.sel(time=keep_times)) print(f"lazy NDVI: {lazy.dtype}, {dict(lazy.sizes)}, {lazy.data.npartitions} chunks") ndvi = lazy.compute().rename("ndvi") ndvi.to_netcdf(ndvi_file) print(f"computed NDVI for {ndvi.sizes['time']} dates in {time.time() - t0:.0f} s")section("NDVI cube")print(f"{dict(ndvi.sizes)}, {float(ndvi.notnull().mean()):.1%} of pixel-dates clear")print(f"NDVI percentiles 5/50/95: {np.nanpercentile(ndvi, [5, 50, 95]).round(3)}")
# --- Step 6: monthly median composites ------------------------------------------------------------------monthly = ndvi.resample(time="1MS").median()n_obs = ndvi.notnull().resample(time="1MS").sum()cover = monthly.notnull().mean(("y", "x"))section("monthly median composites")print(f"{monthly.sizes['time']} months; {int((cover >= 0.95).sum())} have NDVI for at least 95% of pixels, " f"{int((cover == 0).sum())} have none")m23 = monthly.sel(time="2023")table = pd.DataFrame({"median NDVI": [f"{v:.2f}" for v in m23.median(("y", "x")).values], "clear obs/pixel": n_obs.sel(time="2023").median(("y", "x")).values.astype(int), "cover %": (cover.sel(time="2023").values * 100).round().astype(int)}, index=m23.time.dt.strftime("%b").values)print(table.T.to_string())monthly.astype("float32").to_netcdf(OUT / "ndvi_monthly_median_2021_2025.nc")
# --- Step 7: per-field time series ----------------------------------------------------------------------gbox = ds.odc.geoboxlabels = rasterio.features.rasterize(zip(blocks.geometry, blocks["field_id"]), out_shape=gbox.shape, transform=gbox.affine, fill=0, dtype="int32")n_pix = np.bincount(labels.ravel(), minlength=len(blocks) + 1)MIN_FIELD_CLEAR = 0.8 # a field counts as observed on a date only if 80% of its pixels are clearrows = []for t in range(ndvi.sizes["time"]): a = ndvi.isel(time=t).values.ravel() ok = np.isfinite(a) s = np.bincount(labels.ravel()[ok], weights=a[ok], minlength=len(blocks) + 1) c = np.bincount(labels.ravel()[ok], minlength=len(blocks) + 1) frac = c / np.maximum(n_pix, 1) for f in np.nonzero(frac[1:] >= MIN_FIELD_CLEAR)[0] + 1: rows.append((pd.Timestamp(ndvi.time.values[t]).normalize(), f, s[f] / c[f], frac[f]))ts = pd.DataFrame(rows, columns=["date", "field_id", "ndvi", "clear_frac"])ts = ts.merge(blocks[["field_id", "crop", "area_ha"]], on="field_id")ts.round(4).to_csv(OUT / "field_ndvi_timeseries.csv", index=False, date_format="%Y-%m-%d")section("per-field time series")print(f"{len(ts)} field-dates for {ts['field_id'].nunique()} fields; " f"median {ts.groupby('field_id').size().median():.0f} clear observations per field over five years")print(ts.groupby(["crop", ts["date"].dt.year])["ndvi"].count().unstack().to_string())
# --- Step 8: Savitzky-Golay smoothing and phenology ---------------------------------------------------------STEP = "5D" # resample to a regular 5-day grid before filteringWINDOW, ORDER = 9, 2 # 9 x 5 days = 45-day window, quadratic fit
def smooth(series: pd.Series, start: str = "2021-01-01", end: str = "2025-12-31") -> pd.Series: """Upper-envelope Savitzky-Golay smoothing (after Chen et al., 2004) on a regular 5-day grid.""" grid = pd.date_range(start, end, freq=STEP) s = series.groupby(level=0).mean() for _ in range(3): # observations far below the curve are probably cloud or shadow: pull them up filled = s.reindex(s.index.union(grid)).interpolate("time", limit_direction="both").reindex(grid) fit = pd.Series(savgol_filter(filled.values, WINDOW, ORDER), index=grid) on_fit = fit.reindex(s.index, method="nearest") s = s.where(s >= on_fit - 0.05, on_fit) return fit.clip(-0.2, 1)
def phenology(curve: pd.Series, year: int) -> dict: """Green-up, peak and senescence dates where the curve crosses 50% of the seasonal amplitude.""" c = curve[f"{year - 1}-10-01":f"{year}-12-31"] peak = c[f"{year}-03-01":f"{year}-10-31"].idxmax() lo_before, lo_after = c[:peak].min(), c[peak:].min() up = c[:peak][c[:peak] < lo_before + 0.5 * (c[peak] - lo_before)] down = c[peak:][c[peak:] < lo_after + 0.5 * (c[peak] - lo_after)] return {"green_up": up.index[-1] if len(up) else pd.NaT, "peak": peak, "peak_ndvi": c[peak], "senescence": down.index[0] if len(down) else pd.NaT, "amplitude": c[peak] - min(lo_before, lo_after)}
curves = {f: smooth(g.set_index("date")["ndvi"]) for f, g in ts.groupby("field_id") if len(g) >= 40}pheno = pd.DataFrame([dict(field_id=f, **phenology(c, 2023)) for f, c in curves.items()])pheno = pheno.merge(blocks[["field_id", "crop", "area_ha"]], on="field_id")section("2023 phenology by crop (median of fields)")summary = pheno.groupby("crop").agg( fields=("field_id", "size"), green_up=("green_up", lambda d: d.dropna().median().strftime("%d %b")), peak=("peak", lambda d: d.median().strftime("%d %b")), peak_ndvi=("peak_ndvi", "median"), senescence=("senescence", lambda d: d.dropna().median().strftime("%d %b")),).sort_values("peak", key=lambda s: pd.to_datetime(s + " 2023", format="%d %b %Y"))print(summary.round(2).to_string())pheno.round(3).to_csv(OUT / "phenology_2023.csv", index=False, date_format="%Y-%m-%d")
# --- Figures ---------------------------------------------------------------------------------------------------bb = gbox.boundingboxext = [bb.left, bb.right, bb.bottom, bb.top]
def km_axes(ax) -> None: """Label map axes in km from the lower-left corner of the study area.""" xt, yt = np.arange(0, ext[1] - ext[0], 1000), np.arange(0, ext[3] - ext[2], 1000) ax.set_xticks(ext[0] + xt, [f"{v / 1000:.0f}" for v in xt]) ax.set_yticks(ext[2] + yt, [f"{v / 1000:.0f}" for v in yt]) ax.set_xlabel("km")
cmap = plt.get_cmap("Spectral").copy()cmap.set_bad("#d9dcdb")
# 1. study area: true colour on a clear summer day, and the CROME crop blockssummer = clear_frac[(clear_frac.index.month.isin([6, 7])) & (clear_frac.index.year == 2023)]rgb_time = summer.idxmax()rgb_ds = ds[["B02", "B03", "B04"]].sel(time=rgb_time).compute()off = int(offset.sel(time=rgb_time))rgb = np.dstack([(rgb_ds[b].values.astype("float32") - off) / 10000 for b in ("B04", "B03", "B02")])rgb = np.clip(rgb / np.nanpercentile(rgb, 99), 0, 1) ** 0.8section("figures")print(f"true-colour date {pd.Timestamp(rgb_time):%d %b %Y}, {summer.max():.0%} clear")fig, axes = plt.subplots(1, 2, figsize=(11, 5.6), sharey=True)axes[0].imshow(rgb, extent=ext)axes[0].set_title(f"Sentinel-2 true colour, {pd.Timestamp(rgb_time):%d %B %Y}")axes[1].imshow(rgb.mean(axis=2), cmap="gray", extent=ext, vmin=0, vmax=1.4)for crop, g in blocks.groupby("crop"): g.plot(ax=axes[1], color=COLOURS[crop], edgecolor="white", linewidth=0.3)axes[1].set_title(f"CROME 2023 crop blocks of at least {MIN_FIELD_HA} ha")axes[1].legend(handles=[Patch(color=COLOURS[c], label=f"{c} ({(blocks['crop'] == c).sum()})") for c in COLOURS], loc="lower left", fontsize=8, framealpha=0.9)for ax in axes: ax.set_xlim(ext[:2]); ax.set_ylim(ext[2:]) km_axes(ax)axes[0].set_ylabel("km")axes[1].set_ylabel("")fig.savefig(OUT / "01-study-area.png")plt.close(fig)
# 2. how many clear observations: per date and per monthfig, axes = plt.subplots(2, 1, figsize=(10, 5.6), gridspec_kw={"height_ratios": [2, 1.2]})cf = clear_fracaxes[0].scatter(cf.index, cf * 100, s=7, c=np.where(cf >= MIN_CLEAR, "#1d6fa3", "#b0b7b5"))axes[0].axhline(MIN_CLEAR * 100, color="#c21f3a", lw=0.8, ls="--")axes[0].set_ylabel("clear pixels (%)")axes[0].set_title(f"Clear-sky share of the study area on each of {len(cf)} Sentinel-2 dates, 2021–2025 " f"(blue = used, {int((cf >= MIN_CLEAR).sum())} dates)")axes[0].xaxis.set_major_locator(mdates.YearLocator())axes[0].xaxis.set_major_formatter(mdates.DateFormatter("%Y"))usable = cf[cf >= MIN_CLEAR]counts = pd.Series(usable.index.month).value_counts().reindex(range(1, 13), fill_value=0) / 5axes[1].bar(range(1, 13), counts.values, color="#2f9e44")axes[1].set_xticks(range(1, 13), ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"])axes[1].set_ylabel("usable dates\nper year (mean)")for ax in axes: ax.grid(alpha=0.3)fig.tight_layout()fig.savefig(OUT / "02-clear-observations.png")plt.close(fig)
# 3. small multiples: monthly median NDVI in 2023fig, axes = plt.subplots(3, 4, figsize=(11, 9.4))for ax, t in zip(axes.ravel(), m23.time.values): im = ax.imshow(m23.sel(time=t), cmap=cmap, vmin=-0.1, vmax=0.9, extent=ext) nobs = int(n_obs.sel(time=t).median()) ax.set_title(f"{pd.Timestamp(t):%B} ({nobs} clear obs.)", fontsize=8.5) ax.set_xticks([]); ax.set_yticks([])fig.subplots_adjust(wspace=0.04, hspace=0.16, bottom=0.08)cax = fig.add_axes([0.25, 0.04, 0.5, 0.015])fig.colorbar(im, cax=cax, orientation="horizontal", label="NDVI, monthly median, 2023 (grey = no clear observation)")fig.savefig(OUT / "03-monthly-ndvi-2023.png")plt.close(fig)
# 4. one field per crop, raw and smoothed, over five yearsdef most_typical(crop: str) -> int: """The field whose 2023 curve correlates best with the median 2023 curve of its crop.""" fields = pheno.loc[pheno["crop"] == crop, "field_id"] c23 = pd.DataFrame({f: curves[f]["2023"] for f in fields}) return int(c23.corrwith(c23.median(axis=1)).idxmax())
area = blocks.set_index("field_id")["area_ha"]example = {crop: most_typical(crop) for crop in ["Winter wheat", "Sugar beet", "Potato", "Maize"]}print("most typical field per crop:", example)fig, axes = plt.subplots(4, 1, figsize=(10, 8.6), sharex=True)for ax, (crop, f) in zip(axes, example.items()): g = ts[ts["field_id"] == f].set_index("date")["ndvi"] ax.scatter(g.index, g.values, s=8, color="#8a9491", label="clear observations (field mean)") ax.plot(curves[f].index, curves[f].values, color=COLOURS[crop], lw=1.6, label="Savitzky–Golay upper envelope") for y in range(2021, 2026): ax.axvspan(pd.Timestamp(f"{y}-06-01"), pd.Timestamp(f"{y}-08-31"), color="#fff1b8", lw=0, zorder=0) ax.text(0.01, 0.95, f"Field {f}: {crop} in 2023 ({area[f]:.0f} ha, {len(g)} observations)", transform=ax.transAxes, fontweight="bold", color=COLOURS[crop], va="top") ax.set_ylim(-0.15, 1.05) ax.grid(alpha=0.3)axes[0].legend(loc="lower right", fontsize=7.5, ncol=2, framealpha=0.9)axes[2].set_ylabel("NDVI")axes[-1].xaxis.set_major_locator(mdates.YearLocator())axes[-1].xaxis.set_major_formatter(mdates.DateFormatter("%Y"))fig.suptitle("Five years of NDVI for four fields, labelled by their 2023 crop (yellow = June–August)", y=0.995)fig.tight_layout()fig.savefig(OUT / "04-field-time-series.png")plt.close(fig)
# 5. phenology curves by crop in 2023fig, ax = plt.subplots(figsize=(10, 5.2))for crop in summary.index: ids = pheno.loc[pheno["crop"] == crop, "field_id"] stack = pd.concat([curves[f]["2022-10-01":"2023-12-31"] for f in ids], axis=1) q25, q50, q75 = stack.quantile(0.25, axis=1), stack.median(axis=1), stack.quantile(0.75, axis=1) ax.fill_between(q50.index, q25, q75, color=COLOURS[crop], alpha=0.15, lw=0) ax.plot(q50.index, q50, color=COLOURS[crop], lw=2, label=f"{crop} ({len(ids)} fields)") pk = pd.Timestamp(f"{summary.loc[crop, 'peak']} 2023") ax.plot(pk, q50.asof(pk), "o", color=COLOURS[crop], mec="white", ms=7)ax.set_xlim(pd.Timestamp("2022-10-01"), pd.Timestamp("2023-12-31"))ax.xaxis.set_major_locator(mdates.MonthLocator(bymonth=[1, 3, 5, 7, 9, 11]))ax.xaxis.set_major_formatter(mdates.DateFormatter("%b %Y"))ax.set_ylabel("NDVI (smoothed)")ax.set_title("2023 phenology curves: median and interquartile range of fields per crop; dots mark the median peak")ax.legend(fontsize=8, loc="upper left", ncol=2)ax.grid(alpha=0.3)fig.savefig(OUT / "05-phenology-by-crop.png")plt.close(fig)
# 6. crop rotation: monthly maximum NDVI per field, 2021-2025, grouped by 2023 cropmon = ts.groupby(["field_id", pd.Grouper(key="date", freq="1MS")])["ndvi"].max().unstack()mon = mon.reindex(columns=pd.date_range("2021-01-01", "2025-12-01", freq="MS"))order = pheno[pheno["crop"].isin(example)].sort_values(["crop", "area_ha"], ascending=[True, False])mat = mon.loc[order["field_id"]]fig, ax = plt.subplots(figsize=(10, 6.4))im = ax.imshow(np.ma.masked_invalid(mat.values), aspect="auto", cmap=cmap, vmin=-0.1, vmax=0.9, interpolation="nearest")pos = 0for crop, g in order.groupby("crop", sort=False): ax.axhline(pos - 0.5, color="black", lw=0.8) ax.text(-1, pos + len(g) / 2, crop, ha="right", va="center", color=COLOURS[crop], fontweight="bold") pos += len(g)ax.set_yticks([])ax.set_xticks([i * 12 - 0.5 for i in range(5)], [str(y) for y in range(2021, 2026)], ha="left")ax.set_title("Monthly maximum NDVI per field, 2021–2025, grouped by the 2023 crop (grey = no clear observation)")fig.colorbar(im, ax=ax, fraction=0.03, pad=0.01, label="NDVI")fig.savefig(OUT / "06-crop-rotation-heatmap.png")plt.close(fig)
# Animated GIF of the 2023 monthly composites (written to output/, not shown in the article)frames = []for t in m23.time.values: f, a = plt.subplots(figsize=(5, 5.4), dpi=100) a.imshow(m23.sel(time=t), cmap=cmap, vmin=-0.1, vmax=0.9, extent=ext) a.set_title(f"Methwold Fen NDVI, {pd.Timestamp(t):%B %Y}") a.set_xticks([]); a.set_yticks([]) f.canvas.draw() frames.append(np.asarray(f.canvas.buffer_rgba())[..., :3].copy()) plt.close(f)iio.imwrite(OUT / "ndvi_2023.gif", np.stack(frames), duration=700, loop=0)print(f"wrote {len(list(OUT.glob('*.png')))} figures, ndvi_2023.gif ({len(frames)} frames) and the CSV and NetCDF files")