Detecting Seasonal Trends with Rolling Windows

To separate a long-term trend from the seasonal cycle, roll a one-year centred window over a regular series:

import xarray as xr

regular = ndvi.resample(time="10D").mean()          # 36-37 steps per year
trend = regular.rolling(time=37, center=True, min_periods=30).mean()
anomaly = regular - regular.groupby("time.dayofyear").mean("time")

Rolling windows are the simplest useful tool in Temporal Aggregation and Time-Series Analysis, and the one most often applied without checking what the window actually spans.


Why This Arises in Remote Sensing Workflows

A vegetation index series is dominated by its seasonal cycle. Anything you want to know about degradation, recovery, intensification or drought is a small signal sitting on top of a large annual oscillation, and looking at the raw series tells you mostly about the calendar.

Two operations separate the two. A rolling mean over exactly one year averages a complete cycle at every point, so the seasonal component cancels and what remains is trend plus residual. A day-of-year climatology, subtracted from the series, does the reverse: it removes the typical seasonal shape and leaves the departure from it, which is what a monitoring product usually wants to alert on.

Both are one-liners, and both have three parameters that decide whether the result means anything: the step of the underlying axis, the window length in steps, and the minimum number of contributing observations. Getting any of them wrong produces a smooth, plausible curve that describes the method rather than the vegetation.

Season, trend and anomaly from one series The raw series oscillates annually with a slow downward drift. A one-year centred rolling mean removes the oscillation and exposes the drift. Subtracting the day-of-year climatology instead removes the drift's shape and leaves departures, where a drought year appears as a sustained negative excursion. Three views of the same three years raw series — season dominates 1-year rolling mean — the trend anomaly — a sustained negative excursion in year 2 (drought) 0 year 1 year 2 year 3

Environment & Setup

Package Version Why
xarray ≥2023.6 rolling, groupby, resample
numpy ≥1.23 Trend fitting
pandas ≥2.0 Time axis handling
dask ≥2023.5 Optional: chunked evaluation over large cubes
pip install "xarray>=2023.6" "pandas>=2.0" "dask>=2023.5"

Complete Working Example

This function builds the three layers a monitoring product needs — smoothed series, trend, anomaly — with the parameters made explicit and the contributing-observation count carried through.

Which rolling statistic answers which question A rolling mean over one year exposes trend. A rolling minimum tracks the worst condition in each window, which suits drought monitoring. A rolling standard deviation tracks variability, an early stress signal. A day-of-year anomaly answers whether conditions depart from normal. Pick the statistic from the question, not the habit question statistic window is there a multi-year trend? rolling mean exactly one year, centred how bad did it get? rolling minimum 2–3 months is the system becoming unstable? rolling standard deviation one year, centred is this month unusual? day-of-year anomaly fixed baseline period Rising variability with a flat mean is a stress signal the mean alone cannot show.
import numpy as np
import xarray as xr


def seasonal_decompose(
    ndvi: xr.DataArray,
    *,
    step_days: int = 10,
    smooth_steps: int = 3,
    trend_days: int = 365,
    min_fraction: float = 0.8,
) -> xr.Dataset:
    """Smoothed series, one-year trend and day-of-year anomaly, with counts."""
    step = f"{step_days}D"
    regular = ndvi.resample(time=step).mean(skipna=True)
    counts = ndvi.resample(time=step).count()

    trend_steps = max(3, round(trend_days / step_days))
    if trend_steps % 2 == 0:
        trend_steps += 1                       # odd window so centring is symmetric

    smoothed = regular.rolling(
        time=smooth_steps, center=True,
        min_periods=max(1, int(smooth_steps * min_fraction))).mean()

    trend = regular.rolling(
        time=trend_steps, center=True,
        min_periods=int(trend_steps * min_fraction)).mean()

    climatology = regular.groupby("time.dayofyear").mean("time")
    anomaly = regular.groupby("time.dayofyear") - climatology

    out = xr.Dataset({
        "smoothed": smoothed.astype("float32"),
        "trend": trend.astype("float32"),
        "anomaly": anomaly.astype("float32"),
        "n_obs": counts.astype("int16"),
    })
    out.attrs.update(step=step, trend_window_steps=trend_steps,
                     trend_window_days=trend_steps * step_days,
                     min_periods_fraction=min_fraction)
    return out


def linear_trend(series: xr.DataArray, dim: str = "time") -> xr.Dataset:
    """Least-squares slope per pixel, in index units per year."""
    years = (series[dim] - series[dim][0]) / np.timedelta64(365, "D")
    fit = series.polyfit(dim=dim, deg=1, skipna=True)
    slope = fit.polyfit_coefficients.sel(degree=1) * np.timedelta64(365, "D") / np.timedelta64(1, "ns")
    return xr.Dataset({"slope_per_year": slope.astype("float32")})


