agriculture

Timeseries Crop Trend Analysis

2025-10-08 · 3 min · Advanced

What you’ll build

You’ll create a Python workflow to analyze crop trends over time using ClearSKY’s cloud-free Sentinel-2 mosaics. This includes loading weekly (or daily) mosaics, masking out NoData, extracting NDVI for a defined field or plot, and visualizing crop health changes through time. The workflow is robust for operational agriculture monitoring, leveraging cloud- and shadow-free, BoA-equivalent data for clean temporal analytics.

The timeseries approach is invaluable for:

  • Early detection of crop stress or abnormal change
  • Monitoring seasonal progression
  • Building up data for later yield modeling or anomaly detection

Requirements

  • Python 3.8+
  • rasterio
  • xarray and rioxarray
  • numpy
  • matplotlib

Install all requirements:

pip install rasterio xarray rioxarray numpy matplotlib
  • Obtain ClearSKY sample data by requesting the Agriculture sample pack at /developers.
    You may substitute clean, cloud-free original Sentinel-2 L2A imagery, ensuring correct band order (B10/cirrus absent).

Load sample data

You’ll need a stack of ClearSKY mosaics, covering the same AOI (tile) over several dates. Each file is a Sentinel-2 L2A-like GeoTIFF with:

  • Resolution: 10 m across all bands
  • No clouds and no cloud shadows – quality-masked in fusion
  • Band order: Sentinel-2 L2A (B10/cirrus absent)
  • NoData: only outside AOI, value -32768
  • Bands: int16 reflectance, scale ÷10000 to [0,1]

Place each GeoTIFF in a folder, naming them e.g.,

  • clearsky_AOI_20240401.tif
  • clearsky_AOI_20240408.tif
  • clearsky_AOI_20240415.tif
  • ...

Prepare a field boundary (optional)

Clip to a single field by providing a shapefile, GeoJSON, or even simple pixel coordinates.

Core logic

This recipe loads the ClearSKY stacks, extracts NDVI for each date, masks NoData, and plots NDVI evolution for the field.

Helper functions

Use these helpers for proper scaling and masking:

import numpy as np
NODATA = -32768

def read_band_reflectance(ds, idx):
    a = ds.read(idx).astype("float32")
    nodata = ds.nodata if ds.nodata is not None else NODATA
    a = np.where(a == nodata, np.nan, a)
    # Sentinel-2 reflectance is scaled by 10000; convert to [0,1]
    if np.nanmax(a) > 1.0:
        a = a / 10000.0
    return a

def image_date_from_tags(ds):
    v = ds.tags().get("IMAGE_DATE")
    if v and len(v) == 8:
        return f"{v[0:4]}-{v[4:6]}-{v[6:8]}"
    return None

Reading and analyzing the time stack

Assuming all GeoTIFFs are in ./clearsky_timeseries/:

import glob
import rasterio
import numpy as np
import matplotlib.pyplot as plt

folder = './clearsky_timeseries/'
filelist = sorted(glob.glob(folder + '*.tif'))
print(f"Found {len(filelist)} mosaics.")

ndvi_list = []
dates = []

for fname in filelist:
    with rasterio.open(fname) as ds:
        print(f"Processing {fname} ...")
        # Sentinel-2 L2A order; NIR=B08 (idx=8), RED=B04 (idx=4)
        nir = read_band_reflectance(ds, 8)
        red = read_band_reflectance(ds, 4)
        # NDVI = (NIR - RED) / (NIR + RED), avoid zero divide
        ndvi = np.where((nir + red) > 0, (nir - red) / (nir + red), np.nan)
        # Mask field region if desired (e.g., use a binary mask array)
        ndvi_list.append(ndvi)
        # Extract date from ClearSKY tag
        date_str = image_date_from_tags(ds)
        dates.append(date_str)

(Optional) Add a spatial mask for your field

This can be a binary numpy array with shape matching your mosaic, where True marks the area-of-interest; e.g.,

# For demonstration: select a center 50x50 window
field_mask = np.zeros_like(ndvi, dtype=bool)
ysize, xsize = ndvi.shape
field_mask[ysize//2-25:ysize//2+25, xsize//2-25:xsize//2+25] = True

To apply to each NDVI:

# Apply mask after NDVI computation
mean_ndvi_timeseries = [np.nanmean(nd[ field_mask ]) for nd in ndvi_list]

If working with entire AOI, just take np.nanmean(ndvi) across each image.

Visualize/Export

Plot NDVI trend

plt.figure(figsize=(10,5))
plt.plot(dates, mean_ndvi_timeseries, marker='o', linewidth=2)
plt.xlabel('Date')
plt.ylabel('Mean NDVI')
plt.title('Crop NDVI Timeseries (ClearSKY, cloud-free)')
plt.xticks(rotation=45)
plt.grid(True)
plt.tight_layout()
plt.show()

Show NDVI spatial maps for select dates

import matplotlib.cm as cm
for i, ndvi in enumerate(ndvi_list[::len(ndvi_list)//3]):  # Pick 3 dates
    plt.figure()
    plt.imshow(ndvi, cmap=cm.YlGn, vmin=0, vmax=1)
    plt.title(f'NDVI map ({dates[::len(ndvi_list)//3][i]})')
    plt.colorbar(label='NDVI')
    plt.tight_layout()
plt.show()

Troubleshooting

  • If you see all-NaN NDVI values, ensure you’re masking only for NoData (-32768)—not valid zero reflectance.
  • When band values seem too large or small, recheck that you’ve properly scaled: int16 reflectance ÷10000 → [0,1].
  • NDVI outside expected [-1,+1]? Clip manually if necessary; if you use ClearSKY’s precomputed NDVI from API, scale by ÷32767 → [-1,1].
  • If arrays don't match, check that all images use the same AOI/extent and projection—ClearSKY sample packs are pre-mosaicked/gridded.
  • Daily–weekly cadence means there may be small date gaps.

Next steps

  • Expand to multiple fields and plot multiple timeseries per field or crop type.
  • Compute rolling averages or detect anomalies (e.g., with scipy.signal.find_peaks).
  • Export trend results as CSV for downstream use.
  • Integrate with other indices: NDWI, EVI, etc. (see docs.clearsky.vision for details).
  • For API access and automation, see api.clearsky.vision.

Ready to scale? To ingest Agriculture sample packs or subscribe to production mosaics, see /developers.