Finding the Nearest Ocean Pixel: How cKDTree Works#

Context: Used in SI_code1_PreProcessing_finalized.ipynb β€” the get_nearest_valid_value() function snaps each sediment core site to the nearest valid (non-land) pixel in a gridded dataset.


The Problem#

Gridded datasets (e.g., World Ocean Atlas temperature, WOA nitrate) store data on a regular lat/lon grid. Pixels over land are NaN. A sediment core site near a coastline may fall exactly on a land pixel β€” so we can’t just do a direct lookup. We need to find the nearest ocean pixel instead.

  Grid (each cell = 1Β° Γ— 1Β°):

  ~~~  ~~~  ~~~  ~~~  ~~~  ~~~
  ~~~  ~~~  ~~~  ~~~  ~~~  ~~~
  ~~~  ~~~  NaN  NaN  NaN  ~~~     NaN = land
  ~~~  ~~~  NaN  NaN  NaN  ~~~
  ~~~  ~~~  ~~~  ~~~  ~~~  ~~~

  Core site ✦ falls here:
                 ↓
  ~~~  ~~~  ~~~  NaN  NaN  ~~~
                  ✦ ← oops, this is land!

We want to automatically jump to the nearest ~~~ (ocean) cell.


Step 1 β€” Flatten and Drop Land#

The 2D grid is stacked into a 1D list of points, then all NaN (land) entries are dropped.

Before stacking (2D grid):

  col→   0     1     2     3     4
row 0: [~~~] [~~~] [~~~] [~~~] [~~~]
row 1: [~~~] [~~~] [NaN] [NaN] [NaN]
row 2: [~~~] [~~~] [NaN] [NaN] [NaN]

After .stack():
  [(0,0,~~~), (0,1,~~~), (0,2,~~~), (0,3,~~~), (0,4,~~~),
   (1,0,~~~), (1,1,~~~), (1,2,NaN), (1,3,NaN), (1,4,NaN),
   (2,0,~~~), (2,1,~~~), (2,2,NaN), (2,3,NaN), (2,4,NaN)]

After .dropna():
  [(0,0,~~~), (0,1,~~~), (0,2,~~~), (0,3,~~~), (0,4,~~~),
   (1,0,~~~), (1,1,~~~),
   (2,0,~~~), (2,1,~~~)]
                ↑
         land pixels gone β€” only ocean remains

We now have a 1D array of (lat, lon, value) for ocean points only.


Step 2 β€” Build the cKDTree#

Why not just check every point?#

With a global 1Β° grid, there are ~180 Γ— 360 = 64,800 grid cells, most of them ocean. If you have 500 core sites and check each against every ocean point:

500 sites Γ— ~40,000 ocean points = 20,000,000 comparisons  😬

A KD-Tree organizes points spatially so you only check a tiny fraction.


What the tree looks like#

A KD-Tree recursively splits points along alternating axes (latitude, then longitude, then latitude, …). Each split divides the remaining points roughly in half.

                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                    β”‚       ALL OCEAN POINTS       β”‚
                    β”‚  (lat/lon pairs, no NaNs)    β”‚
                    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                   β”‚
                          Split on LATITUDE
                          ─────────────────
                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                    β”‚                             β”‚
              lat < 20Β°N                     lat β‰₯ 20Β°N
           (tropical ocean)              (mid/high latitude)
                    β”‚                             β”‚
           Split on LONGITUDE             Split on LONGITUDE
           ──────────────────             ──────────────────
          β”Œβ”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”               β”Œβ”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”
        lon<0Β°        lonβ‰₯0Β°          lon<0Β°        lonβ‰₯0Β°
       (Atlantic)   (Indo-Pac)      (N. Atl.)    (N. Pac.)
          β”‚             β”‚               β”‚             β”‚
         ...           ...             ...           ...
          β”‚
    [small bucket
     of ~10-20 pts]  ← only these get compared at query time

Each level halves the search space. With 40,000 ocean points, it takes only ~logβ‚‚(40,000) β‰ˆ 15 comparisons to reach the right bucket.


Speed comparison#

Method

Comparisons (500 sites, 40k ocean pts)

Brute force

20,000,000

cKDTree

~500 Γ— 15 = 7,500

The tree is built once and then queried 500 times cheaply.

