Cloud removes 30–70% of optical acquisitions in most of the world, so a “monthly mean NDVI” is really a mean over whichever days happened to be clear. Before aggregating, fill the gaps deliberately — linear interpolation for short ones, a harmonic fit for long ones — and carry a mask that records what was filled. The mask matters as much as the fill: without it, a pixel reconstructed from two clear scenes is indistinguishable from one observed thirty times.
This page covers gap filling. For the aggregation that follows, see aggregating daily satellite data to monthly features and the wider workflow in Temporal Aggregation for Time Series Geodata.
Why This Fails in Geospatial ML Pipelines
Cloud is not random. It clusters in the growing season, in mountains, and in the tropics — exactly where and when the signal you want is strongest. So a naive mean over available observations is biased in a spatially structured way: dry lowland pixels are averaged over many clear days, humid upland pixels over a handful of unusual clear ones. A model then learns “cloudiness” as if it were a land-cover property, and any spatial cross-validation that separates regions will expose it as an unexplained regional performance gap.
The second failure is chord-cutting. Linear interpolation across a six-week gap draws a straight line between the last pre-gap and the first post-gap observation. If that gap covers the seasonal peak — which it usually does, because peak growth coincides with convective cloud — the reconstructed series has no peak at all. Features derived from it, such as maximum NDVI or the day of maximum, are then systematically wrong in the direction that most reduces class separability.
The third is provenance loss. Once a filled array is written to disk as a plain float raster, nothing distinguishes an observation from an estimate. Downstream aggregation weights them identically, the model treats them identically, and no drift monitor can tell that a region’s predictions degraded because its imagery got cloudier rather than because the world changed.
Core Principles
- Mask before filling. Cloud, shadow, snow and saturated pixels are not data.
- Choose the filler by gap length. Interpolate short gaps; fit a seasonal model across long ones.
- Never extrapolate past the series. Leading and trailing gaps stay
NaN. - Refuse to fill hopeless pixels. Below a minimum valid count, emit
NaNand let the model handle missing. - Emit a companion mask and a valid count. Propagate both through every aggregation.
- Fit the seasonal model per pixel, not per scene. Phenology varies with elevation and crop.
Production-Ready Code
from __future__ import annotations
import logging
import numpy as np
import xarray as xr
logger = logging.getLogger(__name__)
def mask_invalid(da: xr.DataArray, scl: xr.DataArray) -> xr.DataArray:
"""Set cloud, shadow, snow and saturated pixels to NaN using the scene classification."""
bad = scl.isin([0, 1, 3, 8, 9, 10, 11]) # nodata, saturated, shadow, cloud, snow
out = da.where(~bad)
logger.info("masked %.1f%% of observations", 100 * float(bad.mean()))
return out
def harmonic_design(doy: np.ndarray, n_harmonics: int = 2) -> np.ndarray:
"""Design matrix for a seasonal harmonic model: intercept plus sin/cos pairs."""
t = 2 * np.pi * doy / 365.25
cols = [np.ones_like(t)]
for k in range(1, n_harmonics + 1):
cols += [np.sin(k * t), np.cos(k * t)]
return np.column_stack(cols)
def fill_pixel(values: np.ndarray, doy: np.ndarray, max_linear_gap: int = 2,
min_valid: int = 6, n_harmonics: int = 2) -> tuple[np.ndarray, np.ndarray]:
"""Fill one pixel's series. Returns (filled values, filled-flag mask).
Short runs of missing steps are interpolated; longer ones are predicted from a
harmonic fit over the observed steps. Leading and trailing gaps are left NaN.
"""
valid = np.isfinite(values)
filled = values.astype("float64").copy()
was_filled = np.zeros_like(valid)
if valid.sum() < min_valid:
return np.full_like(filled, np.nan), was_filled
# 1. Linear interpolation for interior gaps, applied everywhere first.
idx = np.arange(len(values))
interp = np.interp(idx, idx[valid], values[valid])
# 2. Identify run lengths of missing steps.
gaps = ~valid
runs, start = [], None
for i, g in enumerate(gaps):
if g and start is None:
start = i
elif not g and start is not None:
runs.append((start, i))
start = None
if start is not None:
runs.append((start, len(gaps)))
# 3. Fit harmonics once; use them for the long gaps only.
design = harmonic_design(doy[valid], n_harmonics)
coeffs, *_ = np.linalg.lstsq(design, values[valid], rcond=None)
seasonal = harmonic_design(doy, n_harmonics) @ coeffs
for a, b in runs:
if a == 0 or b == len(values):
continue # never extrapolate
source = interp if (b - a) <= max_linear_gap else seasonal
filled[a:b] = source[a:b]
was_filled[a:b] = True
filled[~valid & ~was_filled] = np.nan
return filled, was_filled
def fill_stack(da: xr.DataArray, max_linear_gap: int = 2,
min_valid: int = 6) -> tuple[xr.DataArray, xr.DataArray]:
"""Apply fill_pixel across a (time, y, x) DataArray, returning values and a mask."""
doy = da["time"].dt.dayofyear.values.astype("float64")
def _apply(series):
out, flag = fill_pixel(series, doy, max_linear_gap, min_valid)
return np.concatenate([out, flag.astype("float64")])
stacked = xr.apply_ufunc(
_apply, da,
input_core_dims=[["time"]], output_core_dims=[["time2"]],
vectorize=True, dask="parallelized",
dask_gufunc_kwargs={"output_sizes": {"time2": 2 * da.sizes["time"]}},
output_dtypes=["float64"],
)
n = da.sizes["time"]
values = stacked.isel(time2=slice(0, n)).rename({"time2": "time"}).assign_coords(time=da.time)
flags = stacked.isel(time2=slice(n, None)).rename({"time2": "time"}).assign_coords(time=da.time)
return values, flags.astype("bool")Step-by-Step Walkthrough
Step 1 — Load and mask
import rioxarray
import xarray as xr
ndvi = xr.open_dataarray("ndvi_2026.nc", chunks={"time": -1, "y": 512, "x": 512})
scl = xr.open_dataarray("scl_2026.nc", chunks={"time": -1, "y": 512, "x": 512})
clean = mask_invalid(ndvi, scl)
valid_count = clean.notnull().sum("time")
print(f"median valid observations per pixel: {float(valid_count.median()):.0f} "
f"of {clean.sizes['time']}")Step 2 — Look at the gap structure before choosing a strategy
import numpy as np
sample = clean.isel(y=slice(0, 200), x=slice(0, 200)).compute()
per_pixel_gaps = (~np.isfinite(sample)).sum("time")
print(f"pixels with zero gaps: {float((per_pixel_gaps == 0).mean()):.1%}")
print(f"pixels below the min_valid floor: {float((valid_count < 6).mean()):.1%}")If a large share of pixels sit below the floor, gap filling is not the right tool — the answer is a longer compositing window or a radar source that sees through cloud.
Step 3 — Fill, and keep the mask
filled, filled_flag = fill_stack(clean, max_linear_gap=2, min_valid=6)
filled = filled.compute()
filled_flag = filled_flag.compute()
print(f"{float(filled_flag.mean()):.1%} of all time steps were reconstructed")Step 4 — Aggregate, carrying provenance
monthly = filled.resample(time="1MS").mean()
monthly_obs = (~filled_flag & filled.notnull()).resample(time="1MS").sum()
monthly_filled_frac = filled_flag.resample(time="1MS").mean()
features = xr.Dataset({
"ndvi_mean": monthly,
"ndvi_n_obs": monthly_obs, # how many REAL observations backed it
"ndvi_filled_frac": monthly_filled_frac,
})
features.to_netcdf("monthly_features.nc")Giving the model ndvi_n_obs alongside ndvi_mean lets it learn to discount reconstructed months — and gives you a covariate that a drift monitor can watch, in the sense described in model drift detection for geospatial inference.
Verification
import numpy as np
def test_short_gap_is_interpolated_linearly():
doy = np.arange(1, 366, 10, dtype="float64")
values = 0.5 + 0.3 * np.sin(2 * np.pi * doy / 365.25)
obs = values.copy()
obs[10] = np.nan # one missing step
filled, flag = fill_pixel(obs, doy, max_linear_gap=2)
assert flag[10] and abs(filled[10] - values[10]) < 0.02
def test_long_gap_uses_the_seasonal_shape():
"""A harmonic fill must recover a peak that linear interpolation would flatten."""
doy = np.arange(1, 366, 10, dtype="float64")
values = 0.3 + 0.4 * np.sin(2 * np.pi * (doy - 80) / 365.25)
obs = values.copy()
peak = np.arange(14, 24)
obs[peak] = np.nan
filled, flag = fill_pixel(obs, doy, max_linear_gap=2)
linear = np.interp(np.arange(len(obs)), np.flatnonzero(np.isfinite(obs)),
obs[np.isfinite(obs)])
assert filled[peak].max() > linear[peak].max(), "harmonic fill should keep the peak"
def test_leading_and_trailing_gaps_stay_nan():
doy = np.arange(1, 366, 10, dtype="float64")
obs = np.full(len(doy), 0.5)
obs[:3] = np.nan
obs[-3:] = np.nan
filled, flag = fill_pixel(obs, doy)
assert np.isnan(filled[:3]).all() and np.isnan(filled[-3:]).all()
assert not flag[:3].any()
def test_hopeless_pixel_returns_all_nan():
doy = np.arange(1, 366, 10, dtype="float64")
obs = np.full(len(doy), np.nan)
obs[:3] = 0.4
filled, flag = fill_pixel(obs, doy, min_valid=6)
assert np.isnan(filled).all() and not flag.any()For a data-level check, hold out a set of clear observations, fill as if they were cloudy, and compare. The residual distribution tells you the real uncertainty of your reconstruction — a number worth reporting alongside the features, and one that is usually much larger than teams expect for long gaps.
FAQ
Should I fill gaps before or after aggregating to monthly features?
Before, provided the month has enough real observations to justify it. Aggregating first hides how much of each month was cloud. Either way, carry a valid-observation count so the model can discount reconstructed months.
Is linear interpolation good enough for a vegetation index?
For gaps of one or two acquisitions, yes. Across weeks it cuts the corner off the phenological curve and under-estimates the seasonal peak — precisely the feature most crop models rely on. Switch to a harmonic fit beyond the crossing point you measure.
How do I stop imputed values from being treated as observations?
Emit a companion mask, propagate it through aggregation, and expose a per-feature observation count. Without it, a pixel reconstructed from two clear scenes looks exactly as reliable as one observed thirty times.
Related
- Temporal Aggregation for Time Series Geodata — the aggregation this feeds
- Resampling Irregular Satellite Time Series with xarray — getting to a regular time axis first
- Masking Clouds Before Computing NDVI — producing the mask this relies on
- Aggregating Daily Satellite Data to Monthly Features — the downstream feature table
Part of: Temporal Aggregation for Time Series Geodata Part of: Spatial Feature Engineering for Machine Learning