Computing Phenology Metrics from NDVI Time Series

Smooth a regular series, then find where it crosses a fraction of its seasonal amplitude:

import numpy as np
from scipy.signal import savgol_filter

s = savgol_filter(ndvi_5day, window_length=7, polyorder=2)   # regular 5-day series
base, peak = np.percentile(s, 5), s.max()
thr = base + 0.5 * (peak - base)
above = np.where(s >= thr)[0]
sos, pos, eos = doy[above[0]], doy[s.argmax()], doy[above[-1]]

Phenology metrics turn a noisy year of observations into a handful of numbers — when growth started, when it peaked, when it ended, and how strong it was. This page belongs to temporal aggregation and time series analysis in Satellite Processing Workflows & Index Pipelines.


The Metrics on One Curve

Start, peak and end of season A smoothed NDVI curve rises from a winter base to a summer peak and falls again. A threshold at half the seasonal amplitude above the base is crossed on the way up at the start of season and on the way down at the end of season. The peak date is the maximum. Season length is the interval between start and end, and integrated NDVI is the area under the curve above the base between them. Phenology metrics from a smoothed series 50% amplitude SOS POS EOS Length = EOS − SOS. Amplitude = peak − base. Integrated NDVI = area above base between SOS and EOS.

A relative threshold — a fraction of each pixel’s own amplitude — is the standard approach because it adapts to land cover. A fixed NDVI threshold of 0.4 would mark the start of season for a lush crop in early spring and never trigger for a sparse rangeland, while half-amplitude marks an equivalent point on both curves.


Environment & Setup

Package Version pin Used for
xarray >=2023.1 Per-pixel series, vectorised metrics
scipy >=1.11 Savitzky–Golay smoothing
numpy >=1.23 Threshold crossing
dask >=2023.1 Running over large areas
pip install "xarray>=2023.1" "scipy>=1.11" "numpy>=1.23" "dask>=2023.1"

Complete Working Example

import numpy as np
import xarray as xr
from scipy.signal import savgol_filter


def regularise(ndvi: xr.DataArray, step: str = "5D", max_gap: str = "30D") -> xr.DataArray:
    reg = ndvi.resample(time=step).max()                     # max favours clear over hazy
    return reg.interpolate_na("time", max_gap=max_gap)


def _metrics_1d(y: np.ndarray, doy: np.ndarray, frac: float) -> np.ndarray:
    out = np.full(6, np.nan, dtype="float32")
    if np.isnan(y).mean() > 0.3:
        return out
    y = np.interp(np.arange(len(y)), np.flatnonzero(~np.isnan(y)), y[~np.isnan(y)])
    s = savgol_filter(y, window_length=7, polyorder=2)
    base, ipk = np.percentile(s, 5), int(np.argmax(s))
    amp = s[ipk] - base
    if amp < 0.1:                                            # no real season
        return out
    thr = base + frac * amp
    up = np.flatnonzero(s[:ipk + 1] < thr)
    down = np.flatnonzero(s[ipk:] < thr)
    if len(up) == 0 or len(down) == 0:
        return out
    i_sos, i_eos = up[-1] + 1, ipk + down[0] - 1
    integ = float(np.trapz(np.clip(s[i_sos:i_eos + 1] - base, 0, None), doy[i_sos:i_eos + 1]))
    out[:] = [doy[i_sos], doy[ipk], doy[i_eos], doy[i_eos] - doy[i_sos], amp, integ]
    return out


def phenology(ndvi: xr.DataArray, frac: float = 0.5) -> xr.Dataset:
    reg = regularise(ndvi)
    doy = reg.time.dt.dayofyear.values.astype("float32")
    res = xr.apply_ufunc(
        _metrics_1d, reg, kwargs={"doy": doy, "frac": frac},
        input_core_dims=[["time"]], output_core_dims=[["metric"]],
        vectorize=True, dask="parallelized", output_dtypes=["float32"],
        dask_gufunc_kwargs={"output_sizes": {"metric": 6}})
    names = ["sos", "pos", "eos", "length", "amplitude", "integrated"]
    return res.assign_coords(metric=names).to_dataset("metric")

