Cabo Verde LCS evolution

An extracted hyperbolic LCS (see cabo_verde_lcs) is a material curve: given its vertices at \(t_0\), the lon/lat that shrink_lines returns, its position at any other time is fixed by the flow map, \(\mathcal{M}(t) = F_{t_0}^{t}(\mathcal{M}(t_0))\) (Haller 2015, Eq. 5, doi:10.1146/annurev-fluid-010313-141322). The LCS is not re-diagnosed at each time: one curve, fixed at \(t_0\), is carried to each horizon.

The flow map \(F_{t_0}^{t}\) is the advected-position field a FlowMap stores, so the curve is evolved by interpolating that field at the curve’s vertices with FlowMap.image. The curve itself is never re-integrated.

Each family is evolved in its coherent direction, the one in which perturbations shrink. The attracting curve is therefore carried by the forward maps and the repelling curve by the backward maps. Each is evolved in the opposite direction to the one it was diagnosed on.

# 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

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 horizons

A rectilinear NeighborSeedGrid over the release box, anchored at the middle of the window the local file covers so five days fit either side of \(t_0\).

The horizons are daily, out to five days. At that cadence this flow moves a curve visibly and consecutive frames still overlap, so the sequence shows one curve deforming rather than a set of unrelated curves. The longest horizon is also the one the LCS is diagnosed at.

