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.1Parcels 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.15Extract 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.804684152154print(
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.804684152154Snapshots¶
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")