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.

VIIRS extraction with AerEO

This chapter extracts VIIRS imagery from NASA’s Earthdata holdings. Unlike Sentinel-2, which is served through a public STAC API, VIIRS data requires authentication and is stored in a swath projection that must be reprojected to a regular grid.

AerEO hides most of that complexity behind the same ExtractionJob API. The config file selects a NASA-aware search provider, a Satpy-based reader, and a swath reprojection step.

Environment setup

The first cell installs AerEO plus the VIIRS-specific reader plugin (aereo-read-satpy) and the swath reprojection extra. 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 data access is handled by earthaccess. Before running the search or read steps, you need a valid ~/.netrc file containing your Earthdata login. If you see authentication errors, follow the earthaccess authentication guide.

In short:

  1. Register at urs.earthdata.nasa.gov.

  2. Link your application (e.g., LP DAAC, OB_DAAC, or GES DISC).

  3. Run earthaccess.login(persist=True) once, or write the credentials manually to ~/.netrc.

If you are running interactively, you can create the file with:

import os
from getpass import getpass

netrc_path = os.path.expanduser("~/.netrc")
with open(netrc_path, "w") as f:
    f.write(
        "machine urs.earthdata.nasa.gov login {username} password {password}\n".format(
            username=getpass("Earthdata username: "),
            password=getpass("Earthdata password: "),
        )
    )
os.chmod(netrc_path, 0o600)

Config used in this notebook

job_viirs.yaml configures a swath-based extraction:

aoi_path: config/aoi/chocon.geojson
name: viirs_sample
grid_dist: 50_000
grid_cells_margin: 10
target_aoi: ${aoi_path}
output_uri: /tmp/aereo_extraction
overwrite: false

search:
  _target_: aereo.builtins.search_earthaccess
  _partial_: true
  collections:
    VJ202IMG: ["I04"]
    VJ203IMG: []
  intersects: ${aoi_path}
  start_datetime: "2024-01-01T00:00:00Z"
  end_datetime: "2024-01-02T23:59:59Z"

read:
  _partial_: true
  _target_: aereo.read_satpy.read_satpy
  reader: viirs_l1b
  wishlist: [I04]
  downloader:
    _target_: aereo.asset_downloader.core._download_with_earthaccess
    _partial_: true

reproject:
  _target_: aereo.builtins.reproject_swath
  _partial_: true
reproject_mode: grid
resolution: 375

write:
  _target_: aereo.builtins.write.write_geotiff

Key differences from Sentinel-2:

  • search_earthaccess queries NASA CMR/DAAC holdings.

  • read_satpy uses Satpy to read VIIRS L1B files.

  • reproject_swath resamples the VIIRS swath to the output grid.

  • resolution: 375 matches the VIIRS I-band nominal resolution.

  • downloader is explicitly set to earthaccess to avoid S3 region retries.

# 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_viirs.yaml",
    "config/job_viirs.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

# Load the job from the Hydra config package.
job = ExtractionJob.load_from_config(
    config_dir="config",
    config_name="job_viirs",
)

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 14:51:23 [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 14:51:36 [info     ] build_tasks_start              assets=10 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))
5

The executor

For swath data the reprojection step is CPU-bound, so this notebook uses use_threads=False to run tasks with process-based parallelism (joblib’s loky backend). This avoids the Python GIL and the fork-after-read deadlock that can occur with netCDF/HDF5 state.

# 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 VIIRS

The output is a reprojected GeoTIFF at 375 m resolution. plot_artifact_patches overlays the extracted patches on the AOI so you can verify spatial coverage.

from aereo.viz import plot_artifact_patches

plot_artifact_patches(
    artifacts,
    ds_factor=1,
    cmap="viridis",
    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>