t0 = np.datetime64("2025-08-06")
horizons = np.arange(1, 6) * np.timedelta64(1, "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.ds
<xarray.Dataset> Size: 611kB
Dimensions:   (i: 151, j: 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
    lon_0     (i, j) float64 152kB -27.0 -27.0 -27.0 -27.0 ... -21.0 -21.0 -21.0
    lat_0     (i, j) float64 152kB 13.5 13.54 13.58 13.62 ... 18.42 18.46 18.5
Data variables:
    *empty*

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 to each horizon

The horizons are reached one leg at a time, so the five-day run is five days of integration rather than \(1 + 2 + 3 + 4 + 5\).

Each leg starts a new ParticleSet from the positions the previous leg ended at, released at that leg’s start time. Reusing one set across legs fails: a particle that beaches stops advancing, so its clock stays behind, and Parcels interpolates a particle set on the assumption that every particle shares one clock.

forward_maps, backward_maps = [], []
for direction, maps in ((1, forward_maps), (-1, backward_maps)):
    lon, lat = (np.asarray(a) for a in seed.to_parcels_pset())
    start = np.timedelta64(0, "s")
    for horizon in horizons:
        pset = ParticleSet(
            fieldset,
            pclass=Particle,
            x=lon,
            y=lat,
            z=np.full(lon.size, z_surface),
            t=t0 + direction * start,
        )
        pset.execute(
            [AdvectionRK4, set_lost_to_nan],
            dt=direction * np.timedelta64(1, "h"),
            runtime=horizon - start,
            verbose_progress=False,
        )
        lon, lat = np.asarray(pset.x).copy(), np.asarray(pset.y).copy()
        start = horizon
        maps.append(
            seed.pset_to_flowmap(lon=lon, lat=lat, t0=t0, t1=t0 + direction * horizon)
        )
forward_maps[-1].ds
<xarray.Dataset> Size: 915kB
Dimensions:   (i: 151, j: 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
    lon_0     (i, j) float64 152kB -27.0 -27.0 -27.0 -27.0 ... -21.0 -21.0 -21.0
    lat_0     (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       (i, j) float64 152kB -26.57 -26.61 -26.66 ... -21.66 -21.68 -21.7
    lat       (i, j) float64 152kB 13.28 13.32 13.36 13.41 ... 18.13 18.14 18.15

Extract the LCS at the longest window

The ridges are sharpest at the longest horizon, so the LCS are diagnosed there: repelling LCS from the forward flow, attracting LCS from the backward one (forward–backward duality, Haller & Sapsis 2011, doi:10.1063/1.3579597), each seeded at the local maxima of its own FTLE. FlowMap.hyperbolic_lcs runs that whole chain (FTLE, ridge seeds, shrink lines, pruning of the near-duplicate lines) in one call and reads repelling or attracting off the sign of its own window. Hyperbolic because elliptic LCS are a different family.

repelling_lcs = forward_maps[-1].hyperbolic_lcs()
attracting_lcs = backward_maps[-1].hyperbolic_lcs()
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
    ftle_mean  (line) float64 240B 1.305e-06 1.955e-06 ... 1.475e-06 1.503e-06
    length_m   (line) float64 240B 6.3e+04 1.83e+05 ... 2.94e+05 2.76e+05
    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:        9
    ...                     ...
    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
print(
    f"{repelling_lcs.sizes['line']} repelling, {attracting_lcs.sizes['line']} attracting lines"
)
23 repelling, 30 attracting lines

Evolve each family of material lines in its coherent direction

FlowMap.image interpolates a flow map at the curve’s vertices and returns lon/lat on the curve’s own (line, point) dims, so an image stacks directly with the curve it came from. Concatenating the curve at \(t_0\) with its image under each horizon map gives an evolution cube on (offset, line, point), offset being the signed offset from \(t_0\) in days.

The scalar T on each cube points the opposite way to its offset. That is correct: T is inherited provenance, the window the curve was diagnosed over, while offset is the direction the curve is being advected in. An attracting LCS is diagnosed backward (\(T < 0\)) and evolved forward.

offset_days = np.concatenate([[0.0], horizons / np.timedelta64(1, "D")])
OFFSET_ATTRS = {"long_name": "signed offset from t0", "units": "days"}
# The attracting curve rides the forward maps.
attracting_evo = xr.concat(
    [
        attracting_lcs[["lon", "lat"]],
        *(
            m.image(lon_0=attracting_lcs["lon"], lat_0=attracting_lcs["lat"])
            for m in forward_maps
        ),
    ],
    dim="offset",
).assign_coords(offset=("offset", offset_days, OFFSET_ATTRS))
attracting_evo
<xarray.Dataset> Size: 2MB
Dimensions:  (offset: 6, line: 30, point: 501)
Coordinates:
  * offset   (offset) float64 48B 0.0 1.0 2.0 3.0 4.0 5.0
  * 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
    lon_0    (line, point) float64 120kB nan nan nan nan nan ... nan nan nan nan
    lat_0    (line, point) float64 120kB nan nan nan nan nan ... nan nan nan nan
    t0       datetime64[s] 8B 2025-08-06
    T        timedelta64[s] 8B -5 days
Data variables:
    lon      (offset, line, point) float64 721kB nan nan nan nan ... nan nan nan
    lat      (offset, line, point) float64 721kB 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:        9
    ...                     ...
    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
# The repelling curve rides the backward maps.
repelling_evo = xr.concat(
    [
        repelling_lcs[["lon", "lat"]],
        *(
            m.image(lon_0=repelling_lcs["lon"], lat_0=repelling_lcs["lat"])
            for m in backward_maps
        ),
    ],
    dim="offset",
).assign_coords(offset=("offset", -offset_days, OFFSET_ATTRS))
repelling_evo
<xarray.Dataset> Size: 1MB
Dimensions:  (offset: 6, line: 23, point: 501)
Coordinates:
  * offset   (offset) float64 48B -0.0 -1.0 -2.0 -3.0 -4.0 -5.0
  * line     (line) int64 184B 1 2 3 5 6 9 11 13 15 ... 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
    lon_0    (line, point) float64 92kB nan nan nan nan nan ... nan nan nan nan
    lat_0    (line, point) float64 92kB nan nan nan nan nan ... nan nan nan nan
    t0       datetime64[s] 8B 2025-08-06
    T        timedelta64[s] 8B 5 days
Data variables:
    lon      (offset, line, point) float64 553kB nan nan nan nan ... nan nan nan
    lat      (offset, line, point) float64 553kB nan nan nan nan ... nan nan nan
Attributes: (12/13)
    long_name:              repelling LCS: shrink lines of the forward flow m...
    window_m:               30000.0
    tube_radius_m:          15000.0
    min_new_length_m:       30000.0
    n_lines_in:             53
    n_lines_dropped:        15
    ...                     ...
    ftle_threshold:         2.655289946349675e-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

Snapshots

Each family of material lines day by day, with its own \(t_0\) position drawn faintly in every panel for reference. The axes are shared and left to autoscale, so a curve that leaves the release box stays visible.

fig, axes = plt.subplots(2, len(offset_days), figsize=(16, 6), sharex=True, sharey=True)
lcs_families = [
    (attracting_evo, "tab:blue", "attracting"),
    (repelling_evo, "tab:red", "repelling"),
]
for row, (evo, color, name) in zip(axes, lcs_families, strict=True):
    for k, ax in enumerate(row):
        ax.plot(
            evo["lon"].isel(offset=0).T,
            evo["lat"].isel(offset=0).T,
            color="0.7",
            lw=0.6,
        )
        ax.plot(
            evo["lon"].isel(offset=k).T,
            evo["lat"].isel(offset=k).T,
            color=color,
            lw=0.8,
        )
        ax.set_title(f"{name}, offset {evo['offset'].isel(offset=k).item():+.0f} d")
../_images/017398818379c97f037f73b728d8d51db86507797847e6fc1b2ff7efd7baf350.png