The search for the start of season walks backwards from the peak rather than forwards from January. Walking forwards finds the first crossing of the year, which is often a noise spike or a winter cover crop; walking back from the peak finds the crossing that belongs to the main season. The same logic, mirrored, finds the end. Gap filling before smoothing follows filling gaps in NDVI time series with interpolation.


Smoothing Choices and Their Bias

Smoothing that preserves the peak A moving average flattens and broadens the seasonal peak, lowering amplitude and shifting start and end dates outward. A Savitzky–Golay filter fits local polynomials and keeps the peak height and timing close to the underlying curve while removing noise. Around the peak Savitzky–Golay moving average A flattened peak shrinks amplitude and pushes SOS earlier and EOS later.

Residual cloud pulls NDVI down, never up, so noise in these series is one-sided. Taking the maximum within each regularisation step, as the example does, favours the clearest observation; a further refinement is an upper-envelope fit that iteratively replaces values below the smoothed curve with the curve itself, which is how established tools such as TIMESAT treat cloud-contaminated series. Whichever smoother is used, keep it identical across years, because a change of smoother changes every metric.


Two Seasons in One Year

Double-cropped fields, and savannas with two rainy seasons, have two peaks. The single-peak method reports only the larger and computes a season length that may span both. Detecting a second season means finding local maxima separated by a minimum that falls below the threshold, then applying the same crossing logic around each peak. Report a count of seasons as its own layer so users know which pixels carry a second set of metrics. For most temperate applications one season is enough, but in South and Southeast Asia assuming one season silently discards half the agricultural calendar.


Storing and Comparing Years

Phenology metrics become most useful as a multi-year record. Write each year’s metrics as a small multi-band COG — start, peak, end, length, amplitude, integrated NDVI and a season count — with day-of-year stored as int16 and a nodata value outside the valid range. Comparing a year against the average of previous years then reveals early or late springs, shortened seasons in drought years, and fields whose management changed. Because every metric depends on the smoother, threshold fraction and regularisation step, record those settings in the file’s tags; a two-day shift in start of season is meaningless if the method changed between years, so treat the settings as part of the data itself.


Verification

Sanity checks on phenology layers Start of season must precede peak, which must precede end. Season length should fall within a plausible range for the land cover. Neighbouring pixels of the same field should give similar dates, so a speckled start-of-season map indicates noise. Three checks before trusting the metrics ordering SOS < POS < EOS plausible length e.g. 60–250 days coherent fields low within-field spread A speckled SOS map is a noise map, not a phenology map.
import numpy as np

ph = phenology(ndvi_2025).compute()
ok = ph.sos.notnull()
assert bool(((ph.sos < ph.pos) & (ph.pos < ph.eos)).where(ok, True).all())
print("median length", float(ph.length.median()), "days;",
      "pixels with a season", f"{float(ok.mean()):.1%}")

Common Errors

Start of season in January for every pixel

The search runs forward from the start of the year and finds noise. Search backwards from the peak.

Amplitude is lower than expected

A moving-average smoother flattened the peak. Use Savitzky–Golay or an upper-envelope fit.

Many pixels have no season

The amplitude floor is too high for sparse vegetation, or too many observations are missing. Lower the floor or lengthen the gap-fill limit.

Seasons that cross New Year are cut in half

Southern-hemisphere and winter crops span two calendar years. Run on a July-to-June window for those regions.


Frequently Asked Questions

Q: What threshold should define start of season? Half the seasonal amplitude is the most common choice and is robust. Lower fractions such as 20% find earlier green-up but are more sensitive to noise.

Q: Why smooth before extracting metrics? Raw series contain cloud dips and noise that create false crossings. Smoothing gives a curve with one clear rise and fall per season.

Q: How do I handle seasons that cross the new year? Shift the analysis window — for example July to June — so the whole season falls inside one window, and convert day-of-year accordingly.

Q: Is integrated NDVI a measure of productivity? It is a widely used proxy for seasonal productivity, since it combines how green and how long. It is relative, not an absolute biomass measurement, unless calibrated against field data.