How to extract a Sentinel-1 time series for an area

On this page

Problem statement

A single radar image says how bright a surface is; a time series says what it is doing. Rice paddies drown and drain, cereals grow and are cut, reservoirs rise and fall, and each leaves a shape in backscatter that a single date cannot show. Building that series from a catalogue is where the work is: the frames have to be grouped into passes, the passes kept to one relative orbit, the values averaged over the area in linear units, and the result labelled with enough metadata to be reproducible.

Over a block of rice fields near Sueca, south of the Albufera, a year of Sentinel-1 VV from one descending orbit runs from −17.4 dB when the fields are under water to −7.0 dB at the height of the canopy: a ten-decibel swing, measured every twelve days through a season when optical imagery is repeatedly lost to cloud.

Quick answer

Group into passes, keep one orbit, and average linear values inside the polygon:

import numpy as np, pandas as pd, geopandas as gpd
from rasterio.features import geometry_mask
from sar_io import search, passes, grid, read_pass, to_db

bbox = [-0.345, 39.255, -0.300, 39.285]             # rice fields near Sueca, south of the Albufera
g = grid(bbox, "EPSG:25830")
area = gpd.GeoSeries.from_wkt(
    ["POLYGON((-0.334 39.262, -0.313 39.262, -0.313 39.278, -0.334 39.278, -0.334 39.262))"],
    crs=4326).to_crs(g["crs"])
inside = ~geometry_mask(area, out_shape=(g["height"], g["width"]),
                        transform=g["transform"], invert=False)

rows = []
for (day, orbit), frames in passes(search(bbox, "2024-01-01/2024-12-31", orbit=110)).items():
    vv, vh = read_pass(frames, "vv", g), read_pass(frames, "vh", g)
    rows.append({"date": pd.Timestamp(day), "orbit": orbit,
                 "vv_db": to_db(np.nanmean(vv[inside])), "vh_db": to_db(np.nanmean(vh[inside])),
                 "pixels": int(np.isfinite(vv[inside]).sum())})
ts = pd.DataFrame(rows).set_index("date")
print(f"{len(ts)} passes; VV from {ts.vv_db.min():.1f} to {ts.vv_db.max():.1f} dB")
print(ts.head(3).round(2).to_string())
26 passes; VV from -17.4 to -7.0 dB
            orbit  vv_db      vh_db  pixels
date                                       
2024-01-06    110 -11.80 -21.590000   32214
2024-01-18    110 -12.68 -21.639999   32214
2024-02-23    110  -8.59 -18.209999   32214

Averaging inside the polygon in linear units and converting once at the end is what keeps the series comparable with published figures.

Two radar images of rice fields near Sueca with the study polygon outlined: on 1 November 2024 the fields are black, flooded after the storm; on 21 August 2024 they are bright grey under a full crop. Two radar images of rice fields near Sueca with the study polygon outlined: on 1 November 2024 the fields are black, flooded after the storm; on 21 August 2024 they are bright grey under a full crop.
The lowest and highest passes of the 26 in the series. On 21 August the rice is fully grown and the polygon averages −7.0 dB; on 1 November, three days after the flood, the same fields are open water at −17.4 dB. The 10 dB range of the time series is those two states, and everything else in the year sits between them.
Monthly mean VV and VH backscatter over rice fields near Sueca through 2024. rice near Sueca, orbit 110, 2024-25-20-15-10JFMAMJJASONDflooded for sowingwinter floodingcanopydBDark line VV, light line VH; monthly means of 26 passes on one descending orbit spatialworkflow.io
One polygon, one orbit, a year: flooding, growth, harvest and re-flooding are all visible.

A grid around the polygon, one orbit, linear means

1. Choose the polygon and a grid around it

Read onto a grid covering the polygon's bounding box, with a small margin, so every pass lands on the same pixels.

2. Keep to one relative orbit

Different orbits differ by a decibel or more on the same surface. Mixing them puts a sawtooth in the series that looks like a signal.

3. Average in linear power

Take the mean of linear values inside the polygon, then convert to decibels once, as the decibel guide explains.

4. Record the pixel count

A pass whose frames cover only part of the polygon gives a mean over fewer pixels. Store the count and drop passes far below the usual.

5. Keep both polarisations and the ratio

VV responds to surface and water, VH to canopy volume. Their ratio often separates growth stages better than either.

6. Interpret with a calendar

Radar sees structure and water, not crop type. A curve becomes an interpretation only alongside what is known about the local cropping calendar.

