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.
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.
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
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.
Related
- Temporal Aggregation and Time-Series Analysis — the parent topic, including chunking for temporal reductions.
- Filling Gaps in NDVI Time Series with Interpolation — preparing the series this analysis consumes.
- Change Detection and Differencing Workflows — the two-date alternative, and when it is the better tool.
- Creating Monthly NDVI Composites with xarray resample — building the regular axis these windows assume.