Match observations from any ocean data source against any other within a great-circle radius r and a time window delta_t. Built for SWOT × PACE matchups, but generalized so satellite swaths (SWOT, PACE, SST) and in-situ platforms (Argo, gliders, drifters) all colocate through one engine and mix freely: swot × {pace_bgc, argo}, argo × {swot, pace_iop}, etc.

The idea

Every source reduces to the same lingua franca — a flat cloud of point observations, ObsPoints (lon, lat, time, + a payload of variables). A source plugs in as an ObsProvider that knows how to index its granules (cheap: time bounds + optional bbox, no heavy I/O) and load the points in a region. Colocation is then a spatiotemporal join the engine performs on ObsPoints — it never imports swot/pace/… itself.

                 ┌──────────────┐   ObsPoints    ┌───────────────────────┐
  SWOT ──swot──▶ │              │ ─────────────▶ │  broad phase          │
  PACE ──pace─▶  │  ObsProvider │                │   time ± delta_t      │
  SNPP/GOES ──▶  │  .index()    │                │   bbox overlap        │
  RADS/AVISO ─▶  │  .load()     │                ├───────────────────────┤
  Argo/wind ──▶  │              │                │  narrow phase         │
  glider/… ───▶  │              │                │   ECEF KD-tree ball   │
                 └──────────────┘                │   |dt| ≤ delta_t      │
                                                 └───────────┬───────────┘
                                          matchup table  ◀───┴───▶  gridded NetCDF

Two-phase pruning keeps it efficient:

  1. Broad phase (metadata only). Prune granule pairs by time (± delta_t) and bounding box, so a file is opened only when it can match. Every provider consults a prebuilt lookup table (pass segments, granule bboxes, daily-map dates, station extents, or the Argo profile index) rather than walking the archive. SWOT goes further: ~120 km along-track segments plus predicted cycle times drop passes whose swath misses the region before opening any granule (~90% pruning for a regional query). See filter_index_bbox, Region.padded_time, and SwotProvider._prune_by_footprints.
  2. Narrow phase (loaded points). Convert lon/lat to spherical ECEF xyz (immune to longitude wrap and the pole singularity), build a scipy.cKDTree on the target cloud, and query_ball_point for all targets within r of each reference point — then gate on |t_ref − t_target| ≤ delta_t. See match_points.

Outputs

  • Matchup table (one parquet/CSV per target): one row per (reference point, target point) pair, columns namespaced by source (swot_ssha, pace_chlor_a, …) plus distance_km and signed dt_s. All-within-r gives a many-to-many table ready for statistics/averaging; --nearest-only keeps the closest.
  • Gridded co-registration (optional, --grid-res): each source binned onto a shared lat/lon grid (grid_points) as a NetCDF, for field-vs-field maps. For Gaussian/kriging swath remapping instead, use common.swath_regrid.

CLI

# SWOT SSHA vs PACE chlorophyll + Argo, Gulf Stream, ±6 h, 25 km
odsl-colocate --reference swot --targets pace_bgc,argo \
    --bbox -80 25 -60 45 --start 2024-05-01 --end 2024-05-03 \
    --delta-t 6h --radius 25 --out-dir tmp/coloc --grid-res 0.25

# Anchor on Argo profiles instead; match SWOT and PACE IOP
odsl-colocate --reference argo --targets swot,pace_iop \
    --delta-t 12h --radius 50 --out-dir tmp/coloc

odsl-colocate --list-providers

Console script odsl-colocatecolocate.colocate_cli:main.

Registered providers

Name Source Points
swot SWOT L2 LR SSH Expert (ssha) swath pixels
pace, pace_bgc, pace_aop, pace_iop, pace_par PACE OCI L2 by product swath pixels
argo Argo profiles (from the profile index) one point / profile
glider IOOS glider deployments trajectory points
drifter NOAA GDP surface drifters trajectory points
icesat2 ICESat-2 ATL07/ATL10 polar segments along-track segments
snpp Suomi-NPP VIIRS L2P SST (processed) swath pixels
geos, goes GOES ABI L2P SST (processed) swath pixels
rads RADS conventional nadir altimetry along-track samples
aviso, aviso_l4 AVISO DUACS L4 daily maps grid cells in the bbox
wind, insitu_wind unified in-situ wind stations station time series

