Cabo Verde FTLE¶
The minimal wiring between lcs_parcels and Parcels v4: seed a grid, advect it
through CMEMS surface currents, ingest the final positions, map the forward
FTLE. One stencil (NeighborSeedGrid); nothing tuned for speed.
The currents come from the local file data/cabo_verde_currents_hourly.nc;
run get_data.ipynb once to produce it.
# Importing Parcels pulls in the holoviews/bokeh bootstrap and prints an
# alpha-version notice, neither of which belongs on the rendered page.
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
Parameters¶
Ten days is roughly one eddy turnover time at these latitudes: the FTLE map below comes out around 0.1/day over most of the box, an e-folding time of about ten days. That is long enough for the stretching to separate neighbouring particles by more than one seed-grid cell (\(1/25^\circ\), about 4.4 km), and short enough that the seed grid still resolves where they went. The seed spacing is finer than the \(1/12^\circ\) currents, so the differencing stencil is not what limits the FTLE.
t0 = np.datetime64("2025-08-01")
T = np.timedelta64(10, "D") # signed window; sign(T) sets the direction
t1 = t0 + T
resolution_deg = 1 / 25 # seed-grid spacing
seed_lon, seed_lat = (-27.0, -21.0), (13.5, 18.5) # release box
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])
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
the FTLE. 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)
Seed grid¶
NeighborSeedGrid releases one particle per diagnostic grid point and differences
the flow-map gradient against the grid neighbours, so the stencil costs nothing
beyond the grid itself. The seed grid carries the release positions
lon_0/lat_0 and the diagnostic grid lon_grid/lat_grid, and no time.
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*Advect¶
to_parcels_pset() flattens the release positions to (lon, lat); Parcels
calls those x/y.
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,
)
Flow map¶
The final positions go back in the seeding order. pset_to_flowmap takes both t0 and
t1 and records the signed window T = t1 - t0.
flowmap = seed.pset_to_flowmap(lon=pset.x, lat=pset.y, t0=t0, t1=t1)
flowmap.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-01
T timedelta64[s] 8B 10 days
Data variables:
lon (i, j) float64 152kB -26.8 -26.81 -26.81 ... -22.97 -22.98 -22.98
lat (i, j) float64 152kB 13.56 13.61 13.66 13.69 ... 17.91 17.97 18.02FTLE¶
ftle = flowmap.ftle()
ftle
<xarray.DataArray 'ftle' (i: 151, j: 126)> Size: 152kB
array([[ nan, nan, nan, ...,
nan, nan, nan],
[ nan, 6.84820502e-07, 6.78707399e-07, ...,
1.10819273e-07, 3.37097757e-08, nan],
[ nan, 8.48859731e-07, 8.15442818e-07, ...,
9.12581386e-08, 7.39471630e-09, nan],
...,
[ nan, 3.42069555e-07, 4.18174074e-07, ...,
8.87432706e-07, 8.20252523e-07, nan],
[ nan, 3.44047083e-07, 3.33431956e-07, ...,
1.00762803e-06, 8.41161483e-07, 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-01
T timedelta64[s] 8B 10 days
Attributes:
long_name: finite-time Lyapunov exponent
units: 1/sMap¶
ftle() returns SI \(1/\mathrm{s}\), which over a 10-day window is a field of
numbers around \(10^{-6}\); rescaling to \(1/\mathrm{day}\) makes the map
readable. The scaling carries the name and long_name over, so only units
has to be corrected. The grid points are 2-D coords on the logical dims
(i, j), so x and y name the coords to use as axes; the labels come from
the metadata.
ftle_per_day = (ftle * 86400.0).assign_attrs(units="1/day")
_ = ftle_per_day.plot.pcolormesh(x="lon_grid", y="lat_grid")