Cabo Verde hyperbolic LCS

The FTLE map (see cabo_verde_ftle) shows where the flow stretches. It does not give the material curves along which the stretching happens. Those curves are tensor lines of the strain tensor. Haller (2015, §5.1 / Table 1, doi:10.1146/annurev-fluid-010313-141322) constructs those curves directly from the Cauchy–Green strain tensor \(C = (\nabla F)^\top \nabla F\), whose eigenpairs satisfy \(C\,\xi_i = \lambda_i\,\xi_i\) with \(0 < \lambda_1 \le \lambda_2\) and \(\xi_1 \perp \xi_2\). A repelling LCS is a shrink line — a curve tangent to \(\xi_1\), i.e. orthogonal to the strong-stretch direction \(\xi_2\) that the FTLE ridge marks. It solves the ODE \(\dot r = \xi_1(r)\).

Attracting LCS come from the forward–backward duality (Haller & Sapsis 2011, doi:10.1063/1.3579597): an attracting LCS is a repelling LCS of the backward flow. The notebook therefore advects the same seed grid both ways and takes the \(\xi_1\) shrink lines of each flow map: the forward map gives the repelling LCS, the backward map the attracting ones.

# Importing Parcels pulls in the holoviews/bokeh bootstrap and prints an
# alpha-version notice, neither of which belongs on the rendered page.
import matplotlib.pyplot as plt
import numpy as np
import xarray as xr
from parcels import FieldSet, Particle, ParticleSet, StatusCode
from parcels.convert import copernicusmarine_to_sgrid
from parcels.kernels import AdvectionRK4

from lcs_parcels import (
    NeighborSeedGrid,
    ftle_ridge_seeds,
    prune_shrink_lines,
    shrink_lines,
)

Currents

The local file that get_data writes.

currents = xr.open_dataset("data/cabo_verde_currents_hourly.nc").load()
currents
<xarray.Dataset> Size: 53MB
Dimensions:    (time: 289, depth: 1, latitude: 145, longitude: 157)
Coordinates:
  * time       (time) datetime64[ns] 2kB 2025-07-31 ... 2025-08-12
  * depth      (depth) float32 4B 0.494
  * latitude   (latitude) float32 580B 10.0 10.08 10.17 ... 21.83 21.92 22.0
  * longitude  (longitude) float32 628B -30.5 -30.42 -30.33 ... -17.58 -17.5
Data variables:
    uo         (time, depth, latitude, longitude) float32 26MB 0.144 ... -0.1622
    vo         (time, depth, latitude, longitude) float32 26MB -0.1292 ... -0...