Each PACE product is its own provider so they can be targeted independently. New SST / RADS / AVISO / wind sources prune against a prebuilt lookup table (granule bbox parquet, RADS pass-index CSV, L4 daily-map parquet, or station footprints) — they do not walk the archive on every query.

Native SWOT × ICESat-2 incidents

The specialized colocate_swot_icesat2.py workflow preserves the native SWOT half-swath and ATL10 along-track records. Build its coarse path indexes once:

python -m colocate.build_swot_icesat2_paths \
    --swot-root /spray/swot/versionD --icesat2-root /spray/icesat2 \
    --ref-cycle 2 --workers 16

This writes ESRI shapefiles (including their .dbf, .shx, .prj, and .cpg sidecars) to:

  • /spray/swot/versionD/footprints/swot_coarse_paths.shp
  • /spray/icesat2/footprints/atl10_coarse_paths.shp

Each simplified path carries a conservative cover_km envelope. The incident workflow loads these indexes automatically and rejects non-crossing granule/half-swath pairs before reading ATL10 segments or subsetting SWOT. Use --swot-paths and --icesat2-paths for non-default locations, or --no-coarse-path-pruning to retain the previous time-only broad phase.

Native SWOT × RADS pairs

The specialized colocate_swot_rads.py workflow writes one NetCDF4 file per SWOT Expert × RADS overflight. SWOT is the 2-D KaRIn swath; RADS is a 1-D nadir profile. A pair is kept only when that profile actually goes over the swath (default: ≥ 60 km of SWOT cross-track and both half-swaths). Native excerpts land in /swot, /rads, /profile (the line through the swath), and /matchup. Default time window is ±1 hour. SWOT SSHA is stored with crossover and HRET added back so it matches RADS ssha (which retains the internal tide).

odsl-colocate-swot-rads \
    --start 2024-08-20 --end 2024-08-23 \
    --out-dir /spray/swot/swot_rads

Or python -m colocate.colocate_swot_rads …. Pair names look like swot_c020_p001_x_j3_c386_p0042.nc. Use --rads-index if the documented pass-index CSV is missing (on Spray the Jason-3 cycle-repack copy is used automatically). --dry-run counts time-overlapping granule pairs without opening swaths.

Quick-looks (2×2 PNG: swath + RADS line, along-track SSHA, SSHA vs cross-track, SWOT vs RADS scatter) go to tmp/ by default:

odsl-colocate-swot-rads-plot /spray/swot/swot_rads/swot_c020_p395_x_j3_c387_p0150.nc \
    --output-dir tmp/swot_rads_png
odsl-colocate-swot-rads-plot /spray/swot/swot_rads --catalog-only --output-dir tmp/swot_rads_png

Keeping the registration fresh

Each provider prunes its query against a precomputed registration index — a footprint parquet (SWOT pass segments, PACE / SNPP / GOES / ICESat-2 granule bounding boxes, glider / drifter / wind station extents), the RADS pass-index CSV, the AVISO L4 daily-map table, or the Argo profile index — instead of opening every granule. These are what the engine "sees" without touching data files, so they must be refreshed after new downloads.

odsl-colocate-refresh unifies all of them behind one command:

odsl-colocate-refresh status                       # what is registered, counts, span, freshness
odsl-colocate-refresh refresh --all                # (re)build every index
odsl-colocate-refresh refresh --targets swot,pace,argo
odsl-colocate-refresh refresh --targets snpp,geos,rads,aviso,wind
odsl-colocate-refresh refresh --targets pace --pace-short-names PACE_OCI_L2_BGC PACE_OCI_L2_IOP