The c in cKDTree means it is compiled in C via SciPy β€” much faster than a pure-Python implementation.


Step 3 β€” Query: Snap Each Core Site to Nearest Ocean Pixel#

For each core site, the tree navigates to the right branch and finds the closest ocean point.

Map view β€” finding nearest ocean for site ✦:

  🌊  🌊  🌊  🌊  🌊  🌊
  🌊  🌊  🌊  🌊  🌊  🌊
  🌊  🌊  πŸ”οΈ  πŸ”οΈ  πŸ”οΈ  🌊
  🌊  🌊  πŸ”οΈ  πŸ”οΈ  πŸ”οΈ  🌊
  🌊  🌊  🌊  🌊  🌊  🌊

             ✦  ← core site (on land pixel πŸ”οΈ)

  cKDTree search:
    Navigate tree β†’ reach bucket containing nearby points
    Measure distance to each point in bucket:

        🌊 ←── 2.1Β°    🌊 ←── 2.8Β°
        🌊 ←── 1.4Β° βœ“  πŸ”οΈ  (NaN, already removed)
        🌊 ←── 1.9Β°    πŸ”οΈ  (NaN, already removed)
                 ✦

  Winner: 🌊 at distance 1.4Β° β†’ return its value

The distances array returned by .query() can optionally be used to flag sites that are suspiciously far from any ocean (e.g., > 5Β°), which might indicate a data entry error in the core coordinates.


Putting It All Together#

get_nearest_valid_value(ds, target_lats, target_lons)
         β”‚
         β–Ό
  1. Stack 2D grid β†’ 1D list
  2. Drop NaN (land) β†’ ocean-only points
  3. Extract (lat, lon) coordinates of ocean points
         β”‚
         β–Ό
  4. cKDTree(ocean_coords)
     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
     β”‚  Build spatial index β”‚  ← one-time cost
     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
         β”‚
         β–Ό
  5. tree.query(target_coords)
     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
     β”‚ For each core site:            β”‚
     β”‚   Navigate tree β†’ find nearest β”‚  ← fast, repeated
     β”‚   Return index + distance      β”‚
     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
         β”‚
         β–Ό
  6. ocean_points.isel(grid_point=indices)
     β†’ return the actual data values
       (temperature, NO₃, etc.)
         β”‚
         β–Ό
     numpy array, shape (N_sites,)

Code Reference#

from scipy.spatial import cKDTree
import numpy as np

def get_nearest_valid_value(ds, target_lats, target_lons,
                            lat_dim='lat', lon_dim='lon'):

    # Steps 1–2: flatten the 2D grid into a 1D list and drop land (NaN) pixels.
    # .stack() converts the (lat, lon) grid into a single "grid_point" dimension.
    # .dropna() removes every point where the value is NaN (i.e., land),
    # leaving only valid ocean pixels.
    ocean_points = ds.stack(grid_point=(lat_dim, lon_dim)).dropna('grid_point')

    # Step 3: extract the (lat, lon) coordinates of the surviving ocean pixels.
    # ocean_coords is a 2-column array β€” one row per ocean pixel:
    #   [[lat_0, lon_0],
    #    [lat_1, lon_1],
    #    ...]
    ocean_coords = np.column_stack((ocean_points[lat_dim].values,
                                    ocean_points[lon_dim].values))

    # Step 4: build the KD-Tree from the ocean coordinates.
    #
    # A KD-Tree ("K-Dimensional Tree") is a spatial index that organises
    # points by recursively splitting them along alternating axes:
    #
    #   Level 0 β€” split on LATITUDE  (north vs south)
    #   Level 1 β€” split on LONGITUDE (east vs west)
    #   Level 2 β€” split on LATITUDE  again …
    #   …until each leaf holds only a handful of points.
    #
    # Diagram (schematic β€” not to scale):
    #
    #              ALL ~40 000 OCEAN POINTS
    #                        β”‚
    #              β”Œβ”€β”€β”€ lat < 0Β°? ───┐
    #           S. Hem.           N. Hem.
    #              β”‚                 β”‚
    #       β”Œβ”€lon < 0°─┐      β”Œβ”€lon < 0°─┐
    #      SW          SE    NW          NE
    #       β”‚           β”‚     β”‚           β”‚
    #      ...         ...   ...    [~600 pts]
    #                               β”‚
    #                        β”Œβ”€ lat < 45Β°? ─┐
    #                     [~300]          [~300]
    #                        β”‚
    #                      … keep splitting …
    #                        β”‚
    #                   [leaf: ~15 pts]   ← only these are compared
    #
    # Building the tree is a one-time O(N log N) cost.
    # Each subsequent query is only O(log N) β€” ~15 comparisons instead of 40 000.
    #
    # The 'c' prefix means this is SciPy's C-compiled implementation,
    # far faster than a pure-Python KD-Tree.
    tree = cKDTree(ocean_coords)

    # Step 5: query β€” for every core site, find the index of the nearest ocean pixel.
    # target_coords has the same 2-column format as ocean_coords:
    #   [[site_lat_0, site_lon_0],
    #    [site_lat_1, site_lon_1], ...]
    # .query() traverses the tree for each row and returns:
    #   distances β€” great-circle-ish distance to the nearest ocean pixel
    #               (useful for flagging sites unreasonably far from ocean, e.g. > 5Β°)
    #   indices   β€” position in ocean_points of the nearest pixel
    target_coords = np.column_stack((target_lats, target_lons))
    distances, indices = tree.query(target_coords)

    # Step 6: use the indices to pull the actual data values
    # (temperature, NO₃, etc.) from ocean_points and return as a plain numpy array.
    return ocean_points.isel(grid_point=indices).values

