Use cases
cdse-client finds, downloads and prepares Sentinel data. The analysis after that is yours.
This page shows five complete workflows, one per persona. Each is a runnable script in
examples/. Every script runs
with no arguments, writes to ./output/<example>/, and keeps the downloads in
./output/<example>/downloads/, so a second run skips the download.
All five need CDSE_CLIENT_ID and CDSE_CLIENT_SECRET (see Getting started).
Tested offline only
The scripts were run end to end against synthetic products that follow the real product layout, not against the live CDSE API. The assumptions this leaves open are listed in Not yet verified against the live API.
Agronomist: NDVI through a growing season
A field is a few hundred pixels inside a 110 km tile, and half the dates of a season are cloudy over it. The goal is one clean NDVI curve from sowing to harvest, with the cloudy dates skipped and the reason recorded, instead of averaging clouds in.
What you get (in ./output/agri_ndvi_timeseries/)
ndvi_timeseries.csv: one row per selected date, with the clear fraction over the field, NDVI mean, p10 and p90, pixel count, and astatuscolumn that says why a date was skippedndvi_timeseries.png: mean NDVI with the p10-p90 band, and skipped dates as grey ticksdownloads/: the L2A ZIPs
Run
(agri_ndvi_timeseries.py)
pip install "cdse-client[processing]" matplotlib
python examples/agri_ndvi_timeseries.py
# = --geojson examples/data/field_lombardy.geojson --start 2025-04-01 --end 2025-09-30
# --max-cloud 60 --min-clear 0.7 --every-days 7 --limit 200
The script keeps the least cloudy product in each 7-day window. It crops B08 and B04 to the field, puts the 20 m SCL onto the same 10 m grid, and masks the pixels that SCL does not classify as clear:
geom = transform_geom("EPSG:4326", crs, geometry)
in_field = geometry_mask([geom], out_shape=scl.shape, transform=transform, invert=True)
n_field = int(in_field.sum())
if n_field == 0:
raise ValueError("the field polygon covers no pixel of the product")
# DN 0 is L2A no-data (tile edge); SCL class 0 usually flags it too.
clear = in_field & ~np.isin(scl, BAD_SCL) & (b08 > 0) & (b04 > 0)
clear_fraction = float(clear.sum()) / n_field
# reflectance = (DN + BOA_ADD_OFFSET) / 10000, and the offset is negative.
offset = boa_offset(zip_path) if apply_offset else 0.0
if offset:
b08 = np.clip(b08 + offset, 0, None)
b04 = np.clip(b04 + offset, 0, None)
denom = b08 + b04
valid = clear & (denom > 0)
ndvi = (b08[valid] - b04[valid]) / denom[valid]
Where the library stops
- Every date downloads the full ~1 GB product, even though the field is a few hundred
pixels. CDSE OData serves whole products.
--every-days 7bounds this to about 26 downloads for a 6-month season. - The pick in each window uses tile-level cloud cover. The least cloudy tile can still have a cloud over the field. The SCL mask catches most of these, but SCL sometimes misses thin cirrus and the edges of cloud shadows.
- Search is by bounding box only. The polygon is applied afterwards, in numpy.
- Since processing baseline 04.00 (January 2022), L2A DNs carry a -1000 offset, which
pulls NDVI towards zero. The script removes it, reading the value from
MTD_MSIL2A.xmlinside the product and falling back to the_Nxxxx_baseline in the name when that file is absent. examples/data/field_lombardy.geojsonis a sample area, not a real parcel.
Wildfire analyst: burn severity (dNBR)
A few weeks after a fire, civil protection needs a first map of burn severity and the hectares in each class. The standard measure is the drop in the Normalized Burn Ratio between a clear scene before the fire and one after it.
What you get (in ./output/wildfire_dnbr/)
dnbr.tif: float32 dNBR (NBR pre minus NBR post), NaN where maskeddnbr_severity.tif: uint8 USGS severity classes (0 masked, 1 unburned to 5 high) with an embedded colour tableseverity.png: severity map with hectares per class in the legendbefore_after.png: true-colour previews of the pre-fire and post-fire scenes- a hectares-per-class table printed to the console
Run
(wildfire_dnbr.py).
The default is the Montiferru fire in Sardinia, 24 July 2021.
pip install "cdse-client[processing]"
python examples/wildfire_dnbr.py
# = --bbox 8.45,40.05,8.75,40.25 --event-date 2021-07-24 --window-days 30 --max-cloud 20
For each date, NBR is computed from B8A and B12 at 20 m, with the BOA offset removed. The post-fire rasters are put onto the pre-fire grid, and only pixels clear on both dates count:
with rasterio.open(nbr_path["pre"]) as ref:
profile, transform = grid_profile(ref), ref.transform
nbr_pre = ref.read(1)
nbr_post = read_on_grid(nbr_path["post"], ref, Resampling.bilinear)
valid = (read_on_grid(clear["pre"], ref, Resampling.nearest) == 1) & (
read_on_grid(clear["post"], ref, Resampling.nearest) == 1
)
valid &= np.isfinite(nbr_pre) & np.isfinite(nbr_post)
dnbr = np.where(valid, nbr_pre - nbr_post, np.nan).astype(np.float32)
severity = classify(np.nan_to_num(dnbr), valid)
write_outputs(out, profile, dnbr, severity)
ha = hectares_per_class(severity, transform)
Where the library stops
- You need to know the event date and a bbox around it already. There is no lookup of fire perimeters and no polygon search.
- Each of the two dates downloads a full ~1 GB L2A product.
- Scenes are picked by tile-level cloud cover, which may not reflect cloud or smoke over the fire. The SCL mask (cloud, shadow, water and snow) catches most of it, but not all. The post window starts the day after the event, so a fire that lasted several days may still show smoke.
compute_index("nbr")works on raw DNs and ignores the -1000 offset of baseline 04.00 and later. With one old and one new product, unburned vegetation would show up as burned. The script computes NBR itself, reading the baseline from the product name.- The burned area is not converted to polygons. Use
rasterio.features.shapesondnbr_severity.tifif you need them. - The Key & Benson / USGS thresholds are generic. Calibrate them locally before any official use.
Emergency analyst: Sentinel-1 pre/post pairs
During a flood the sky is overcast, so optical imagery is useless and radar is the only option. SAR change detection only works when the before and after images come from the same relative orbit (track), so the first job is to find those pairs.
What you get (in ./output/s1_pre_post_pairs/)
pairs.csv: one row per track, with the latest pre-event and the earliest post-event GRD, the days between them, the polarisation and the bbox coveragepairs.geojson: the pre and post footprints of each pairdownloads/: with--download, the best pair as two ZIPs- a table of the pairs, sorted by days apart, printed to the console
Run
(s1_pre_post_pairs.py).
The default is the May 2023 Emilia-Romagna floods.
pip install "cdse-client[geo]"
python examples/s1_pre_post_pairs.py
# = --bbox 11.6,44.1,12.3,44.5 --event-date 2023-05-17 --window-days 24 --limit 100
Products are grouped by track, then split into before and after the event. Within a track, the script keeps the latest pre-event slice and the earliest post-event slice, choosing the slice that covers more of the bbox:
tracks: dict[str, dict] = {}
for p in products:
when = acquisition_time(p)
if when is None:
log.warning("Skipping %s: no acquisition time", p.name)
continue
key, exact = track_key(p)
entry = tracks.setdefault(key, {"exact": exact, "pre": [], "post": []})
# An acquisition on the event day itself counts as "post": it may already show it.
entry["pre" if when.date() < event else "post"].append((when, p))
pairs = []
for key, entry in tracks.items():
if not entry["pre"] or not entry["post"]:
continue
# Latest pre / earliest post; consecutive slices of one pass share a date, so
# prefer the slice that covers more of the area of interest.
pre_t, pre = max(entry["pre"], key=lambda t: (t[0].date(), bbox_coverage(t[1], bbox)))
post_t, post = min(entry["post"], key=lambda t: (t[0].date(), -bbox_coverage(t[1], bbox)))
Where the library stops
- It only finds and downloads. GRD data must be calibrated, speckle-filtered and terrain-corrected before change detection (SNAP, pyroSAR or the Sentinel Hub processing API). None of that is done here.
- Each GRD is about 1 GB, and
--downloadfetches two. - Search is by bbox only. The relative orbit comes from
sat:relative_orbitwhen the catalogue provides it. Otherwise it is computed from the absolute orbit in the name, which works for S1A and S1B only. Other platforms get an approximate track key, markedexact_track=False. - GRD products usually have no checksum in the STAC metadata, so
download_with_checksumonly logs a warning.
Journalist or planner: a city before and after
A side-by-side image of a city years apart shows new districts, dried-up rivers or burnt parks better than any description. Optionally, a map of where vegetation (NDVI) or water (NDWI) changed shows where to look.
What you get (in ./output/city_before_after/)
before_<date>.png/.tifandafter_<date>.png/.tif: true-colour preview and cropped RGB GeoTIFF for each datebefore_after.png: the two views side by side- with
--index ndvi|ndwi:<index>_a.tif,<index>_b.tif, and<index>_change.tif/.png(after minus before, NaN where there is no data or cloud) - the
cdse download --name ...command for each picked product
Run
(city_before_after.py)
pip install "cdse-client[processing]" matplotlib
python examples/city_before_after.py
# = --city milano --date-a 2019-07-15 --date-b 2025-07-15 --days 20 --max-cloud 15
# --index none --size 1200
python examples/city_before_after.py --city roma --index ndwi
The default dates fall on either side of the 2022 baseline change, so the index is computed from reflectance after removing each product's own offset, with SCL clouds masked:
bands = extract_bands_from_safe(zip_path, [band_a, band_b], tmp, resolution=10)
# SCL only exists at 20 m; its clear mask is put on the 10 m grid below.
scl = extract_bands_from_safe(zip_path, ["SCL"], tmp, resolution=20)["SCL"]
mask_tif = cloud_mask_from_scl(crop_to_bbox(scl, bbox, tmp / "scl.tif"), tmp / "m.tif")
with rasterio.open(crop_to_bbox(bands[band_a], bbox, tmp / "a.tif")) as src_a:
dn_a = src_a.read(1).astype(np.float32)
grid = {"crs": src_a.crs, "transform": src_a.transform}
with rasterio.open(crop_to_bbox(bands[band_b], bbox, tmp / "b.tif")) as src_b:
dn_b = src_b.read(1).astype(np.float32)
clear = np.zeros(dn_a.shape, dtype=np.uint8)
with rasterio.open(mask_tif) as src_m:
reproject(
rasterio.band(src_m, 1),
clear,
dst_transform=grid["transform"],
dst_crs=grid["crs"],
resampling=Resampling.nearest,
)
# DN 0 is the L2A no-data value (outside the swath), whatever the offset.
refl_a, refl_b = (dn_a + offset) / 10000, (dn_b + offset) / 10000
Where the library stops
- Each date downloads a full ~1 GB L2A product to crop a few km² of it.
- City boxes are rectangles from a small built-in table. There is no polygon search and no administrative boundary.
- Pixels are 10 m: districts, parks and roads are visible, single buildings are not.
- The cloud filter uses tile-level cloud cover. The index change masks clouds with SCL, but the RGB views do not.
- Each RGB view is stretched on its own (2nd to 98th percentile). Colours are for the eye, not for comparing brightness between dates, and a cloud over more than a few percent of the crop darkens the whole view.
compute_index()ignores the baseline 04.00 offset, so the script computes the index itself, reading the offset fromMTD_MSIL2A.xml.- Only the tile containing the bbox centre is searched, so a city that straddles two tiles may come out partly empty.
Air-quality analyst: a month of Sentinel-5P NO2
An environmental agency wants a monthly map of tropospheric NO2 over a region, to compare with ground stations or earlier months. Sentinel-5P passes over a region once or twice a day, and each orbit is a netCDF swath that has to be quality-filtered and averaged onto a regular grid.
What you get (in ./output/s5p_no2_monthly/)
no2_monthly.tif: monthly mean tropospheric NO2 in µmol/m², EPSG:4326, float32, NaN where no valid observation fell in a cellno2_monthly.png: the same grid as a map, with a colour barno2_counts.tif: the number of valid observations per celldownloads/: the NO2 products
Run
(s5p_no2_monthly.py).
The default is the Po Valley in January 2025.
pip install "cdse-client[processing]" xarray netCDF4
python examples/s5p_no2_monthly.py
# = --bbox 7.5,44.4,12.6,46.2 --month 2025-01 --limit 500 --qa 0.75 --grid 0.05
The search asks for NO2 products only and keeps one OFFL product per orbit. Each pixel with
qa_value > 0.75 inside the bbox is then added to the grid cell its centre falls in:
min_lon, min_lat, max_lon, max_lat = bbox
# qa_value > 0.75 is the recommended threshold for tropospheric NO2: it
# removes cloudy scenes, snow/ice and retrieval failures that bias the mean.
# Small negative columns are retrieval noise and are kept, or the mean over
# clean areas would be biased high.
keep = (
np.isfinite(no2)
& (qa_value > qa)
& (lon >= min_lon)
& (lon < max_lon)
& (lat > min_lat)
& (lat <= max_lat)
)
if not keep.any():
return 0
# Row 0 is the northern edge, matching a north-up GeoTIFF.
rows = ((max_lat - lat[keep]) / grid).astype(int).clip(0, sums.shape[0] - 1)
cols = ((lon[keep] - min_lon) / grid).astype(int).clip(0, sums.shape[1] - 1)
np.add.at(sums, (rows, cols), no2[keep])
np.add.at(counts, (rows, cols), 1)
Where the library stops
- cdse-client does not read netCDF. The script opens the files with xarray.
- Every OFFL product is a full orbit of several hundred MB, downloaded whole although only a small part covers the bbox. A month over the Po Valley is roughly 30 to 50 orbits, which means tens of GB. There is no cap on the number of products.
- Each pixel counts in the cell its centre falls in, without weighting by footprint area. For rigorous L3 products use harp or satpy.
- The catalogue mixes every S5P product type (NO2, CO, O3, CH4 and so on). The script
sends a CQL2 filter on
s5p:type. If the catalogue rejects the filter, the script falls back to an unfiltered search (up to 1000 results) and filters by name. - S5P downloads rely on the recent fix that appends
.ncto the OData product name. This example is the acceptance test for that fix.
Not yet verified against the live API
The scripts were run offline with synthetic products and a fake client. The assumptions below were not checked against the live CDSE services:
Search (STAC catalogue)
- The catalogue's default result order, and whether the default limits are enough (200 for a Sentinel-2 season, 100 for Sentinel-1, 500 filtered or 1000 unfiltered for a month of Sentinel-5P). Each script logs a warning when a search hits its limit.
- Sentinel-2 L2A items carry
datetimeandeo:cloud_cover, including 2019 products over Milano. Items with no datetime are dropped or sorted last. - The date range includes the whole end day. The client sends
{end}T23:59:59Z, but the service was not tested. - The tile containing the bbox centre covers the whole area of interest (Lodi field and Milano, assumed T32TNR; Montiferru, assumed T32TMK). Scenes under the cloud threshold exist in each default window.
- A search may return both the original and the reprocessed copy of an acquisition (for example N0301 and N0500). The scripts accept either one, and choose between them only by cloud cover and date.
- For
sentinel-1-grd, the catalogue returnssat:relative_orbit,sat:orbit_stateandsar:polarizations(ors1:polarization). Withoutsat:orbit_state,--directiondrops every product. - The collection may also contain EW/GRDM products, which are not filtered out.
- The catalogue accepts a CQL2-JSON
filterbody, withfilter-lang: cql2-json, onsat:orbit_stateand ons5p:type. - On Python 3.9 and 3.10, a STAC datetime whose fractional seconds do not have 3 or 6 digits does not parse. The Sentinel-1 script then falls back to the time in the name.
Product names
- Real L2A ZIPs contain
MTD_MSIL2A.xmlwithBOA_ADD_OFFSET, including the reprocessed N0500 copies of older dates. That file is what the three Sentinel-2 scripts read. Where it is missing they fall back on the baseline in the STAC id (_Nxxxx_), assuming the offset is exactly -1000 from N0400 onwards. - Sentinel-1 STAC ids follow the SAFE naming (start time in field 5, absolute orbit in
field 7), and the id plus
.SAFEis the exact OData name. - Sentinel-5P STAC ids are the standard file names
(
S5P_<OFFL|NRTI|RPRO>_L2__NO2____<start>_<end>_<orbit>_...) without.nc. cdse download --name <STAC id>finds the product (possibly through the prefix fallback) and saves it under the same file name the scripts expect.
Downloads and product contents
- Each download is saved as
<output_dir>/<product.name>.zip. - CDSE OData ZIPs of L2A products use the
<name>.SAFE/GRANULE/*/IMG_DATA/R10m|R20m/*_<band>_<res>.jp2layout. Real JPEG2000 decoding speed was not tested, because the synthetic products used GeoTIFF data under.jp2names. - SCL class codes behave as documented, and DN 0 is no-data in both the bands and SCL.
- Sentinel-1 GRD products have no checksum in STAC, so
download_with_checksumcannot verify them. - The OData lookup for Sentinel-5P works with the
.ncsuffix. The payload may be a ZIP or the raw netCDF file, and the script handles both. - Sentinel-5P NO2 files open with xarray (
group="PRODUCT", netCDF4 engine) and containnitrogendioxide_tropospheric_column,qa_value,latitudeandlongitude. - Product sizes and orbit counts per month are estimates.
Roadmap
Changes that would make these workflows cheaper:
- Per-band download. OData
Nodescan fetch single files from inside a product, so an NDVI date would cost two bands and SCL, a few tens of MB, instead of a ~1 GB ZIP. - Polygon search. A STAC
intersectssearch would find products for a field or a district outline instead of its bounding rectangle. - Server-side property filters. First-class support for CQL2 filters (orbit direction, relative orbit, product type) would replace the client-side filtering and the large search limits these scripts need.
- Offset-aware indices.
compute_index()does not remove the BOA offset of baseline 04.00 and later. Three of the scripts work around this. A fix in the library would make those workarounds unnecessary.