if __name__ == "__main__":
    parts = seasonal_decompose(ndvi_cube, step_days=10, trend_days=365)
    print(parts.attrs)
    print("trend window spans", parts.attrs["trend_window_days"], "days")

    # A pixel is only worth interpreting where enough observations contributed
    reliable = parts["n_obs"].sum("time") > 40
    print("pixels with a usable record:", float(reliable.mean()))

Deriving trend_steps from the step size rather than hard-coding a number is what keeps the window meaning one year when someone later changes the resampling step from ten days to five — the single most common way a decomposition quietly stops removing the season.


Variant Patterns

1. Sizing the window deliberately

Window length decides what survives A window of a few weeks removes acquisition noise and keeps the seasonal cycle. A window of a season smooths the cycle's shape and is rarely what anyone wants. A one-year window removes the cycle entirely and exposes the trend. A multi-year window also smooths the trend and should be avoided unless the record is long. Rolling window length and what it removes window removes use for 2–4 weeks acquisition noise cleaning a series for plotting 2–4 months the cycle's shape, partly rarely the right answer exactly 1 year the seasonal cycle exposing a multi-year trend 3+ years the trend as well only with a decade of record The window is in steps, not days — recompute it whenever the resampling step changes.

2. Anomalies against a fixed baseline period

Computing the climatology from the same series that is being analysed hides a trend inside the baseline. For monitoring, define the baseline from a fixed historical period instead.

baseline = regular.sel(time=slice("2017-01-01", "2021-12-31"))
climatology = baseline.groupby("time.dayofyear").mean("time")

anomaly = regular.groupby("time.dayofyear") - climatology     # departures from a fixed normal

A fixed baseline makes anomalies comparable between years and between reruns, which a rolling baseline does not; the trade-off is that the definition of “normal” has to be stated and periodically revisited.

3. Rolling statistics other than the mean

The mean is not always the right summary. A rolling minimum tracks the worst condition in each window, which suits drought monitoring; a rolling standard deviation tracks variability, which is a useful degradation indicator in its own right.

worst = regular.rolling(time=9, center=True, min_periods=6).min()
variability = regular.rolling(time=37, center=True, min_periods=30).std()

Increasing variability with a flat mean is a well-known early signal of stress, and it is invisible in the mean alone.


Reading the Result Carefully

Three checks stop a decomposition from being over-read.

Look at the observation count alongside the trend. A rolling mean computed from three observations in a 37-step window is not a trend, and without min_periods xarray will happily return it. Plotting n_obs under the trend line makes thin sections obvious.

Check the ends of the record. Centred windows cannot be filled at the start and end, so those regions are either NaN — correct — or, if min_periods is too permissive, computed from partial windows and biased. A trend that turns sharply in the final months is almost always this artefact rather than a real change.

Separate trend from gap structure. If cloud cover is seasonal — which it usually is — then the observations are not evenly distributed through the year, and a rolling mean over a year weights the clear season more heavily. Where that matters, compute the trend on the anomaly series instead of on the raw one, because anomalies are already referenced to their own time of year. The gap-handling that precedes this is covered in Filling Gaps in NDVI Time Series with Interpolation.


Common Errors

The trend line still oscillates annually

The window is not exactly one year in duration. Compute the number of steps from the resampling step rather than assuming, and make it odd so centring is symmetric.

Memory blows up on a large cube

Rolling over a chunked array with the time axis split forces cross-chunk communication. Chunk with time: -1 and split space instead, as described in Tuning Dask Chunk Sizes for Raster Cubes.

The anomaly has a seasonal signal in it

The climatology was computed over too few years, so it carries the noise of individual seasons. Smooth it across day-of-year, or use a longer baseline.


Frequently Asked Questions

Q: What window length removes the seasonal cycle? Exactly one year. A 365-day centred mean averages one full cycle at every point, so what remains is trend plus noise. Any shorter window leaves seasonal residual, and any longer one starts smoothing the trend you are trying to see.

Q: Should the window be centred? For retrospective analysis, yes — centring avoids shifting features in time. For near-real-time monitoring you cannot centre, because future observations do not exist, and a trailing window necessarily lags.

Q: Why does my trend line dive at the ends? Edge effects: near the start and end of the record the window is only partly filled. Set min_periods so partial windows return NaN rather than a mean computed from a handful of points.