Longitude Convention Sensitivity#

TL;DR β€” the tree uses plain Euclidean distance, so both the dataset and your target coordinates must use the same longitude convention, and neither should straddle the Β±180Β°/0°–360Β° boundary.

Two failure modes#

1. Convention mismatch β€” dataset on 0–360, core sites on -180–180 (or vice versa):

Dataset lon  = 350Β°   (eastern Atlantic, 0–360 convention)
Core site lon = -10Β°  (same location,   -180–180 convention)

Tree sees: |350 βˆ’ (βˆ’10)| = 360Β°  ← thinks they're on opposite sides of Earth
Truth:                        0Β°

2. Antimeridian wraparound β€” points near Β±180Β° are far apart in Euclidean space even when geographically adjacent:

  -180/180 convention:
    Point A: lon =  179Β°  ]
    Point B: lon = -179Β°  ]  2Β° apart in reality

    Tree distance: |179 βˆ’ (βˆ’179)| = 358Β°  ← treated as near-antipodal

This is a real issue for Pacific sediment cores near the date line.

Diagnosis#

Check before calling the function:

print(ds[lon_dim].values.min(), ds[lon_dim].values.max())
# 0–360 dataset:   0.5 ... 359.5
# -180/180 dataset: -179.5 ... 179.5

print(target_lons.min(), target_lons.max())
# must match the dataset convention

Fix: normalise to the same convention#

Pick one convention and convert both sides before building the tree.

# Helper: force any longitude array into -180 to +180
def to_180(lon):
    return ((np.asarray(lon) + 180) % 360) - 180

# Use on the dataset coordinate
ds[lon_dim] = to_180(ds[lon_dim].values)

# Use on target coordinates
target_lons_fixed = to_180(target_lons)

result = get_nearest_valid_value(ds, target_lats, target_lons_fixed)

Or convert to 0–360 instead β€” either is fine, just be consistent.

Robust alternative: 3-D Cartesian tree#

Converting lat/lon to 3-D (x, y, z) on the unit sphere eliminates both problems β€” Euclidean distance in 3-D space is a monotone function of great-circle distance and has no seam:

def latlon_to_xyz(lat, lon):
    lat_r = np.radians(lat)
    lon_r = np.radians(lon)
    x = np.cos(lat_r) * np.cos(lon_r)
    y = np.cos(lat_r) * np.sin(lon_r)
    z = np.sin(lat_r)
    return np.column_stack((x, y, z))

# Build tree in 3-D
ocean_xyz   = latlon_to_xyz(ocean_points[lat_dim].values,
                             ocean_points[lon_dim].values)
target_xyz  = latlon_to_xyz(target_lats, target_lons)

tree = cKDTree(ocean_xyz)
distances, indices = tree.query(target_xyz)
# distances are now chord lengths (0–2), not degrees

This version is insensitive to longitude convention and works correctly across the date line and poles.


See Also#