status (the default subcommand) reports, per dataset, whether the index exists, how many rows it holds, the time span it covers, and when it was last built — for example:

TARGET    STATUS            ROWS  SPAN                       UPDATED     DETAIL
swot      registered   96,360 segments  -                    2026-07-01  584 passes (predicted geometry)
pace      registered    1,708 granules  2024-03-05 .. 03-20  2026-07-01  PACE_OCI_L2_BGC
argo      registered  922,285 profiles  1997-07-28 .. now    2026-07-01  5231 floats

Targets are swot pace argo glider drifter icesat2 snpp geos rads aviso wind; pace_* / goes / aviso_l4 / insitu_wind map onto those indexes. Selecting a single target lets you override its scan root with --root. --rebuild (alias --force) forces a full rescan where the builder is incremental (Argo, glider, drifter, ICESat-2, SNPP, GOES). SWOT, PACE, RADS, AVISO, and wind rebuild or rescan their lookup tables. A target whose optional dependencies or data root are missing is reported and skipped, never fatal.

The individual builders still exist and are wrapped unchanged (odsl-swot-footprints, odsl-pace-footprints, odsl-snpp-footprints, odsl-geos-footprints, odsl-insitu-footprints --source {drifter,glider}, odsl-icesat2-footprints, odsl-aviso-index, odsl-wind-footprints, and odsl-argo-index); odsl-colocate-refresh is the one-stop front end over them. Every index is stored under the source's data root ({data_root}/footprints/…, /spray/argo/processed/… for Argo, or /spray/rads/data/lookup_tables/rads_pass_index.csv for RADS) and, for the incremental ones, re-running re-scans only changed files.

Adding a source

One file, one decorator — no engine changes to core.py:

from colocate.core import ObsPoints, ObsProvider, Region
from colocate.registry import register

class MyProvider(ObsProvider):
    name = "mysensor"
    def index(self, region: Region):
        # return a DataFrame with columns: path, t0, t1
        # (+ lon_min/lon_max/lat_min/lat_max to enable bbox broad-phase)
        ...
    def load(self, paths, region: Region) -> ObsPoints:
        # open files, return ObsPoints(source=self.name, lon, lat, time, data={...})
        ...

@register("mysensor")
def _make(**kwargs):
    return MyProvider(**kwargs)

Point sources (like Argo) may instead override load_region to build points straight from an index without per-file I/O. Providers register lazily on first use and a missing optional dependency for one source never breaks the others.

Two small wiring steps make it discoverable:

  1. Load it by default. Add the module name to the tuple in registry.py:_load_builtin_providers so get_provider/--list-providers find it without an explicit import.
  2. Make its registration refreshable (only if it prunes against a footprint or profile index). Add a RegistrationTarget in refresh_registration.py:_build_targets with the index's default path, a refresh_fn that wraps your builder, and a describe_fn that reports the row count / time span — then odsl-colocate-refresh status and … refresh --targets mysensor cover it too. Sources with no precomputed index (they scan on the fly) can skip this.

Design notes / caveats

  • Longitudes may be [-180,180) or [0,360); the ECEF match and the antimeridian-aware bbox test (_lon_interval_overlaps) handle both.
  • Time is datetime64[ns], naive UTC. NaT never satisfies the gate.
  • PACE carries per-scan times, so pixel-level dt is exact even though the broad-phase granule interval is nominal (±6 min).
  • Argo matchups carry wmo/cycle and, when the profile index was built with the current scanner, surface_temp_c / surface_psu (shallowest valid level). Open the full column with argo.argo_profiles.load_profile_levels.
  • Scale. The narrow phase loads all reference + target points in the region/window into memory. For very large global/long-window runs, chunk by time and concatenate matchup tables. The broad-phase file pruning already keeps typical regional runs small.

Tests

pytest colocate/tests      # analytic checks, no /spray data required