Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Sentinel-3 OLCI NDVI with AerEO

This chapter combines the Sentinel-3 OLCI pipeline with an NDVI preprocessor. It demonstrates how AerEO’s composable config lets you reuse an entire sensor pipeline and swap only the product calculation.

Environment setup

The first cell installs AerEO and the plugins needed for reading and reprojecting Sentinel-3 swath data. On Binder these are pre-installed, so pip will skip the download.

# Install AerEO and any required plugins for this notebook (Google Colab)
!pip install -q "aereo[swath,viz]" aereo-read-satpy

NASA Earthdata authentication

NASA-hosted Sentinel-3 data requires a valid ~/.netrc. See the VIIRS chapter for setup instructions.

Config used in this notebook

job_sentinel3-ndvi.yaml inherits from job_sentinel3.yaml and adds the NDVI preprocessor:

defaults:
  - job_sentinel3
  - _self_

name: sentinel3_ndvi
preprocess:
  - _target_: aereo.builtins.ndvi
    _partial_: true
    ndvi_nir_band: Oa17
    ndvi_red_band: Oa08

Note that the band names match the OLCI band naming convention (Oa08, Oa17) rather than Sentinel-2’s red/nir. Hydra composition means the rest of the OLCI config is inherited unchanged.

# Download config files and AOIs from the GitHub repository so this
# notebook can run outside the repo (e.g. Google Colab).
import os
import urllib.request

GITHUB_RAW = "https://raw.githubusercontent.com/frandorr/aereo/main"

os.makedirs("config/aoi", exist_ok=True)

# Config files
urllib.request.urlretrieve(
    f"{GITHUB_RAW}/examples/config/job_sentinel3.yaml",
    "config/job_sentinel3.yaml",
)
urllib.request.urlretrieve(
    f"{GITHUB_RAW}/examples/config/job_sentinel3-ndvi.yaml",
    "config/job_sentinel3-ndvi.yaml",
)

# AOI files
urllib.request.urlretrieve(
    f"{GITHUB_RAW}/examples/config/aoi/chocon.geojson",
    "config/aoi/chocon.geojson",
)

Loading the job

ExtractionJob.load_from_config() parses the YAML with Hydra, resolves every _target_ callable, and validates the resulting ExtractionJob. The config_name argument is the YAML filename without extension, and config_dir is the folder that contains it.

A job is a declarative bundle of pipeline steps. Once loaded, the same job can be searched, can have tasks built from it, and can be executed.

from aereo.cache import TaskResultCache
from aereo.executors import LocalExecutor
from aereo.pipeline import ExtractionJob

job = ExtractionJob.load_from_config(
    config_dir="config",
    config_name="job_sentinel3-ndvi",
)

Search and task building

job.search() calls the configured search provider and returns a GeoDataFrame of matched assets. This is a separate step from execution: it only discovers what data is available, without reading or writing anything.

job.build_tasks(assets) turns those assets into a list of ExtractionTask objects. Each task groups the assets needed for one grid cell and time slice, so the work can be parallelised later.

assets = job.search()  # Use the search method from the job object to get the assets.
tasks = job.build_tasks(assets)
len(tasks)
2026-07-08 15:06:54 [info     ] search_called                  provider=search_earthaccess
/root/repos/aereo/.venv/lib/python3.13/site-packages/earthaccess/results.py:348: FutureWarning: As of version 1.0, `DataGranule.size` will be accessed as an attribute; e.g. use `DataCollection.size` **not** `DataCollection.size()`
  self["size"] = self.size()
/root/repos/aereo/components/aereo/builtins/search.py:324: FutureWarning: As of version 1.0, `DataGranule.size` will be accessed as an attribute; e.g. use `DataCollection.size` **not** `DataCollection.size()`
  size_mb = g.size()
2026-07-08 15:06:59 [info     ] build_tasks_start              assets=3 builder=build_grouped_tasks
/root/repos/aereo/.venv/lib/python3.13/site-packages/pydantic/_internal/_validate_call.py:137: UserWarning: assets has no 'crs' column; assuming all assets share the same native CRS. Mixed-CRS assets in one task may fail or produce incorrect results.
  res = self.__pydantic_validator__.validate_python(pydantic_core.ArgsKwargs(args, kwargs))
3

The executor

For CPU-bound swath reprojection we use use_threads=False with process-based parallelism.

# now we create an Executor, in this case a LocalExecutor to run
# each ExtractionTask using Threads
local_exec = LocalExecutor(workers=2, use_threads=False, cache=TaskResultCache())

Running the extraction

job.execute(tasks, executor=...) hands the prepared tasks to the executor. The executor runs each task independently; inside every task AerEO performs the pipeline:

read -> preprocess -> reproject -> postprocess -> write

The returned object is a GeoDataFrame of artifacts — one row per output GeoTIFF with its metadata, CRS, and footprint.

# Extract!
print("Extracting...")
artifacts = job.execute(tasks, executor=local_exec)
print(f"✓ Extracted {len(artifacts)} artifacts")

Visualizing NDVI from OLCI

OLCI’s 300 m resolution gives a coarser view than Sentinel-2, but the swath width is much larger, making it well suited for regional vegetation monitoring.

from aereo.viz import plot_artifact_patches

plot_artifact_patches(
    artifacts,
    ds_factor=1,
    cmap="RdYlGn",
    stretch="percentile",
    aoi=job.target_aoi,
    aoi_edgecolor="blue",
)
(<Figure size 2000x1981.62 with 2 Axes>, <Axes: title={'center': 'Extracted Patches Spatial Overview'}, xlabel='UTM X', ylabel='UTM Y'>)
Ignoring fixed y limits to fulfill fixed data aspect with adjustable data limits.
<Figure size 2000x1981.62 with 2 Axes>