Mean VV over the same rice polygon from four relative orbits in July 2024. same fields, same month, four orbitsorbit 103, ascending−8.5 dBorbit 110, descending−8.6 dBorbit 30, ascending−9.2 dBorbit 8, descending−10.0 dBBars show |dB|; 1.4 dB separates the orbit means, 1.9 dB the individual passes spatialworkflow.io
The same fields in July 2024: 1.9 dB between the brightest and darkest pass, with no change on the ground.

A year of backscatter over rice fields

The data used on this page

Sentinel-1 radiometrically terrain-corrected backscatter (sentinel-1-rtc) from the Microsoft Planetary Computer, read through sar_io.py — the small module from the reading guide that searches, groups frames into passes and mosaics each pass onto a fixed grid.

%%writefile sar_io.py
"""Find, read and mosaic Sentinel-1 RTC scenes from Microsoft Planetary Computer."""
from collections import defaultdict

import numpy as np
import planetary_computer
import pystac_client
import rasterio
from rasterio.enums import Resampling
from rasterio.transform import from_origin
from rasterio.vrt import WarpedVRT
from rasterio.warp import transform_bounds

STAC = "https://planetarycomputer.microsoft.com/api/stac/v1"


def search(bbox, dates, orbit=None, direction=None, collection="sentinel-1-rtc"):
    """Items over bbox (lon/lat) in a date range, filtered by relative orbit and pass direction."""
    cat = pystac_client.Client.open(STAC, modifier=planetary_computer.sign_inplace)
    items = cat.search(collections=[collection], bbox=bbox, datetime=dates).item_collection()
    keep = [i for i in items
            if (orbit is None or i.properties["sat:relative_orbit"] == orbit)
            and (direction is None or i.properties["sat:orbit_state"] == direction)]
    return sorted(keep, key=lambda i: i.datetime)


def passes(items):
    """Group frames into passes: same day, same relative orbit."""
    groups = defaultdict(list)
    for i in items:
        groups[(i.datetime.date(), i.properties["sat:relative_orbit"])].append(i)
    return dict(sorted(groups.items()))


