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.

Multi-constellation extraction with AerEO

This chapter shows how to combine two independent AerEO jobs — one for VIIRS and one for GOES-19 ABI — and align them to the same area and time window. This is useful when you want to compare or fuse observations from different satellites.

The key idea is that each ExtractionJob is a self-contained pipeline. You can load several jobs, run their searches independently, align the results temporally, build tasks for each job, and then execute both sets of tasks with the same executor.

Environment setup

The first cell installs AerEO plus the plugins for reading VIIRS swath data, reprojecting swaths, and searching AWS GOES. 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 aereo-search-aws-goes

NASA Earthdata authentication and configs

The VIIRS part of this notebook reads NASA-hosted data, so a valid ~/.netrc is required. The GOES-19 part uses AWS open data and does not need NASA credentials. See the VIIRS chapter or the earthaccess authentication guide for setup instructions.

This notebook loads two jobs and aligns them to the same AOI:

  • config/job_viirs.yaml (see the VIIRS tutorial for the full file)

  • config/job_goes19.yaml (see the GOES-19 tutorial for the full file)

Both jobs are loaded with Hydra overrides so they use the same AOI and the VIIRS job uses a coarser alignment resolution:

viirs_job = ExtractionJob.load_from_config(
    config_dir="config",
    config_name="job_viirs",
    overrides=[
        "target_aoi=config/aoi/cordoba.geojson",
        "search.intersects=config/aoi/cordoba.geojson",
        "resolution=400",
        "+alignment_resolution=2000",
    ],
)

goes_job = ExtractionJob.load_from_config(
    config_dir="config",
    config_name="job_goes19",
    overrides=[
        "target_aoi=config/aoi/cordoba.geojson",
        "search.intersects=config/aoi/cordoba.geojson",
    ],
)
# 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",
)
urllib.request.urlretrieve(
    f"{GITHUB_RAW}/examples/config/job_goes19.yaml",
    "config/job_goes19.yaml",
)

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

Loading both jobs

ExtractionJob.load_from_config() is called twice, once per config. Each returned job is an independent pipeline with its own search, read, reproject, and write settings. The overrides make both jobs use the same AOI.

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

target_aoi_path = "config/aoi/cordoba.geojson"
# Load the job from the Hydra config package.
viirs_job = ExtractionJob.load_from_config(
    config_dir="config",
    config_name="job_viirs",
    overrides=[
        "target_aoi=config/aoi/cordoba.geojson",
        "search.intersects=config/aoi/cordoba.geojson",
        "resolution=400",
        "+alignment_resolution=2000",
    ],
)

goes_job = ExtractionJob.load_from_config(
    config_dir="config",
    config_name="job_goes19",
    overrides=[
        "target_aoi=config/aoi/cordoba.geojson",
        "search.intersects=config/aoi/cordoba.geojson",
    ],
)

Aligning VIIRS and GOES-19 in time

Instead of searching GOES-19 across an arbitrary time window, we first search VIIRS and then use the VIIRS result’s start and end times to drive the GOES-19 search. This guarantees that the two extracted datasets cover the same period.

start_time = viirs_assets.iloc[0].start_time
end_time   = viirs_assets.iloc[0].end_time
goes19_assets = goes_job.search(start_datetime=start_time, end_datetime=end_time)

This pattern is common in multi-sensor workflows: use the sensor with the narrower swath or rarer coverage to define the time window, then pull the other sensor for the same window.

# lets change daterange for the viirs search
viirs_assets = viirs_job.search(
    start_datetime="2026-01-01T10:00:00Z",
    end_datetime="2026-01-01T23:00:00Z",
    intersects=viirs_job.target_aoi,
)
2026-07-08 17:00:28 [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()
# now, lets get the viirs asset start_time and end_time and search the corresponding goes-19 assets
start_time = viirs_assets.iloc[0].start_time
end_time = viirs_assets.iloc[0].end_time
goes19_assets = goes_job.search(start_datetime=start_time, end_datetime=end_time)
2026-07-08 17:00:37 [info     ] search_called                  provider=search_aws_goes

Building tasks for both jobs

Tasks are built independently for each job because they have different grids, readers, and reprojectors. The resulting task lists can be executed by the same executor.

viirs_tasks = viirs_job.build_tasks(viirs_assets)
goes_tasks = goes_job.build_tasks(goes19_assets)
2026-07-08 17:00:39 [info     ] build_tasks_start              assets=2 builder=build_grouped_tasks
2026-07-08 17:00:39 [info     ] build_tasks_start              assets=2 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))
/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))

The executor

We use a LocalExecutor with use_threads=False and workers=2 to run tasks in separate processes. This is preferable when tasks are CPU-bound (e.g., swath reprojection) and you want to avoid the Python GIL.

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

Executing both pipelines

viirs_job.execute(...) and goes_job.execute(...) run their respective tasks through the shared executor. Inside each task the pipeline is read → preprocess/reproject → write. Search already happened in the earlier cells.

# Extract!
viirs_artifacts = viirs_job.execute(viirs_tasks, executor=local_exec)
# Extract!
goes_artifacts = goes_job.execute(goes_tasks, executor=local_exec)
2026-07-08 17:01:59 [info     ] execute_start                  executor=LocalExecutor task_count=2
2026-07-08 17:02:02 [debug    ] file_downloaded                engine=satpy local_path=/tmp/aereo_extraction/OR_ABI-L1b-RadF-M6C07_G19_s20260011810226_e20260011819546_c20260011819586.nc
2026-07-08 17:02:14 [debug    ] file_downloaded                engine=satpy local_path=/tmp/aereo_extraction/OR_ABI-L1b-RadF-M6C07_G19_s20260011800226_e20260011809546_c20260011809589.nc

Visualizing GOES-19 ABI

First we plot the GOES-19 ABI channel. Because GOES is geostationary, the patch may cover a larger area than the VIIRS swath.

from aereo.viz import plot_artifact_patches

fig, ax = plot_artifact_patches(
    goes_artifacts,
    ds_factor=1,
    stretch="percentile",
    cmap="RdYlGn",
    aoi=goes_job.target_aoi,
    aoi_edgecolor="blue",
)
Ignoring fixed y limits to fulfill fixed data aspect with adjustable data limits.
<Figure size 2000x1591.83 with 2 Axes>

Visualizing VIIRS

Next we plot the VIIRS I-band. The swath reprojection gives a local grid centered on the AOI.

fig, ax = plot_artifact_patches(
    viirs_artifacts,
    ds_factor=1,
    stretch="percentile",
    cmap="RdYlGn",
    aoi=viirs_job.target_aoi,
    aoi_edgecolor="blue",
)
Ignoring fixed y limits to fulfill fixed data aspect with adjustable data limits.
<Figure size 2000x1591.83 with 2 Axes>

Side-by-side comparison

By extracting both sensors for the same AOI and time window, you can directly compare observations, stack them as layers, or feed them into a downstream fusion model.

from aereo.viz import plot_artifact_patches

fig, ax = plot_artifact_patches(
    goes_artifacts,
    ds_factor=1,
    stretch="percentile",
    cmap="RdYlGn",
    aoi=goes_job.target_aoi,
    aoi_edgecolor="blue",
)
fig, ax = plot_artifact_patches(
    viirs_artifacts, ds_factor=1, stretch="percentile", cmap="Grays", ax=ax, alpha=0.3
)
Ignoring fixed y limits to fulfill fixed data aspect with adjustable data limits.
<Figure size 2000x1591.83 with 2 Axes>