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:
- 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). Seefilter_index_bbox,Region.padded_time, andSwotProvider._prune_by_footprints. - Narrow phase (loaded points). Convert lon/lat to spherical ECEF xyz
(immune to longitude wrap and the pole singularity), build a
scipy.cKDTreeon the target cloud, andquery_ball_pointfor all targets withinrof each reference point — then gate on|t_ref − t_target| ≤ delta_t. Seematch_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, …) plusdistance_kmand signeddt_s. All-within-rgives a many-to-many table ready for statistics/averaging;--nearest-onlykeeps 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, usecommon.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-colocate → colocate.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:
- Load it by default. Add the module name to the tuple in
registry.py:_load_builtin_providerssoget_provider/--list-providersfind it without an explicit import. - Make its registration refreshable (only if it prunes against a footprint
or profile index). Add a
RegistrationTargetinrefresh_registration.py:_build_targetswith the index's default path, arefresh_fnthat wraps your builder, and adescribe_fnthat reports the row count / time span — thenodsl-colocate-refresh statusand… refresh --targets mysensorcover 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.NaTnever satisfies the gate. - PACE carries per-scan times, so pixel-level
dtis exact even though the broad-phase granule interval is nominal (±6 min). - Argo matchups carry
wmo/cycleand, when the profile index was built with the current scanner,surface_temp_c/surface_psu(shallowest valid level). Open the full column withargo.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