Batch Computing Indices Across a STAC Collection
To run an index pipeline over an entire search, freeze the item list, make each item independently processable, and record the outcome of each:
items = list(search.items())
manifest = [{"id": i.id, "datetime": i.datetime.isoformat(), "tile": i.properties.get("s2:mgrs_tile")}
for i in items]
results = [process_item(entry) for entry in manifest] # or a pool over the same list
This is the operational form of the pipeline described in Spectral Index Calculation Pipelines.
Why This Arises in Remote Sensing Workflows
An index pipeline that works on one scene rarely works on ten thousand without change. The arithmetic is unchanged; what breaks is everything around it. One scene is missing an asset. One has a null CRS. One is 403 because a token expired mid-run. A worker is preempted. The process dies at 80 percent and nobody knows which scenes completed.
The structural answer is to make the item the unit of work and to make that unit idempotent: a function of an item identifier that produces a deterministic output and can be run twice with no harm. Every operational property follows from that — retry, resume, parallelism, partial delivery — and none of them are available if the pipeline is written as a loop that accumulates state.
Environment & Setup
| Package | Version | Why |
|---|---|---|
pystac-client |
≥0.7 | Building the work list |
rioxarray |
≥0.15 | Reads, index arithmetic, COG writes |
rasterio |
≥1.3.0 | Validation of existing outputs |
pandas |
≥2.0 | Manifest and status table |
pip install "pystac-client>=0.7" "rioxarray>=0.15" "rasterio>=1.3.0" "pandas>=2.0"
Complete Working Example
This module builds a frozen manifest, processes one item idempotently, and runs the whole list with failures isolated.
import os
import tempfile
from concurrent.futures import ThreadPoolExecutor, as_completed
import numpy as np
import pandas as pd
import rasterio
import rioxarray # noqa: F401
import xarray as xr
from rasterio.enums import Resampling
INDEX_DEFS = {
"ndvi": ("nir", "red"),
"ndwi": ("green", "nir"),
"nbr": ("nir", "swir22"),
}
ASSETS = {"red": "B04", "green": "B03", "nir": "B08", "swir22": "B12", "scl": "SCL"}
def build_manifest(search, path: str) -> pd.DataFrame:
"""Freeze the work list so the run is reproducible and resumable."""
rows = [{"id": i.id,
"datetime": i.datetime.isoformat(),
"tile": i.properties.get("s2:mgrs_tile"),
"cloud": i.properties.get("eo:cloud_cover")}
for i in search.items()]
df = pd.DataFrame(rows).sort_values("datetime")
df.to_parquet(path, index=False)
return df
def output_path(root: str, item_id: str, index: str) -> str:
"""Deterministic: the same item always maps to the same file."""
return os.path.join(root, index, f"{item_id}_{index}.tif")
def already_done(path: str) -> bool:
"""Skip only if the existing output is readable and complete."""
if not os.path.exists(path):
return False
try:
with rasterio.open(path) as src:
return src.width > 0 and src.count >= 1
except Exception:
return False # corrupt leftovers are reprocessed
def nd(a: xr.DataArray, b: xr.DataArray) -> xr.DataArray:
denom = a + b
return xr.where(denom != 0, (a - b) / denom, np.nan).astype("float32")
def process_item(item, root: str, indices=("ndvi",)) -> dict:
"""Compute the requested indices for one item. Never raises; returns a status row."""
status = {"id": item.id, "written": [], "skipped": [], "error": None}
try:
wanted = [ix for ix in indices
if not already_done(output_path(root, item.id, ix))]
status["skipped"] = [ix for ix in indices if ix not in wanted]
if not wanted:
return status
needed = {b for ix in wanted for b in INDEX_DEFS[ix]} | {"scl"}
bands = {}
for key in needed:
href = item.assets[ASSETS[key]].href
da = rioxarray.open_rasterio(href, masked=True, chunks={"x": 1024, "y": 1024})
bands[key] = da.squeeze(drop=True).astype("float32")
reference = bands[INDEX_DEFS[wanted[0]][0]]
for key, da in bands.items():
if da.rio.transform() != reference.rio.transform():
method = Resampling.nearest if key == "scl" else Resampling.bilinear
bands[key] = da.rio.reproject_match(reference, resampling=method)
clear = ~bands["scl"].isin([0, 1, 3, 8, 9, 10, 11])
for ix in wanted:
a_key, b_key = INDEX_DEFS[ix]
arr = nd(bands[a_key].where(clear), bands[b_key].where(clear))
arr.attrs.update(index=ix, item_id=item.id, datetime=item.datetime.isoformat())
dst = output_path(root, item.id, ix)
os.makedirs(os.path.dirname(dst), exist_ok=True)
fd, tmp = tempfile.mkstemp(suffix=".tif", dir=os.path.dirname(dst))
os.close(fd)
try:
arr.rio.to_raster(tmp, driver="COG", compress="DEFLATE", blocksize=512)
os.replace(tmp, dst) # atomic: a killed worker leaves no partial output
finally:
if os.path.exists(tmp):
os.remove(tmp)
status["written"].append(ix)
except Exception as exc:
status["error"] = repr(exc)
return status
def run(items, root: str, indices=("ndvi", "ndwi"), workers: int = 8) -> pd.DataFrame:
"""Process every item, isolating failures."""
rows = []
with ThreadPoolExecutor(max_workers=workers) as pool:
futures = [pool.submit(process_item, it, root, indices) for it in items]
for fut in as_completed(futures):
rows.append(fut.result())
df = pd.DataFrame(rows)
df.to_parquet(os.path.join(root, "status.parquet"), index=False)
return df
if __name__ == "__main__":
status = run(list(search.items()), root="out/indices")
print("ok:", int(status["error"].isna().sum()), "failed:", int(status["error"].notna().sum()))
print(status[status["error"].notna()].head())
Three properties make this operationally sound. The output path is a pure function of the item and the index, so nothing depends on run order. The write is atomic through a temporary file and os.replace, so a killed worker never leaves a half-written COG that the next run mistakes for a completed one. And process_item never raises, so one broken scene costs one row in the status table rather than the whole job.
Variant Patterns
1. Threads, processes or a cluster
For the distributed case the same process_item becomes a task submitted to a scheduler, as in Computing NDVI with xarray on a Dask Cluster — the function does not change, only who calls it.
2. Externalising the index definitions
Hard-coding INDEX_DEFS works until someone wants a new index. Moving them into configuration turns that into an edit rather than a deployment, which is the argument made in Building a YAML-Driven Multi-Index Pipeline.
3. Streaming a summary alongside the rasters
Most consumers of a batch index run want statistics, not imagery. Computing them during the same pass costs almost nothing extra, because the array is already in memory.
summary = {
"id": item.id, "index": ix,
"valid_fraction": float(arr.notnull().mean()),
"median": float(arr.median(skipna=True)),
"p10": float(arr.quantile(0.1, skipna=True)),
"p90": float(arr.quantile(0.9, skipna=True)),
}
Written to a table, these become a time series that can be plotted without touching a single raster again — the same shape recommended in Zonal Statistics and Vector–Raster Integration.
Monitoring a Long Run
Three numbers tell you whether a batch is healthy, and all three come from the status table.
The failure rate should be low and its causes few. A handful of failures with the same exception is a bug worth fixing; hundreds of distinct exceptions usually means credentials or network trouble rather than data trouble.
The skip rate tells you whether the run is doing what you think. On a resumed run it should start high and fall; on a first run it should be zero, and a non-zero value means outputs from an earlier configuration are being silently accepted.
Throughput, measured as items per minute, should be roughly flat. A steady decline usually means memory pressure building in a long-lived process, and a sudden collapse means throttling — the bucket asking for fewer requests, which is handled by backing off rather than by retrying harder, as covered in Retrying Failed Raster Tasks in a Prefect Pipeline.
Common Errors
The run stops on a single bad scene
An exception escaped the per-item function. Catch broadly inside process_item and record the error; only genuinely fatal conditions — bad credentials, missing output directory — should stop the run.
A resumed run reprocesses everything
The output path depends on something that changes between runs, such as a timestamp. Derive it from the item id alone.
Outputs exist but are unreadable
Writes were not atomic and a worker was killed mid-write. Write to a temporary file and os.replace it into place.
Frequently Asked Questions
Q: Should the unit of work be a scene or a tile? A scene, when indices are computed per acquisition and written per acquisition. It matches the STAC item, needs no coordination, and makes retry trivial. Split into sub-tiles only when a single scene exceeds a worker’s memory.
Q: Why write a manifest instead of re-running the search? Because a catalogue changes. Items get added, reprocessed or withdrawn, so two searches days apart return different sets and the run stops being reproducible. Freeze the list, then process it.
Q: How do I resume a run that died halfway? If each output path is deterministic and each write is atomic, resuming is simply re-running: completed items are skipped because their outputs exist and validate.
Related
- Spectral Index Calculation Pipelines — the parent topic and the index definitions being run.
- Building a YAML-Driven Multi-Index Pipeline — externalising what this batch computes.
- Processing COGs on AWS Batch with Docker — running the same per-item function as an array job.
- Estimating Per-Scene Cloud Cover in Python — screening the manifest before processing it.