Attributes:
    Conventions:               CF-1.8
    area:                      Global
    contact:                   https://marine.copernicus.eu/contact
    credit:                    E.U. Copernicus Marine Service Information (CM...
    institution:               Mercator Ocean International
    licence:                   http://marine.copernicus.eu/services-portfolio...
    producer:                  CMEMS - Global Monitoring and Forecasting Centre
    references:                http://marine.copernicus.eu
    source:                    MOI GLO12
    title:                     hourly mean fields from Global Ocean Physics A...
    copernicusmarine_version:  2.4.1

Parcels v4 field set

copernicusmarine_to_sgrid tags the CMEMS A-grid with SGRID metadata; from_sgrid_conventions wraps it as a spherical FieldSet.

sgrid = copernicusmarine_to_sgrid(fields={"U": currents["uo"], "V": currents["vo"]})
fieldset = FieldSet.from_sgrid_conventions(sgrid, mesh="spherical")
z_surface = float(currents["depth"].values[0])

Seed grid and window

\(t_0\) sits at the middle of the window the local file covers, and the runs are \(\pm 5\) d, so the forward and the backward advection both stay inside the data. A rectilinear NeighborSeedGrid puts one particle on every diagnostic grid point; \(\nabla F\) is then differenced against the grid neighbours.

t0 = np.datetime64("2025-08-06")
T = np.timedelta64(5, "D")
resolution_deg = 1 / 25
seed_lon, seed_lat = (-27.0, -21.0), (13.5, 18.5)

lon_axis = np.arange(seed_lon[0], seed_lon[1] + 1e-9, resolution_deg)
lat_axis = np.arange(seed_lat[0], seed_lat[1] + 1e-9, resolution_deg)
seed = NeighborSeedGrid.from_axes(lon=lon_axis, lat=lat_axis)
seed
<NeighborSeedGrid 151x126 grid, lon -27.00..-21.00, lat 13.50..18.50>

Recovery kernel

Particles that leave the domain or hit land are turned into NaN in place (Parcels would otherwise abort the run), so losses propagate as NaN through every diagnostic below. StatusCode.EndofLoop rather than StatusCode.Delete: deleting shrinks the particle array and breaks the alignment with the seed order.

def set_lost_to_nan(particles, fieldset):
    lost = particles.state >= StatusCode.Error
    particles.x = np.where(lost, np.nan, particles.x)
    particles.y = np.where(lost, np.nan, particles.y)
    particles.state = np.where(lost, StatusCode.EndofLoop, particles.state)

Advect forward

to_parcels_pset emits the flat (lon, lat) pair; the finals go back in via pset_to_flowmap, which takes both times and derives the signed window \(T = t_1 - t_0\).

lon, lat = seed.to_parcels_pset()
z = np.full(len(lon), z_surface)
pset = ParticleSet(fieldset, pclass=Particle, x=lon, y=lat, z=z, t=t0)
pset.execute(
    [AdvectionRK4, set_lost_to_nan],
    dt=np.timedelta64(1, "h"),
    runtime=T,
    verbose_progress=False,
)
forward = seed.pset_to_flowmap(lon=pset.x, lat=pset.y, t0=t0, t1=t0 + T)
forward
<NeighborFlowMap 151x126 grid, lon -27.00..-21.00, lat 13.50..18.50,
                 t0 2025-08-06T00:00:00, T +5.0 days>

Advect backward

The same seed grid, same \(t_0\), negative dt, and \(t_1 = t_0 - T\) so the stored window is negative.

lon, lat = seed.to_parcels_pset()
z = np.full(len(lon), z_surface)
pset = ParticleSet(fieldset, pclass=Particle, x=lon, y=lat, z=z, t=t0)
pset.execute(
    [AdvectionRK4, set_lost_to_nan],
    dt=-np.timedelta64(1, "h"),
    runtime=T,
    verbose_progress=False,
)
backward = seed.pset_to_flowmap(lon=pset.x, lat=pset.y, t0=t0, t1=t0 - T)
backward
<NeighborFlowMap 151x126 grid, lon -27.00..-21.00, lat 13.50..18.50,
                 t0 2025-08-06T00:00:00, T -5.0 days>

Repelling LCS, step by step

The forward FTLE (from \(\lambda_2\) of the forward \(C\)) is the backdrop and its ridges are where repelling LCS live.

ftle_forward = forward.ftle()
ftle_forward
<xarray.DataArray 'ftle' (i: 151, j: 126)> Size: 152kB
array([[           nan,            nan,            nan, ...,
                   nan,            nan,            nan],
       [           nan, 1.35797213e-06, 1.23527600e-06, ...,
        4.40692559e-07, 4.79924914e-07,            nan],
       [           nan, 1.49540361e-06, 1.36816813e-06, ...,
        4.68802660e-07, 5.29881192e-07,            nan],
       ...,
       [           nan, 4.70773490e-07, 5.30224314e-07, ...,
        1.49028367e-06, 1.45351069e-06,            nan],
       [           nan, 5.14297214e-07, 6.16961180e-07, ...,
        1.58231754e-06, 1.56025693e-06,            nan],
       [           nan,            nan,            nan, ...,
                   nan,            nan,            nan]], shape=(151, 126))
Coordinates:
  * i         (i) int64 1kB 0 1 2 3 4 5 6 7 ... 143 144 145 146 147 148 149 150
  * j         (j) int64 1kB 0 1 2 3 4 5 6 7 ... 118 119 120 121 122 123 124 125
    lon_grid  (i, j) float64 152kB -27.0 -27.0 -27.0 -27.0 ... -21.0 -21.0 -21.0
    lat_grid  (i, j) float64 152kB 13.5 13.54 13.58 13.62 ... 18.42 18.46 18.5
    t0        datetime64[s] 8B 2025-08-06
    T         timedelta64[s] 8B 5 days
Attributes:
    long_name:  finite-time Lyapunov exponent
    units:      1/s

ftle_ridge_seeds picks seed points at the ridge tops: grid points that are the maximum over a square window centred on them and lie in the top quantile of the field. window_m is the full side of that window, so two seeds can be about half of it apart. The target here is neighbouring filaments about 15 km apart in this eddy field, so the window is 30 km. The top decile keeps the seeds on the pronounced ridges of this field. The alternative selector, ftle_min, is an absolute floor in 1/s; use it when several regions or windows have to be compared against one threshold.

# Ridge selection.
window_m, quantile = 30_000.0, 0.90
repelling_seeds = ftle_ridge_seeds(ftle_forward, window_m=window_m, quantile=quantile)
repelling_seeds
<xarray.Dataset> Size: 1kB
Dimensions:  (seed: 53)
Coordinates:
  * seed     (seed) int64 424B 0 1 2 3 4 5 6 7 8 ... 44 45 46 47 48 49 50 51 52
Data variables:
    lon      (seed) float64 424B -26.96 -26.68 -26.52 ... -22.2 -21.48 -21.04
    lat      (seed) float64 424B 16.02 14.22 16.46 15.66 ... 17.46 17.26 17.54
Attributes:
    long_name:              seed points at strong local maxima of the FTLE field
    selector:               quantile
    ftle_threshold:         2.655289946349675e-06
    window_m:               30000.0
    window_cells_i:         7
    window_cells_j:         7
    grid_spacing_i_m:       4275.496690165162
    grid_spacing_j_m:       4447.797065782255
    min_seed_separation_m:  16871.804684152154

The seed dataset records how the window came out on this grid. The distance below is the closest two seeds can be: half the window, not the window.

repelling_seeds.attrs["min_seed_separation_m"]
16871.804684152154

shrink_lines integrates \(\dot r = \xi_1(r)\) through each seed, half the length either way. The 3 km step is below the seed-grid cell of about 4.4 km, so the RK2 trace samples every cell it crosses. The 1500 km cap is longer than the box diagonal, so a curve ends by leaving the grid, by hitting a NaN cell, or by dropping below min_anisotropy, the floor on \(\lambda_2 / \lambda_1\) below which \(\xi_1\) is no longer a well-defined direction. That floor is a ratio, so it does not depend on the window or on the stretching rate of the flow, and the default of 1.15 also applies to a longer window or a faster flow.

# Line integration.
step_m, line_length_m, min_anisotropy = 3_000.0, 1_500_000.0, 1.15
repelling_lcs = shrink_lines(
    forward,
    seed_lon=repelling_seeds["lon"],
    seed_lat=repelling_seeds["lat"],
    step_m=step_m,
    line_length_m=line_length_m,
    min_anisotropy=min_anisotropy,
)
repelling_lcs
<xarray.Dataset> Size: 429kB
Dimensions:  (line: 53, point: 501)
Coordinates:
  * line     (line) int64 424B 0 1 2 3 4 5 6 7 8 ... 44 45 46 47 48 49 50 51 52
  * point    (point) int64 4kB 0 1 2 3 4 5 6 7 ... 494 495 496 497 498 499 500
Data variables:
    lon      (line, point) float64 212kB nan nan nan nan nan ... nan nan nan nan
    lat      (line, point) float64 212kB nan nan nan nan nan ... nan nan nan nan

The lines come back as a fixed (line, point) rectangle, NaN-padded past termination: every line has the same number of columns, and the curves have different lengths.

repelling_lcs["lon"].isnull().sum("point")
<xarray.DataArray 'lon' (line: 53)> Size: 424B
array([484, 413, 401, 399, 400, 444, 413, 441, 450, 204, 501, 358, 501,
       188, 359, 291, 231, 501, 352, 325, 472, 498, 483, 440, 359, 330,
       501, 358, 501, 434, 418, 501, 501, 456, 501, 440, 455, 323, 501,
       501, 330, 501, 300, 501, 501, 453, 501, 379, 373, 373, 351, 419,
       501])
Coordinates:
  * line     (line) int64 424B 0 1 2 3 4 5 6 7 8 ... 44 45 46 47 48 49 50 51 52
Attributes:
    long_name:  longitude along the shrink line
    units:      degrees_east

Rows that are NaN in every column traced no curve. About a quarter of the seeds here sit close to land, so the interpolated tensor is NaN at the seed and no curve is produced.

untraceable = repelling_lcs["lon"].isnull().all("point")
int(untraceable.sum()), repelling_lcs.sizes["line"]
(15, 53)

Pruning the bundles

A ridge longer than window_m takes several seeds along it, and a wide ridge takes seeds across it. Those seeds lie on nearly the same tensor line, so the traced set holds bundles of near-copies of one curve.

prune_shrink_lines ranks the lines by the FTLE integrated along each and walks from the strongest down. A line is dropped when it runs inside a tube of radius window_m / 2 around a line already kept and the stretch it spends outside that tube is shorter than window_m. Rows that traced no curve are dropped before the ranking. Both distances come from the window_m the seeds were picked with, which is the only parameter the step takes.

repelling_lcs_pruned = prune_shrink_lines(
    repelling_lcs, ftle=ftle_forward, window_m=window_m
)
repelling_lcs_pruned
<xarray.Dataset> Size: 189kB
Dimensions:    (line: 23, point: 501)
Coordinates:
  * line       (line) int64 184B 1 2 3 5 6 9 11 13 ... 35 36 37 42 45 48 50 51
  * point      (point) int64 4kB 0 1 2 3 4 5 6 7 ... 494 495 496 497 498 499 500
Data variables:
    lon        (line, point) float64 92kB nan nan nan nan ... nan nan nan nan
    lat        (line, point) float64 92kB nan nan nan nan ... nan nan nan nan
    length_m   (line) float64 184B 2.61e+05 2.97e+05 ... 4.47e+05 2.43e+05
    ftle_mean  (line) float64 184B 1.733e-06 1.849e-06 ... 1.972e-06 1.877e-06
Attributes:
    long_name:         shrink lines with near-duplicates of a stronger line d...
    window_m:          30000.0
    tube_radius_m:     15000.0
    min_new_length_m:  30000.0
    n_lines_in:        53
    n_lines_dropped:   30

Lines kept, against the rows that went in.

repelling_lcs_pruned.sizes["line"], repelling_lcs.sizes["line"]
(23, 53)

The dropped curves in light grey under the kept ones in red.

dropped = repelling_lcs.drop_sel(line=repelling_lcs_pruned["line"].values)
fig, ax = plt.subplots()
ftle_forward.plot.pcolormesh(x="lon_grid", y="lat_grid", ax=ax, cmap="Greys")
ax.plot(dropped["lon"].values.T, dropped["lat"].values.T, color="lightgrey", lw=0.8)
ax.plot(
    repelling_lcs_pruned["lon"].values.T,
    repelling_lcs_pruned["lat"].values.T,
    color="tab:red",
    lw=0.8,
)
plt.show()
../_images/65eef461790bfe818b790adf50d96b0b51da15f496246d41d4720389f7489c67.png

Attracting LCS, in one call

The backward flow map runs the same steps, and hyperbolic_lcs packs them into one call: it computes the FTLE once, hands it to the ridge finder, prunes the traced lines, and returns the curves together with the field the seeds were picked from. The attracting family therefore arrives pruned already. Hyperbolic because elliptic LCS are a different family.

attracting_lcs = backward.hyperbolic_lcs(
    window_m=window_m,
    quantile=quantile,
    step_m=step_m,
    line_length_m=line_length_m,
    min_anisotropy=min_anisotropy,
)
attracting_lcs
<xarray.Dataset> Size: 704kB
Dimensions:    (line: 30, point: 501, i: 151, j: 126)
Coordinates:
  * line       (line) int64 240B 1 2 4 5 7 8 9 10 11 ... 37 39 43 45 46 47 49 50
  * point      (point) int64 4kB 0 1 2 3 4 5 6 7 ... 494 495 496 497 498 499 500
  * i          (i) int64 1kB 0 1 2 3 4 5 6 7 ... 143 144 145 146 147 148 149 150
  * j          (j) int64 1kB 0 1 2 3 4 5 6 7 ... 118 119 120 121 122 123 124 125
    lon_grid   (i, j) float64 152kB -27.0 -27.0 -27.0 ... -21.0 -21.0 -21.0
    lat_grid   (i, j) float64 152kB 13.5 13.54 13.58 13.62 ... 18.42 18.46 18.5
    t0         datetime64[s] 8B 2025-08-06
    T          timedelta64[s] 8B -5 days
Data variables:
    lon        (line, point) float64 120kB nan nan nan nan ... nan nan nan nan
    lat        (line, point) float64 120kB nan nan nan nan ... nan nan nan nan
    length_m   (line) float64 240B 6.3e+04 1.83e+05 ... 2.94e+05 2.76e+05
    ftle_mean  (line) float64 240B 1.305e-06 1.955e-06 ... 1.475e-06 1.503e-06
    ftle       (i, j) float64 152kB nan nan nan nan nan ... nan nan nan nan nan
Attributes: (12/13)
    long_name:              attracting LCS: shrink lines of the backward flow...
    window_m:               30000.0
    tube_radius_m:          15000.0
    min_new_length_m:       30000.0
    n_lines_in:             51
    n_lines_dropped:        21
    ...                     ...
    ftle_threshold:         2.571217737960336e-06
    window_cells_i:         7
    window_cells_j:         7
    grid_spacing_i_m:       4275.496690165162
    grid_spacing_j_m:       4447.797065782255
    min_seed_separation_m:  16871.804684152154

Repelling LCS over the forward FTLE

The pruned set of curves. The seed points are dotted, which shows whether they sit on the ridge tops and stay separated. The FTLE gets a greyscale so the coloured curves stand out against it. The dots with no curve through them are the untraceable seeds and the seeds whose line was pruned.

fig, ax = plt.subplots()
ftle_forward.plot.pcolormesh(x="lon_grid", y="lat_grid", ax=ax, cmap="Greys")
ax.plot(
    repelling_lcs_pruned["lon"].values.T,
    repelling_lcs_pruned["lat"].values.T,
    color="tab:red",
    lw=0.8,
)
ax.scatter(repelling_seeds["lon"], repelling_seeds["lat"], s=8, color="tab:red")
plt.show()
../_images/8ecd256317f7c7f64fb43e63f612345f9f3563f975c77a3248031f937e18f10d.png

Attracting LCS over the backward FTLE

Same picture for the backward flow map, straight out of the one dataset hyperbolic_lcs returned.

fig, ax = plt.subplots()
attracting_lcs["ftle"].plot.pcolormesh(x="lon_grid", y="lat_grid", ax=ax, cmap="Greys")
ax.plot(
    attracting_lcs["lon"].values.T,
    attracting_lcs["lat"].values.T,
    color="tab:blue",
    lw=0.8,
)
plt.show()
../_images/e556fb00ed7ab771cf5a68fc5947e9b80db54599e5954c31957f0da464a540de.png

Both families together

Both families over the forward FTLE in one frame. Where a repelling and an attracting curve cross, the flow is locally hyperbolic.

fig, ax = plt.subplots()
ftle_forward.plot.pcolormesh(x="lon_grid", y="lat_grid", ax=ax, cmap="Greys")
ax.plot(
    repelling_lcs_pruned["lon"].values.T,
    repelling_lcs_pruned["lat"].values.T,
    color="tab:red",
    lw=0.8,
)
ax.plot(
    attracting_lcs["lon"].values.T,
    attracting_lcs["lat"].values.T,
    color="tab:blue",
    lw=0.8,
)
plt.show()
../_images/09a4460e384c9f83f0f1bd5c94057d38e999c8681d0bd452886108b8ed2325ca.png