def grid(bbox, crs, res=10):
    """A fixed target grid: every pass is read onto this, so arrays line up."""
    left, bottom, right, top = transform_bounds(4326, crs, *bbox)
    width, height = int((right - left) // res), int((top - bottom) // res)
    return {"crs": crs, "transform": from_origin(left, top, res, res),
            "width": width, "height": height}


def read_pass(frames, band, g):
    """Mosaic one pass's frames for one band onto grid g. Linear power; NaN where no data."""
    out = np.full((g["height"], g["width"]), np.nan, dtype="float32")
    for item in frames:
        with rasterio.open(item.assets[band].href) as src, WarpedVRT(
                src, crs=g["crs"], transform=g["transform"], width=g["width"],
                height=g["height"], resampling=Resampling.bilinear, nodata=np.nan) as vrt:
            a = vrt.read(1)
        a[a <= 0] = np.nan                       # -32768 nodata and zero-padding
        fill = np.isnan(out) & np.isfinite(a)
        out[fill] = a[fill]
    return out


def to_db(linear):
    return 10 * np.log10(linear)
Overwriting sar_io.py

Example 1 — the shape of the year

monthly = ts.resample("MS").mean(numeric_only=True)
for month, row in monthly.iterrows():
    bar = "#" * int((row.vv_db + 20) * 2)
    print(f"{month:%Y-%m}  VV {row.vv_db:6.1f} dB  VH {row.vh_db:6.1f} dB  {bar}")
2024-01  VV  -12.2 dB  VH  -21.6 dB  ###############
2024-02  VV   -8.6 dB  VH  -18.2 dB  ######################
2024-03  VV   -9.0 dB  VH  -18.4 dB  ######################
2024-04  VV   -9.7 dB  VH  -18.3 dB  ####################
2024-05  VV  -13.1 dB  VH  -21.3 dB  #############
2024-06  VV  -10.2 dB  VH  -19.0 dB  ###################
2024-07  VV   -8.6 dB  VH  -17.1 dB  ######################
2024-08  VV   -7.9 dB  VH  -14.6 dB  ########################
2024-09  VV   -7.9 dB  VH  -15.2 dB  ########################
2024-10  VV   -9.0 dB  VH  -16.2 dB  ######################
2024-11  VV  -14.8 dB  VH  -23.2 dB  ##########
2024-12  VV   -9.5 dB  VH  -19.6 dB  ####################

The monthly means trace the paddy calendar: dips in May and November, when the fields are deliberately flooded, and a peak in August and September, when the canopy is at its fullest. In monthly means VH swings further than VV, 8.6 dB against 6.9, because the canopy depolarises; over individual passes both span about 10.5 dB.

Example 2 — why one orbit only

month = passes(search(bbox, "2024-07-01/2024-07-31"))
for (day, orbit), frames in month.items():
    vv = read_pass(frames, "vv", g)
    print(f"{day} orbit {orbit:3d} {frames[0].properties['sat:orbit_state']:10} "
          f"VV {to_db(np.nanmean(vv[inside])):6.2f} dB")
2024-07-03 orbit 103 ascending  VV  -8.34 dB
2024-07-04 orbit 110 descending VV  -8.08 dB
2024-07-09 orbit   8 descending VV  -9.95 dB
2024-07-10 orbit  30 ascending  VV  -9.65 dB
2024-07-15 orbit 103 ascending  VV  -8.51 dB
2024-07-16 orbit 110 descending VV  -8.42 dB
2024-07-22 orbit  30 ascending  VV  -8.76 dB
2024-07-27 orbit 103 ascending  VV  -8.78 dB
2024-07-28 orbit 110 descending VV  -9.17 dB

Example 3 — save it with its provenance

ts["vh_vv_db"] = ts.vh_db - ts.vv_db
ts.attrs = {"collection": "sentinel-1-rtc", "relative_orbit": 110, "polygon_crs": str(g["crs"]),
            "statistic": "mean of linear gamma0 inside the polygon, converted to dB"}
ts.round(3).to_csv("rice_vv_vh_2024_orbit110.csv")
print(ts[["vv_db", "vh_db", "vh_vv_db", "pixels"]].describe().round(2).to_string())
       vv_db  vh_db  vh_vv_db   pixels
count  26.00  26.00     26.00     26.0
mean   -9.91 -18.39     -8.49  32214.0
std     2.44   2.86      1.32      0.0
min   -17.41 -24.41    -11.19  32214.0
25%   -10.03 -19.65     -9.55  32214.0
50%    -9.19 -18.29     -8.67  32214.0
75%    -8.47 -15.91     -7.30  32214.0
max    -7.05 -13.65     -6.55  32214.0

Rice as the clearest example

Why rice is the clearest example

A flooded paddy is a mirror: much of the pulse is reflected away, and VV drops towards the values of open water. As the crop grows, stems and leaves scatter, and both channels rise — VH fastest, because the canopy depolarises. When the fields are drained and cut, the series falls again. Few land covers move through such a wide range in a year, which is why rice mapping was one of the first operational uses of radar.

Why the mean, and what it hides

The mean over a polygon is a convenient summary; it is also blind to the difference between all fields changing a little and a few changing a lot. Keeping a percentile or two — the 10th and 90th within the polygon — costs nothing and shows whether the fields are moving together.

Why the series still needs the metadata

Backscatter has no absolute meaning without the geometry it was measured in. A published series that does not state the collection, the orbit, the polarisation, the statistic and the polygon cannot be compared with any other — and cannot be reproduced next year by its own author.

The rice season near Sueca as Sentinel-1 backscatter sees it. the season in backscatterwinter floodingJan: −12.2 dBdrainedFeb–Apr: −9 dBflooded forsowingMay: −13.1 dBcanopyAug–Sep: −7.9 dBre-floodedNov: −14.8 dBMonthly means; the curve is a calendar once you know which calendar it belongs to spatialworkflow.io
The curve is a calendar, once you know which calendar it belongs to.

Partial coverage, wind and mixed polygons

  • Partial coverage gives a mean over part of the polygon; track the pixel count.
  • Wind on flooded fields raises VV by several decibels for one date.
  • Mixed polygons — half rice, half orchard — average into a curve that describes neither.
  • Border pixels mix in roads and canals; erode the polygon by a pixel or two.
  • Sentinel-1C adds passes from 2025; a series crossing that boundary changes sampling rate.
  • Coherence time series from SLC add information intensity does not carry.

FAQ

How do I build a Sentinel-1 time series for a field?

Search the RTC collection, group frames into passes, keep one relative orbit, and average linear backscatter inside the polygon per pass.

Why must I keep to one orbit?

Different orbits have different look directions and incidence angles, which shifts backscatter by a decibel or more.

Should I average in dB?

No. Average linear values and convert the mean.

How often do I get a value?

Every 12 days per orbit with one satellite; more often if you accept several orbits as separate series.

What does a drop of 10 dB mean over farmland?

Usually flooding or irrigation; a drop that large is water appearing.

Can I compare my curve with a published one?

Only if both state collection, orbit, polarisation and statistic.